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() {
IPSec пароли не найдены
++ Создайте первый IPSec пароль для использования в шаблонах интерфейсов +
+| Название | +Описание | +Действия | +
|---|---|---|
| {pwd.name} | +{pwd.description || '—'} | +
+
+
+
+
+ |
+