diff --git a/README.md b/README.md index 76cd25f..8cde4c0 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ BGP_BACKGROUND_URL=http://77.232.38.173:8080/api/update_bgp/background?api_key=d - 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 }`. +- POST `/api/mikrotik/apply` — применить конфигурацию на MikroTik по API. Body: `{ serverId, type?: 'interfaces'|'recursive'|'all', dryRun?: boolean }`. Только для jumphost. ### Прочее - GET `/api/servers` / POST `/api/servers` — список серверов. diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js index 93d9b82..f5b2948 100644 --- a/backend/routes/mikrotikConfigRoutes.js +++ b/backend/routes/mikrotikConfigRoutes.js @@ -14,6 +14,7 @@ const { } = require('../utils/mikrotikInterfaceGenerator'); const { readS3TextObject } = require('../services/s3Service'); const { RouterOSAPI } = require('node-routeros'); +const { applyBlock } = require('../services/mikrotikApplyService'); async function fetchJsonFromS3(key, defaultValue = null) { try { @@ -227,9 +228,106 @@ async function testMikrotikConnection(req, res) { } } +/** + * POST /api/mikrotik/apply + * Body: { serverId: string, type?: 'interfaces'|'recursive'|'all', dryRun?: boolean } + * Применяет конфигурацию (интерфейсы, маршруты) на MikroTik через RouterOS API. + * dryRun=true — только показать план, не выполнять. + */ +async function applyMikrotikConfig(req, res) { + try { + const { serverId, type = 'all', dryRun = true } = req.body || {}; + if (!serverId) { + return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); + } + + 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 support apply via API', 'E_BAD_REQUEST'); + } + if (!server.encryptedMikrotikPassword) { + return sendError(res, 400, 'MikroTik password not configured. Add credentials in Server settings.', 'E_BAD_REQUEST'); + } + + let password; + try { + password = decrypt(server.encryptedMikrotikPassword); + } catch (decErr) { + return sendError(res, 500, 'Failed to decrypt MikroTik password', 'E_DECRYPT'); + } + + const host = server.mikrotikHost || server.ip || server.dns; + const port = parseInt(server.mikrotikPort || '8728', 10) || 8728; + const user = server.mikrotikUser || 'admin'; + + const config = await fetchJsonFromS3('network-config.json', { gateways: [], tunnelInterfaces: [] }); + const passwordIds = (config.tunnelInterfaces || []) + .filter(i => i.ipsecPasswordId && String(i.ipsecPasswordId).trim() !== '') + .map(i => i.ipsecPasswordId.trim()); + const passwordMap = await fetchIpsecPasswordMap(passwordIds); + + const includeInterfaces = type === 'interfaces' || type === 'all'; + const includeRecursive = type === 'recursive' || type === 'all'; + const blocks = await buildMikrotikConfig(config, servers, passwordMap, { + format: 'json', + serverId, + includeInterfaces, + includeRecursive, + }); + + const conn = new RouterOSAPI({ + host: String(host), + user: String(user), + password: String(password), + port: Number(port) || 8728, + }); + + await conn.connect(); + + const allResults = []; + for (const block of blocks) { + const blockResults = await applyBlock(conn, block, !!dryRun); + allResults.push({ + blockType: block.type, + serverName: block.serverName, + results: blockResults, + }); + } + + conn.close(); + + const summary = { + created: allResults.flatMap(b => b.results).filter(r => r.status === 'created' || r.status === 'would_create').length, + updated: allResults.flatMap(b => b.results).filter(r => r.status === 'updated' || r.status === 'would_update').length, + skipped: allResults.flatMap(b => b.results).filter(r => r.status === 'skip').length, + errors: allResults.flatMap(b => b.results).filter(r => r.status === 'error'), + }; + + res.json({ + ok: true, + dryRun: !!dryRun, + summary, + results: allResults, + }); + } catch (error) { + console.error('Error applying MikroTik config:', error); + const msg = error.message || String(error); + return res.status(500).json({ + ok: false, + error: msg.includes('ECONNREFUSED') ? 'Соединение отклонено' : + msg.includes('Authentication') || msg.includes('login') ? 'Неверный логин или пароль' : msg, + }); + } +} + module.exports = { generateMikrotikConfig, generateInterfaces, generateRecursiveRoutes, testMikrotikConnection, + applyMikrotikConfig, }; diff --git a/backend/server.js b/backend/server.js index 38e8788..06473f7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -450,6 +450,7 @@ 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); +app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig); // === MIKROTIK VALIDATION === app.post('/api/mikrotik/validate', async (req, res) => { diff --git a/backend/services/mikrotikApplyService.js b/backend/services/mikrotikApplyService.js new file mode 100644 index 0000000..606eda9 --- /dev/null +++ b/backend/services/mikrotikApplyService.js @@ -0,0 +1,242 @@ +/** + * Сервис применения конфигурации MikroTik через RouterOS API + * Идемпотентная логика: create / update / skip + */ + +const { RouterOSAPI } = require('node-routeros'); + +/** + * Преобразование params в массив для node-routeros: ['=key=value', ...] + */ +function paramsToRosArray(params) { + if (!params || typeof params !== 'object') return []; + return Object.entries(params) + .filter(([, v]) => v != null && v !== '') + .map(([k, v]) => `=${k}=${String(v)}`); +} + +/** + * Выполнить print с фильтром + * @param {object} conn - RouterOSAPI instance + * @param {string} path - e.g. '/interface/gre' + * @param {object} filter - e.g. { name: 'gre1' } + */ +async function rosPrint(conn, path, filter = {}) { + const pathClean = path.replace(/^\//, '').replace(/\//g, '/'); + const fullPath = `/${pathClean}/print`; + const args = Object.entries(filter) + .filter(([, v]) => v != null && v !== '') + .map(([k, v]) => { + const key = k.startsWith('~') ? k.slice(1) : k; + const prefix = k.startsWith('~') ? '?~' : '?'; + return `${prefix}${key}=${String(v)}`; + }); + const result = args.length > 0 ? await conn.write(fullPath, args) : await conn.write(fullPath); + return Array.isArray(result) ? result : []; +} + +/** + * Выполнить add + */ +async function rosAdd(conn, path, params) { + const pathClean = path.replace(/^\//, '').replace(/\//g, '/'); + const fullPath = `/${pathClean}/add`; + const args = paramsToRosArray(params); + return conn.write(fullPath, args); +} + +/** + * Выполнить set + */ +async function rosSet(conn, path, id, params) { + const pathClean = path.replace(/^\//, '').replace(/\//g, '/'); + const fullPath = `/${pathClean}/set`; + const args = [`.id=${id}`, ...paramsToRosArray(params)]; + return conn.write(fullPath, args); +} + +/** + * Сравнить объект из RouterOS с желаемыми params (только ключевые поля) + */ +function paramsMatch(rosItem, params, keysToCompare) { + if (!rosItem || !params) return false; + for (const k of keysToCompare) { + const rosVal = rosItem[k]; + const wantVal = params[k]; + if (wantVal == null) continue; + if (String(rosVal || '').trim() !== String(wantVal || '').trim()) return false; + } + return true; +} + +/** + * Применить одну операцию с idempotent логикой + */ +async function applyOperation(conn, op, dryRun) { + const { path, action, params, meta } = op; + const result = { path, action, params: { ...params }, status: null, details: null, error: null }; + + if (path === '/interface/list' && action === 'add' && meta?.ensureExists) { + const existing = await rosPrint(conn, '/interface/list', { name: params.name }); + if (existing.length > 0) { + result.status = 'skip'; + result.details = 'Interface list already exists'; + return result; + } + if (dryRun) { + result.status = 'would_create'; + result.details = `Would create interface list ${params.name}`; + return result; + } + await rosAdd(conn, '/interface/list', params); + result.status = 'created'; + return result; + } + + if (path === '/interface/gre' && action === 'add') { + const name = params.name; + const existing = await rosPrint(conn, '/interface/gre', { name }); + const compareKeys = ['remote-address', 'local-address', 'mtu', 'ipsec-secret', 'keepalive', 'allow-fast-path']; + if (existing.length > 0) { + const match = paramsMatch(existing[0], params, compareKeys); + if (match) { + result.status = 'skip'; + result.details = `Interface ${name} already configured`; + return result; + } + if (dryRun) { + result.status = 'would_update'; + result.details = `Would update interface ${name}`; + return result; + } + await rosSet(conn, '/interface/gre', existing[0]['.id'], params); + result.status = 'updated'; + return result; + } + if (dryRun) { + result.status = 'would_create'; + result.details = `Would create GRE interface ${name}`; + return result; + } + await rosAdd(conn, '/interface/gre', params); + result.status = 'created'; + return result; + } + + if (path === '/interface/list/member' && action === 'add') { + const iface = params.interface; + const list = params.list; + const existing = await rosPrint(conn, '/interface/list/member', { list, interface: iface }); + if (existing.length > 0) { + result.status = 'skip'; + result.details = `Member ${iface} already in list ${list}`; + return result; + } + if (dryRun) { + result.status = 'would_create'; + result.details = `Would add ${iface} to list ${list}`; + return result; + } + await rosAdd(conn, '/interface/list/member', params); + result.status = 'created'; + return result; + } + + if (path === '/ip/address' && action === 'add') { + const addr = params.address; + const iface = params.interface; + const existing = await rosPrint(conn, '/ip/address', { interface: iface }); + const match = existing.find(e => (e.address || '').startsWith(addr.split('/')[0])); + if (match) { + result.status = 'skip'; + result.details = `Address ${addr} already on ${iface}`; + return result; + } + if (dryRun) { + result.status = 'would_create'; + result.details = `Would add ${addr} to ${iface}`; + return result; + } + await rosAdd(conn, '/ip/address', params); + result.status = 'created'; + return result; + } + + if (path === '/ip/route') { + if (action === 'remove' && meta?.findComment) { + const existing = await rosPrint(conn, '/ip/route', { '~comment': meta.findComment }); + const toRemove = existing; + if (toRemove.length === 0) { + result.status = 'skip'; + result.details = 'No matching routes to remove'; + return result; + } + if (dryRun) { + result.status = 'would_remove'; + result.details = `Would remove ${toRemove.length} route(s)`; + return result; + } + for (const r of toRemove) { + await conn.write('/ip/route/remove', [`.id=${r['.id']}`]); + } + result.status = 'removed'; + result.details = `${toRemove.length} route(s) removed`; + return result; + } + if (action === 'add') { + const dst = params['dst-address']; + const gw = params.gateway; + const existing = await rosPrint(conn, '/ip/route', { 'dst-address': dst }); + const match = existing.find(e => (e.gateway || '').includes((gw || '').split('%')[0])); + if (match) { + result.status = 'skip'; + result.details = `Route to ${dst} already exists`; + return result; + } + if (dryRun) { + result.status = 'would_create'; + result.details = `Would add route ${dst} via ${gw}`; + return result; + } + await rosAdd(conn, '/ip/route', params); + result.status = 'created'; + return result; + } + } + + result.status = 'skipped'; + result.details = `Unsupported operation: ${path} ${action}`; + return result; +} + +/** + * Применить блок операций к MikroTik + */ +async function applyBlock(conn, block, dryRun) { + const results = []; + const ops = block.operations || []; + for (const op of ops) { + try { + const r = await applyOperation(conn, op, dryRun); + results.push(r); + } catch (err) { + results.push({ + path: op.path, + action: op.action, + params: op.params, + status: 'error', + error: err.message || String(err), + }); + } + } + return results; +} + +module.exports = { + paramsToRosArray, + rosPrint, + rosAdd, + rosSet, + applyOperation, + applyBlock, +}; diff --git a/frontend/src/NetworkConfigManager.jsx b/frontend/src/NetworkConfigManager.jsx index 9bc9cc1..e78fac6 100644 --- a/frontend/src/NetworkConfigManager.jsx +++ b/frontend/src/NetworkConfigManager.jsx @@ -28,6 +28,7 @@ import { IconLayoutGrid, IconList, IconCode, + IconPlug, IconArrowsRightLeft, IconLock, IconChevronDown, @@ -231,6 +232,11 @@ function NetworkConfigManager() { // === MikroTik Code Generation === const [mikrotikCodeModalOpen, setMikrotikCodeModalOpen] = useState(false); const [generatedMikrotikCode, setGeneratedMikrotikCode] = useState([]); + const [applyModalOpen, setApplyModalOpen] = useState(false); + const [applyServerId, setApplyServerId] = useState(null); + const [applyDryRun, setApplyDryRun] = useState(true); + const [applyLoading, setApplyLoading] = useState(false); + const [applyResult, setApplyResult] = useState(null); // === Глобальные настройки PTR зоны === const [globalPtrZoneReplaceFrom, setGlobalPtrZoneReplaceFrom] = useState(''); @@ -2215,6 +2221,40 @@ function NetworkConfigManager() { } }; + // === Применить конфигурацию по API MikroTik === + const handleApplyMikrotikViaApi = async (dryRunOverride = null) => { + if (!applyServerId) return; + const useDryRun = dryRunOverride !== null ? dryRunOverride : applyDryRun; + setApplyLoading(true); + if (dryRunOverride !== null) setApplyResult(null); + try { + const { data } = await api.post('/mikrotik/apply', { + serverId: applyServerId, + type: 'all', + dryRun: useDryRun, + }); + setApplyResult(data); + if (data.ok && !useDryRun) { + notify.success(`Применено: создано ${data.summary?.created || 0}, обновлено ${data.summary?.updated || 0}, пропущено ${data.summary?.skipped || 0}`); + } + } catch (error) { + setApplyResult({ + ok: false, + error: error.response?.data?.error || error.message || 'Ошибка применения', + }); + notify.error('Не удалось применить конфигурацию по API'); + } finally { + setApplyLoading(false); + } + }; + + const openApplyModal = (serverId) => { + setApplyServerId(serverId); + setApplyResult(null); + setApplyDryRun(true); + setApplyModalOpen(true); + }; + // === Генерация кода MikroTik только для одного интерфейса === const handleGenerateMikrotikCodeForInterface = async (iface) => { if (!iface) { @@ -2641,18 +2681,34 @@ function NetworkConfigManager() { {items.length} {type === 'gateway' ? (items.length === 1 ? 'gateway' : 'gateways') : (items.length === 1 ? 'интерфейс' : 'интерфейсов')} {!isUnassigned && items.length > 0 && ( - + <> + + {server?.type === 'jumphost' && ( + + )} + )} @@ -5230,6 +5286,119 @@ function NetworkConfigManager() { )} + {/* Apply via API Modal */} + {applyModalOpen && ( +
+
+
+
+
+ + Применить конфигурацию на MikroTik по API +
+ +
+
+ {!applyResult ? ( + <> +
+ setApplyDryRun(e.target.checked)} + /> + +
+

+ Будет применена конфигурация интерфейсов (GRE, IP) и рекурсивных маршрутов для выбранного сервера. +

+ + ) : ( + <> + {applyResult.ok ? ( + <> +
+ {applyResult.dryRun ? ( + <>Режим dry-run: показан план изменений (ничего не применено) + ) : ( + <>Конфигурация успешно применена + )} +
+
+ Итого:{' '} + создано: {applyResult.summary?.created ?? 0}, обновлено: {applyResult.summary?.updated ?? 0}, пропущено: {applyResult.summary?.skipped ?? 0} + {applyResult.summary?.errors?.length > 0 && ( + , ошибок: {applyResult.summary.errors.length} + )} +
+ {applyResult.results?.map((block, bi) => ( +
+
{block.serverName} — {block.blockType}
+
    + {block.results?.slice(0, 15).map((r, ri) => ( +
  • + {r.status === 'created' || r.status === 'would_create' ? '✓ ' : ''} + {r.status === 'updated' || r.status === 'would_update' ? '↻ ' : ''} + {r.status === 'skip' ? '○ ' : ''} + {r.status === 'error' ? '✗ ' : ''} + {r.details || r.error || `${r.path} ${r.action}`} +
  • + ))} + {(block.results?.length || 0) > 15 && ( +
  • ... и ещё {block.results.length - 15}
  • + )} +
+
+ ))} + + ) : ( +
{applyResult.error}
+ )} + + )} +
+
+ {!applyResult ? ( + <> + + + + ) : ( + <> + {applyResult.dryRun && applyResult.ok && ( + + )} + + + )} +
+
+
+
+ )} + {/* Gateway Template Modal */}