From d223981a93483cd0a241cad0f662ac6d502f3630 Mon Sep 17 00:00:00 2001 From: shats Date: Thu, 22 Jan 2026 19:07:34 +0700 Subject: [PATCH] feat(NetworkConfigManager, server): implement IPSec password management; add CRUD operations for IPSec passwords in the backend and integrate UI components for creating, editing, and deleting passwords in the frontend --- backend/routes/ipsecPasswordsRoutes.js | 258 +++++++++++++++++ backend/server.js | 8 + backend/utils/encryption.js | 86 ++++++ frontend/src/NetworkConfigManager.jsx | 379 ++++++++++++++++++++++++- 4 files changed, 726 insertions(+), 5 deletions(-) create mode 100644 backend/routes/ipsecPasswordsRoutes.js create mode 100644 backend/utils/encryption.js diff --git a/backend/routes/ipsecPasswordsRoutes.js b/backend/routes/ipsecPasswordsRoutes.js new file mode 100644 index 0000000..522c8bb --- /dev/null +++ b/backend/routes/ipsecPasswordsRoutes.js @@ -0,0 +1,258 @@ +/** + * Роуты для управления IPSec паролями + * Пароли хранятся в S3 в зашифрованном виде + */ + +const { sendError, sendOk } = require('../middleware/errorHandler'); +const { readS3TextObject, writeS3JsonObject, deleteS3Object } = require('../services/s3Service'); +const { encrypt, decrypt } = require('../utils/encryption'); + +const S3_KEY = 'network-config/ipsec-passwords.json'; + +/** + * GET /api/ipsec-passwords - получить список паролей (без расшифровки) + */ +async function getIpsecPasswords(req, res) { + try { + const data = await readS3TextObject(S3_KEY).catch(() => ({ body: '[]' })); + let passwords = []; + + try { + passwords = JSON.parse(data.body || '[]'); + if (!Array.isArray(passwords)) { + passwords = []; + } + } catch (parseError) { + passwords = []; + } + + // Возвращаем список паролей БЕЗ расшифровки (только метаданные) + const result = passwords.map(p => ({ + id: p.id, + name: p.name || '', + description: p.description || '', + createdAt: p.createdAt || null, + updatedAt: p.updatedAt || null, + // НЕ возвращаем зашифрованный пароль + })); + + res.json(result); + } catch (error) { + console.error('Error reading IPSec passwords:', error); + return sendError(res, 500, 'Error reading IPSec passwords', 'E_S3'); + } +} + +/** + * GET /api/ipsec-passwords/:id - получить конкретный пароль (расшифрованный) + */ +async function getIpsecPassword(req, res) { + const { id } = req.params; + + try { + const data = await readS3TextObject(S3_KEY).catch(() => ({ body: '[]' })); + let passwords = []; + + try { + passwords = JSON.parse(data.body || '[]'); + if (!Array.isArray(passwords)) { + passwords = []; + } + } catch (parseError) { + passwords = []; + } + + const password = passwords.find(p => p.id === id); + if (!password) { + return sendError(res, 404, 'IPSec password not found', 'E_NOT_FOUND'); + } + + // Расшифровываем пароль для возврата + let decryptedPassword = ''; + try { + decryptedPassword = decrypt(password.encryptedPassword || ''); + } catch (decryptError) { + console.error('Error decrypting password:', decryptError); + return sendError(res, 500, 'Error decrypting password', 'E_DECRYPT'); + } + + res.json({ + id: password.id, + name: password.name || '', + description: password.description || '', + password: decryptedPassword, // Расшифрованный пароль + createdAt: password.createdAt || null, + updatedAt: password.updatedAt || null, + }); + } catch (error) { + console.error('Error reading IPSec password:', error); + return sendError(res, 500, 'Error reading IPSec password', 'E_S3'); + } +} + +/** + * POST /api/ipsec-passwords - создать новый пароль + */ +async function createIpsecPassword(req, res) { + const { name, password, description } = req.body; + + if (!name || !password) { + return sendError(res, 400, 'Name and password are required', 'E_BAD_REQUEST'); + } + + try { + // Читаем текущие пароли + const data = await readS3TextObject(S3_KEY).catch(() => ({ body: '[]' })); + let passwords = []; + + try { + passwords = JSON.parse(data.body || '[]'); + if (!Array.isArray(passwords)) { + passwords = []; + } + } catch (parseError) { + passwords = []; + } + + // Проверяем, нет ли уже пароля с таким именем + if (passwords.some(p => p.name === name)) { + return sendError(res, 400, 'Password with this name already exists', 'E_DUPLICATE'); + } + + // Шифруем пароль + const encryptedPassword = encrypt(password); + + // Создаем новый пароль + const newPassword = { + id: `ipsec-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, + name: String(name).trim(), + description: String(description || '').trim(), + encryptedPassword, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + passwords.push(newPassword); + + // Сохраняем в S3 + const meta = await writeS3JsonObject(S3_KEY, passwords); + + // Возвращаем без расшифрованного пароля + res.json({ + id: newPassword.id, + name: newPassword.name, + description: newPassword.description, + createdAt: newPassword.createdAt, + updatedAt: newPassword.updatedAt, + }); + } catch (error) { + console.error('Error creating IPSec password:', error); + return sendError(res, 500, 'Error creating IPSec password', 'E_S3'); + } +} + +/** + * PUT /api/ipsec-passwords/:id - обновить пароль + */ +async function updateIpsecPassword(req, res) { + const { id } = req.params; + const { name, password, description } = req.body; + + try { + // Читаем текущие пароли + const data = await readS3TextObject(S3_KEY).catch(() => ({ body: '[]' })); + let passwords = []; + + try { + passwords = JSON.parse(data.body || '[]'); + if (!Array.isArray(passwords)) { + passwords = []; + } + } catch (parseError) { + passwords = []; + } + + const index = passwords.findIndex(p => p.id === id); + if (index === -1) { + return sendError(res, 404, 'IPSec password not found', 'E_NOT_FOUND'); + } + + // Проверяем уникальность имени (если изменилось) + if (name && name !== passwords[index].name) { + if (passwords.some(p => p.id !== id && p.name === name)) { + return sendError(res, 400, 'Password with this name already exists', 'E_DUPLICATE'); + } + } + + // Обновляем пароль + if (name !== undefined) { + passwords[index].name = String(name).trim(); + } + if (description !== undefined) { + passwords[index].description = String(description || '').trim(); + } + if (password !== undefined) { + passwords[index].encryptedPassword = encrypt(password); + } + passwords[index].updatedAt = new Date().toISOString(); + + // Сохраняем в S3 + const meta = await writeS3JsonObject(S3_KEY, passwords); + + // Возвращаем без расшифрованного пароля + res.json({ + id: passwords[index].id, + name: passwords[index].name, + description: passwords[index].description, + createdAt: passwords[index].createdAt, + updatedAt: passwords[index].updatedAt, + }); + } catch (error) { + console.error('Error updating IPSec password:', error); + return sendError(res, 500, 'Error updating IPSec password', 'E_S3'); + } +} + +/** + * DELETE /api/ipsec-passwords/:id - удалить пароль + */ +async function deleteIpsecPassword(req, res) { + const { id } = req.params; + + try { + // Читаем текущие пароли + const data = await readS3TextObject(S3_KEY).catch(() => ({ body: '[]' })); + let passwords = []; + + try { + passwords = JSON.parse(data.body || '[]'); + if (!Array.isArray(passwords)) { + passwords = []; + } + } catch (parseError) { + passwords = []; + } + + const filtered = passwords.filter(p => p.id !== id); + + if (filtered.length === passwords.length) { + return sendError(res, 404, 'IPSec password not found', 'E_NOT_FOUND'); + } + + // Сохраняем в S3 + const meta = await writeS3JsonObject(S3_KEY, filtered); + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting IPSec password:', error); + return sendError(res, 500, 'Error deleting IPSec password', 'E_S3'); + } +} + +module.exports = { + getIpsecPasswords, + getIpsecPassword, + createIpsecPassword, + updateIpsecPassword, + deleteIpsecPassword, +}; diff --git a/backend/server.js b/backend/server.js index f9dbd3a..3acc8b0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -26,6 +26,7 @@ const communitiesRoutes = require('./routes/communitiesRoutes'); const filtersRoutes = require('./routes/filtersRoutes'); const serverConfigsRoutes = require('./routes/serverConfigsRoutes'); const miscRoutes = require('./routes/miscRoutes'); +const ipsecPasswordsRoutes = require('./routes/ipsecPasswordsRoutes'); const app = express(); const port = Number(process.env.PORT) || 3001; @@ -477,6 +478,13 @@ app.get('/api/ws/url', miscRoutes.getWsUrl); app.get('/api/ui-settings', miscRoutes.getUiSettings); app.post('/api/ui-settings', miscRoutes.postUiSettings); +// === IPSEC PASSWORDS === +app.get('/api/ipsec-passwords', ipsecPasswordsRoutes.getIpsecPasswords); +app.get('/api/ipsec-passwords/:id', ipsecPasswordsRoutes.getIpsecPassword); +app.post('/api/ipsec-passwords', writeLimiter, ipsecPasswordsRoutes.createIpsecPassword); +app.put('/api/ipsec-passwords/:id', writeLimiter, ipsecPasswordsRoutes.updateIpsecPassword); +app.delete('/api/ipsec-passwords/:id', writeLimiter, ipsecPasswordsRoutes.deleteIpsecPassword); + // === MIKROTIK VALIDATION === app.post('/api/mikrotik/validate', async (req, res) => { const { config } = req.body; diff --git a/backend/utils/encryption.js b/backend/utils/encryption.js new file mode 100644 index 0000000..ac9b8d9 --- /dev/null +++ b/backend/utils/encryption.js @@ -0,0 +1,86 @@ +/** + * Утилиты для шифрования/дешифрования данных + * Использует AES-256-GCM для шифрования паролей + */ + +const crypto = require('crypto'); + +// Ключ шифрования из переменной окружения +const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const ALGORITHM = 'aes-256-gcm'; + +/** + * Получить ключ шифрования (32 байта) + */ +function getEncryptionKey() { + // Если ENCRYPTION_KEY - hex строка, конвертируем в Buffer + if (ENCRYPTION_KEY.length === 64) { + return Buffer.from(ENCRYPTION_KEY, 'hex'); + } + // Иначе используем как есть и дополняем/обрезаем до 32 байт + const key = Buffer.from(ENCRYPTION_KEY, 'utf8'); + if (key.length === 32) return key; + // Дополняем или обрезаем до 32 байт + const result = Buffer.alloc(32); + key.copy(result, 0, 0, Math.min(key.length, 32)); + return result; +} + +/** + * Зашифровать текст + * @param {string} text - Текст для шифрования + * @returns {string} - Зашифрованная строка в формате iv:authTag:encryptedData (все в base64) + */ +function encrypt(text) { + if (!text) return ''; + + const key = getEncryptionKey(); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(String(text), 'utf8', 'base64'); + encrypted += cipher.final('base64'); + + const authTag = cipher.getAuthTag(); + + // Формат: iv:authTag:encryptedData (все в base64) + return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`; +} + +/** + * Расшифровать текст + * @param {string} encryptedText - Зашифрованная строка в формате iv:authTag:encryptedData + * @returns {string} - Расшифрованный текст + */ +function decrypt(encryptedText) { + if (!encryptedText) return ''; + + try { + const parts = encryptedText.split(':'); + if (parts.length !== 3) { + throw new Error('Invalid encrypted format'); + } + + const [ivBase64, authTagBase64, encryptedBase64] = parts; + const iv = Buffer.from(ivBase64, 'base64'); + const authTag = Buffer.from(authTagBase64, 'base64'); + const encrypted = encryptedBase64; + + const key = getEncryptionKey(); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encrypted, 'base64', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } catch (error) { + console.error('Decryption error:', error); + throw new Error('Failed to decrypt data'); + } +} + +module.exports = { + encrypt, + decrypt, +}; diff --git a/frontend/src/NetworkConfigManager.jsx b/frontend/src/NetworkConfigManager.jsx index dc46ccc..a18cdbb 100644 --- a/frontend/src/NetworkConfigManager.jsx +++ b/frontend/src/NetworkConfigManager.jsx @@ -27,7 +27,8 @@ import { IconLayoutGrid, IconList, IconCode, - IconArrowsRightLeft + IconArrowsRightLeft, + IconLock, } from '@tabler/icons-react'; /** @@ -81,6 +82,7 @@ const getEmptyInterface = () => ({ remoteIp: '', serverId: '', serverId2: '', // Второй сервер (опциональный) + ipsecPasswordId: '', // ID IPSec пароля (опционально, только для IPSec) }); const getEmptyIpPool = () => ({ @@ -140,6 +142,14 @@ function NetworkConfigManager() { const [templateNamePrefix, setTemplateNamePrefix] = useState(''); const [templateName1, setTemplateName1] = useState(''); // Опциональное имя для сервера 1 const [templateName2, setTemplateName2] = useState(''); // Опциональное имя для сервера 2 + const [templateIpsecPasswordId, setTemplateIpsecPasswordId] = useState(''); // Выбранный IPSec пароль + + // === IPSec Passwords Management === + const [ipsecPasswords, setIpsecPasswords] = useState([]); + const [ipsecPasswordModalOpen, setIpsecPasswordModalOpen] = useState(false); + const [ipsecPasswordsListModalOpen, setIpsecPasswordsListModalOpen] = useState(false); + const [editingIpsecPassword, setEditingIpsecPassword] = useState(null); + const [ipsecPasswordModalMode, setIpsecPasswordModalMode] = useState('add'); // 'add' | 'edit' // === MikroTik Code Generation === const [mikrotikCodeModalOpen, setMikrotikCodeModalOpen] = useState(false); @@ -149,6 +159,7 @@ function NetworkConfigManager() { useEffect(() => { fetchConfig(); fetchServers(); + fetchIpsecPasswords(); }, []); const fetchConfig = async () => { @@ -194,6 +205,16 @@ function NetworkConfigManager() { } }; + const fetchIpsecPasswords = async () => { + try { + const response = await api.get('/ipsec-passwords'); + setIpsecPasswords(Array.isArray(response.data) ? response.data : []); + } catch (error) { + console.error('Error fetching IPSec passwords:', error); + setIpsecPasswords([]); + } + }; + // === Сохранение === const handleSave = async () => { setSaving(true); @@ -529,7 +550,8 @@ function NetworkConfigManager() { localIp: localIp, remoteIp: remoteIp, serverId: templateServer1, - serverId2: templateServer2 + serverId2: templateServer2, + ipsecPasswordId: templateType === 'IPSec' && templateIpsecPasswordId ? templateIpsecPasswordId : '' }; const conflicts = checkInterfaceIpConflict(newInterface); @@ -554,6 +576,7 @@ function NetworkConfigManager() { setTemplateNamePrefix(''); setTemplateName1(''); setTemplateName2(''); + setTemplateIpsecPasswordId(''); }; // === CRUD для Interfaces === @@ -836,6 +859,67 @@ function NetworkConfigManager() { setDeleteModalOpen(true); }; + // === CRUD для IPSec Passwords === + const handleAddIpsecPassword = () => { + setEditingIpsecPassword({ name: '', password: '', description: '' }); + setIpsecPasswordModalMode('add'); + setIpsecPasswordModalOpen(true); + }; + + const handleEditIpsecPassword = async (passwordId) => { + try { + const response = await api.get(`/ipsec-passwords/${passwordId}`); + setEditingIpsecPassword(response.data); + setIpsecPasswordModalMode('edit'); + setIpsecPasswordModalOpen(true); + } catch (error) { + console.error('Error fetching IPSec password:', error); + notify.error('Не удалось загрузить пароль'); + } + }; + + const handleSaveIpsecPassword = async () => { + if (!editingIpsecPassword.name || !editingIpsecPassword.password) { + notify.error('Имя и пароль обязательны'); + return; + } + + try { + if (ipsecPasswordModalMode === 'add') { + await api.post('/ipsec-passwords', { + name: editingIpsecPassword.name, + password: editingIpsecPassword.password, + description: editingIpsecPassword.description || '' + }); + notify.success('IPSec пароль создан'); + } else { + await api.put(`/ipsec-passwords/${editingIpsecPassword.id}`, { + name: editingIpsecPassword.name, + password: editingIpsecPassword.password, + description: editingIpsecPassword.description || '' + }); + notify.success('IPSec пароль обновлён'); + } + await fetchIpsecPasswords(); + setIpsecPasswordModalOpen(false); + setEditingIpsecPassword(null); + } catch (error) { + console.error('Error saving IPSec password:', error); + notify.error('Не удалось сохранить пароль'); + } + }; + + const handleDeleteIpsecPassword = async (passwordId) => { + try { + await api.delete(`/ipsec-passwords/${passwordId}`); + notify.success('IPSec пароль удалён'); + await fetchIpsecPasswords(); + } catch (error) { + console.error('Error deleting IPSec password:', error); + notify.error('Не удалось удалить пароль'); + } + }; + // === CRUD для IP Pools === const handleAddPool = () => { setEditingPool(getEmptyIpPool()); @@ -1908,6 +1992,14 @@ function NetworkConfigManager() { Шаблон + )} {activeTab === 'pools' && ( @@ -2630,10 +2722,44 @@ function NetworkConfigManager() { name="type" type="select" value={editingInterface.type} - onChange={(val) => setEditingInterface({ ...editingInterface, type: val })} + onChange={(val) => setEditingInterface({ ...editingInterface, type: val, ipsecPasswordId: val !== 'IPSec' ? '' : editingInterface.ipsecPasswordId })} options={INTERFACE_TYPES} /> + {editingInterface.type === 'IPSec' && ( +
+ +
+ + +
+
+ Выберите сохраненный IPSec пароль или создайте новый +
+
+ )}
@@ -2787,6 +2913,7 @@ function NetworkConfigManager() { setTemplateNamePrefix(''); setTemplateName1(''); setTemplateName2(''); + setTemplateIpsecPasswordId(''); }} onSubmit={handleCreateInterfaceFromTemplate} title="Создать интерфейс из шаблона" @@ -2892,9 +3019,43 @@ function NetworkConfigManager() { onChange={(e) => setTemplateName2(e.target.value)} placeholder={autoName2} /> -
Оставьте пустым для автогенерации
-
+
Оставьте пустым для автогенерации
+
+ {templateType === 'IPSec' && (
+ +
+ + +
+
+ Выберите сохраненный IPSec пароль или создайте новый +
+
+ )} +
Предпросмотр интерфейса:
@@ -2926,6 +3087,214 @@ function NetworkConfigManager() {
+ {/* IPSec Password Modal */} + { + setIpsecPasswordModalOpen(false); + setEditingIpsecPassword(null); + }} + onSubmit={handleSaveIpsecPassword} + title={ipsecPasswordModalMode === 'add' ? 'Добавить IPSec пароль' : 'Редактировать IPSec пароль'} + submitLabel={ipsecPasswordModalMode === 'add' ? 'Создать' : 'Сохранить'} + submitIcon={ipsecPasswordModalMode === 'add' ? IconPlus : IconEdit} + > + {editingIpsecPassword && ( +
+
+ setEditingIpsecPassword({ ...editingIpsecPassword, name: val })} + placeholder="Основной IPSec пароль" + required + /> +
+
+ setEditingIpsecPassword({ ...editingIpsecPassword, password: val })} + placeholder="Введите пароль" + required + /> +
Пароль будет зашифрован перед сохранением в S3
+
+
+ setEditingIpsecPassword({ ...editingIpsecPassword, description: val })} + placeholder="Описание использования пароля" + /> +
+
+ )} +
+ + {/* IPSec Password Modal */} + { + setIpsecPasswordModalOpen(false); + setEditingIpsecPassword(null); + }} + onSubmit={handleSaveIpsecPassword} + title={ipsecPasswordModalMode === 'add' ? 'Добавить IPSec пароль' : 'Редактировать IPSec пароль'} + submitLabel={ipsecPasswordModalMode === 'add' ? 'Создать' : 'Сохранить'} + submitIcon={ipsecPasswordModalMode === 'add' ? IconPlus : IconEdit} + > + {editingIpsecPassword && ( +
+
+ setEditingIpsecPassword({ ...editingIpsecPassword, name: val })} + placeholder="Основной IPSec пароль" + required + /> +
+
+ setEditingIpsecPassword({ ...editingIpsecPassword, password: val })} + placeholder="Введите пароль" + required + /> +
Пароль будет зашифрован перед сохранением в S3
+
+
+ setEditingIpsecPassword({ ...editingIpsecPassword, description: val })} + placeholder="Описание использования пароля" + /> +
+
+ )} +
+ + {/* IPSec Passwords List Modal */} + {ipsecPasswordsListModalOpen && ( +
+
+
+
+
+ + Управление IPSec паролями +
+ +
+
+
+
+
Сохраненные IPSec пароли
+
Пароли хранятся в зашифрованном виде в S3
+
+ +
+ {ipsecPasswords.length === 0 ? ( +
+
+ +
+

IPSec пароли не найдены

+

+ Создайте первый IPSec пароль для использования в шаблонах интерфейсов +

+
+ +
+
+ ) : ( +
+ + + + + + + + + + {ipsecPasswords.map(pwd => ( + + + + + + ))} + +
НазваниеОписаниеДействия
{pwd.name}{pwd.description || '—'} +
+ + +
+
+
+ )} +
+
+ +
+
+
+
+ )} + {/* Pool Modal */}