diff --git a/README.md b/README.md index ec02715..76cd25f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Frontend доступен на `http://localhost:5173` (по умолчанию) ### Важные переменные окружения - `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `S3_BUCKET_NAME` — доступ к Object Storage +- `ENCRYPTION_KEY` — ключ шифрования (64 hex символа) для IPSec и MikroTik паролей - `CORS_ORIGINS` — список разрешённых Origin через запятую (если пусто — разрешены все) - `LOG_LEVEL` — уровень логов pino (`info` по умолчанию) - `PORT` — порт backend (по умолчанию 3001) @@ -97,6 +98,7 @@ BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=d - POST `/api/mikrotik/generate` — сгенерировать конфиг интерфейсов и маршрутов. Body: `{ format?: 'text'|'json', type?: 'interfaces'|'recursive'|'all', serverId?: string, config?, servers? }`. Возвращает `{ blocks }` — массив блоков с полем `code` (text) или `operations` (json). - GET `/api/mikrotik/generate-interfaces?format=text|json&serverId=` — только интерфейсы. - GET `/api/mikrotik/generate-recursive-routes?format=text|json&serverId=` — только рекурсивные маршруты. +- POST `/api/mikrotik/test-connection` — проверить соединение с MikroTik. Body: `{ serverId }` или `{ host, port?, user?, password }`. ### Прочее - GET `/api/servers` / POST `/api/servers` — список серверов. diff --git a/backend/package-lock.json b/backend/package-lock.json index d497f79..d764bb5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,6 +17,7 @@ "express": "^4.19.2", "express-rate-limit": "^6.11.2", "helmet": "^7.1.0", + "node-routeros": "^1.6.8", "pino": "^9.4.0", "pino-http": "^10.3.0", "prom-client": "^15.1.3" @@ -1989,7 +1990,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2631,6 +2631,16 @@ "node": ">= 0.6" } }, + "node_modules/node-routeros": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/node-routeros/-/node-routeros-1.6.8.tgz", + "integrity": "sha512-6N1N60mAsT8ALpbURMuLVGZ1tJAYislu8n1KBZ1TFBFXKO92krz+U92+xdMhu09RfPh8QQ8CyI5Fp9UznruhkA==", + "license": "MIT", + "dependencies": { + "debug": "*", + "iconv-lite": "*" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", diff --git a/backend/package.json b/backend/package.json index ac7cbf4..afcbf8d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,17 +16,18 @@ "license": "ISC", "description": "", "dependencies": { + "@aws-sdk/client-s3": "^3.687.0", + "ajv": "^8.17.1", + "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^17.0.1", "express": "^4.19.2", - "compression": "^1.7.4", - "ajv": "^8.17.1", - "helmet": "^7.1.0", "express-rate-limit": "^6.11.2", + "helmet": "^7.1.0", + "node-routeros": "^1.6.8", "pino": "^9.4.0", "pino-http": "^10.3.0", - "prom-client": "^15.1.3", - "@aws-sdk/client-s3": "^3.687.0" + "prom-client": "^15.1.3" }, "devDependencies": { "nodemon": "^3.1.10" diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js index 45bd5cb..93d9b82 100644 --- a/backend/routes/mikrotikConfigRoutes.js +++ b/backend/routes/mikrotikConfigRoutes.js @@ -13,6 +13,7 @@ const { buildMikrotikConfig, } = require('../utils/mikrotikInterfaceGenerator'); const { readS3TextObject } = require('../services/s3Service'); +const { RouterOSAPI } = require('node-routeros'); async function fetchJsonFromS3(key, defaultValue = null) { try { @@ -162,8 +163,73 @@ async function generateRecursiveRoutes(req, res) { } } +/** + * POST /api/mikrotik/test-connection + * Body: { serverId?: string, host?, port?, user?, password? } + * Если host/port/user/password переданы — тестирует с ними (без сохранения). + * Иначе берёт credentials из сервера по serverId. + */ +async function testMikrotikConnection(req, res) { + try { + const { serverId, host: bodyHost, port: bodyPort, user: bodyUser, password: bodyPassword } = req.body || {}; + + let host, port, user, password; + + if (bodyHost && bodyPassword) { + host = bodyHost; + port = parseInt(bodyPort || '8728', 10) || 8728; + user = bodyUser || 'admin'; + password = bodyPassword; + } else if (serverId) { + const servers = await fetchJsonFromS3('servers.json', []); + const server = servers.find(s => s.id === serverId || s.dns === serverId || s.ip === serverId); + if (!server) { + return sendError(res, 404, 'Server not found', 'E_NOT_FOUND'); + } + if (server.type !== 'jumphost') { + return sendError(res, 400, 'Only jumphost servers have MikroTik credentials', 'E_BAD_REQUEST'); + } + if (!server.encryptedMikrotikPassword) { + return sendError(res, 400, 'MikroTik password not configured for this server', 'E_BAD_REQUEST'); + } + try { + password = decrypt(server.encryptedMikrotikPassword); + } catch (decErr) { + return sendError(res, 500, 'Failed to decrypt MikroTik password', 'E_DECRYPT'); + } + host = server.mikrotikHost || server.ip || server.dns; + port = parseInt(server.mikrotikPort || '8728', 10) || 8728; + user = server.mikrotikUser || 'admin'; + } else { + return sendError(res, 400, 'Provide serverId or (host + password)', 'E_BAD_REQUEST'); + } + + const conn = new RouterOSAPI({ + host: String(host), + user: String(user), + password: String(password), + port: Number(port) || 8728, + }); + + await conn.connect(); + await conn.write('/system/resource/print', []); + conn.close(); + + res.json({ ok: true, message: 'Соединение успешно' }); + } catch (error) { + const msg = error.message || String(error); + res.status(400).json({ + ok: false, + message: msg.includes('ECONNREFUSED') ? 'Соединение отклонено. Проверьте хост и порт.' : + msg.includes('Authentication') || msg.includes('login') ? 'Неверный логин или пароль' : + msg, + }); + } +} + module.exports = { generateMikrotikConfig, generateInterfaces, generateRecursiveRoutes, + testMikrotikConnection, }; diff --git a/backend/routes/serversRoutes.js b/backend/routes/serversRoutes.js new file mode 100644 index 0000000..5ab6fa7 --- /dev/null +++ b/backend/routes/serversRoutes.js @@ -0,0 +1,158 @@ +/** + * Кастомные роуты для серверов с поддержкой зашифрованных MikroTik учётных данных + * Для jumphost: mikrotikHost, mikrotikPort, mikrotikUser, encryptedMikrotikPassword + */ + +const { GetObjectCommand, PutObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); +const { s3, BUCKET_NAME, streamToString } = require('../services/s3Service'); +const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler'); +const { encrypt, decrypt } = require('../utils/encryption'); + +const S3_KEY = 'servers.json'; +const SERVER_TYPES = ['jumphost', 'exit', 'bgp', 'dns', 'home']; +const TYPES_NEED_TUNNEL = ['jumphost', 'exit']; +const TYPES_NEED_GATEWAYS = ['jumphost', 'exit']; + +function validateServer(server, i) { + if (!server.ip || !server.dns || !server.country || !server.provider) { + return `Server at index ${i} is missing required fields (ip, dns, country, provider)`; + } + const normalizedType = String(server.type || '').toLowerCase(); + if (!SERVER_TYPES.includes(normalizedType)) { + return `Server at index ${i} has invalid type (allowed: ${SERVER_TYPES.join(', ')})`; + } + if (TYPES_NEED_TUNNEL.includes(normalizedType) && !server.tunnel) { + return `Server at index ${i} (${normalizedType}) requires tunnel type`; + } + if (TYPES_NEED_GATEWAYS.includes(normalizedType)) { + if (!Array.isArray(server.gateways) || server.gateways.length === 0) { + return `Server at index ${i} must have gateways for type ${normalizedType}`; + } + const primaries = server.gateways.filter(g => g && g.primary); + if (primaries.length !== 1) { + return `Server at index ${i} must have exactly one primary gateway`; + } + for (let j = 0; j < server.gateways.length; j++) { + const gw = server.gateways[j] || {}; + if (!gw.name || String(gw.name).trim().length === 0) { + return `Server at index ${i} gateway at index ${j} is missing name`; + } + } + } + server.type = normalizedType; + return null; +} + +async function readServersFromS3() { + try { + const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })); + const body = await streamToString(data.Body); + const parsed = JSON.parse(body || '[]'); + return Array.isArray(parsed) ? parsed : []; + } catch (e) { + if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return []; + throw e; + } +} + +async function writeServersToS3(servers) { + const body = JSON.stringify(servers, null, 2); + await s3.send(new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: S3_KEY, + Body: body, + ContentType: 'application/json', + })); +} + +/** + * GET /api/servers — возвращает серверы, для jumphost заменяет encryptedMikrotikPassword на hasMikrotikPassword + */ +async function getServers(req, res) { + try { + const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null); + const etag = head?.ETag || null; + if (etag) res.set('ETag', String(etag)); + if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString()); + if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength)); + if (checkIfNoneMatch(req, res, etag)) return; + + const servers = await readServersFromS3(); + const sanitized = servers.map(s => { + const out = { ...s }; + if (s.type === 'jumphost' && out.encryptedMikrotikPassword) { + out.hasMikrotikPassword = true; + delete out.encryptedMikrotikPassword; + } else if (s.type === 'jumphost') { + out.hasMikrotikPassword = false; + } + return out; + }); + res.json(sanitized); + } catch (error) { + if (error?.name === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) { + return res.json([]); + } + console.error(error); + return sendError(res, 500, 'Error reading servers', 'E_S3'); + } +} + +/** + * POST /api/servers — сохраняет серверы, шифрует mikrotikPassword при наличии + */ +async function postServers(req, res) { + const items = req.body?.domains ?? req.body?.servers; + if (!Array.isArray(items)) { + return sendError(res, 400, 'Data must be an array (domains or servers)', 'E_BAD_REQUEST'); + } + + for (let i = 0; i < items.length; i++) { + const err = validateServer(items[i], i); + if (err) return sendError(res, 400, err, 'E_SCHEMA'); + } + + try { + const currentServers = await readServersFromS3(); + const findCurrent = (srv) => currentServers.find(c => + (srv.id && c.id === srv.id) || (srv.dns && c.dns === srv.dns) || (srv.ip && c.ip === srv.ip) + ); + + const toSave = items.map((srv) => { + const current = findCurrent(srv); + + if (srv.type === 'jumphost') { + const out = { ...srv }; + if (srv.mikrotikPassword !== undefined && srv.mikrotikPassword !== null && String(srv.mikrotikPassword).trim() !== '') { + try { + out.encryptedMikrotikPassword = encrypt(String(srv.mikrotikPassword).trim()); + } catch (encErr) { + console.error('MikroTik password encryption failed:', encErr); + } + delete out.mikrotikPassword; + } else if (current?.encryptedMikrotikPassword) { + out.encryptedMikrotikPassword = current.encryptedMikrotikPassword; + } + return out; + } + return srv; + }); + + await writeServersToS3(toSave); + const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null); + return sendOk(res, { + etag: head?.ETag || null, + lastModified: head?.LastModified ? head.LastModified.toISOString() : null, + contentLength: typeof head?.ContentLength === 'number' ? head.ContentLength : null, + }); + } catch (error) { + console.error(error); + return sendError(res, 500, error.message || 'Error saving servers', 'E_S3'); + } +} + +module.exports = { + getServers, + postServers, + readServersFromS3, +}; diff --git a/backend/server.js b/backend/server.js index da035ba..38e8788 100644 --- a/backend/server.js +++ b/backend/server.js @@ -302,51 +302,10 @@ app.post('/api/ip-ranges', writeLimiter, ipRangesRoutes.post); // === JSON DATA ROUTES (используют фабрики) === -// Servers -const SERVER_TYPES = ['jumphost', 'exit', 'bgp', 'dns', 'home']; -const TYPES_NEED_TUNNEL = ['jumphost', 'exit']; // Типы, которым нужен туннель -const TYPES_NEED_GATEWAYS = ['jumphost', 'exit']; // Типы, которым нужны gateways - -const serversRoutes = createJsonDataRoutes('servers.json', (server, i) => { - // Базовые обязательные поля - if (!server.ip || !server.dns || !server.country || !server.provider) { - return `Server at index ${i} is missing required fields (ip, dns, country, provider)`; - } - - const normalizedType = String(server.type || '').toLowerCase(); - if (!SERVER_TYPES.includes(normalizedType)) { - return `Server at index ${i} has invalid type (allowed: ${SERVER_TYPES.join(', ')})`; - } - - // Туннель обязателен только для jumphost и exit - if (TYPES_NEED_TUNNEL.includes(normalizedType) && !server.tunnel) { - return `Server at index ${i} (${normalizedType}) requires tunnel type`; - } - - // Gateways обязательны только для jumphost и exit - if (TYPES_NEED_GATEWAYS.includes(normalizedType)) { - if (!Array.isArray(server.gateways) || server.gateways.length === 0) { - return `Server at index ${i} must have gateways for type ${normalizedType}`; - } - const primaries = server.gateways.filter(g => g && g.primary); - if (primaries.length !== 1) { - return `Server at index ${i} must have exactly one primary gateway`; - } - for (let j = 0; j < server.gateways.length; j++) { - const gw = server.gateways[j] || {}; - if (!gw.name || String(gw.name).trim().length === 0) { - return `Server at index ${i} gateway at index ${j} is missing name`; - } - } - } - - // Нормализуем тип, чтобы в S3 всегда лежали одинаковые значения - server.type = normalizedType; - return null; -}); - -app.get('/api/servers', serversRoutes.get); -app.post('/api/servers', serversRoutes.post); +// Servers (кастомные роуты с поддержкой зашифрованных MikroTik учётных данных для jumphost) +const serversRoutes = require('./routes/serversRoutes'); +app.get('/api/servers', serversRoutes.getServers); +app.post('/api/servers', serversRoutes.postServers); // Server connections for topology graph const serverConnectionsRoutes = createJsonDataRoutes('server-connections.json', (conn, i) => { @@ -490,6 +449,7 @@ app.delete('/api/ipsec-passwords/:id', writeLimiter, ipsecPasswordsRoutes.delete app.post('/api/mikrotik/generate', mikrotikConfigRoutes.generateMikrotikConfig); app.get('/api/mikrotik/generate-interfaces', mikrotikConfigRoutes.generateInterfaces); app.get('/api/mikrotik/generate-recursive-routes', mikrotikConfigRoutes.generateRecursiveRoutes); +app.post('/api/mikrotik/test-connection', mikrotikConfigRoutes.testMikrotikConnection); // === MIKROTIK VALIDATION === app.post('/api/mikrotik/validate', async (req, res) => { diff --git a/frontend/src/components/server/ServerModal.jsx b/frontend/src/components/server/ServerModal.jsx index 5a13808..6a384a8 100644 --- a/frontend/src/components/server/ServerModal.jsx +++ b/frontend/src/components/server/ServerModal.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; -import { IconPlus, IconEdit, IconTrash, IconServer, IconWorld, IconMapPin } from '@tabler/icons-react'; +import { IconPlus, IconEdit, IconTrash, IconServer, IconWorld, IconMapPin, IconRouter, IconPlug } from '@tabler/icons-react'; +import api from '../../lib/api.js'; import { makeGateway, normalizeGateways, @@ -76,6 +77,8 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter const [localServer, setLocalServer] = useState(getEmptyServer()); const [customProvider, setCustomProvider] = useState(''); const [internalError, setInternalError] = useState(''); + const [mikrotikTesting, setMikrotikTesting] = useState(false); + const [mikrotikTestResult, setMikrotikTestResult] = useState(null); const isEditMode = mode === 'edit'; const error = externalError || internalError; @@ -119,6 +122,7 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter } if (!show) { initializedRef.current = false; + setMikrotikTestResult(null); } }, [show, server, isEditMode]); @@ -560,6 +564,103 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter )} + + {/* MikroTik — только для jumphost */} + {localServer.type === 'jumphost' && ( +