/** * Роуты для генерации и применения конфигурации MikroTik * Использует RouterOS REST API — требует RouterOS 7.1+ с www-ssl (443) или www (80) */ const { GetObjectCommand } = require('@aws-sdk/client-s3'); const { s3, BUCKET_NAME, streamToString, readS3TextObject } = require('../services/s3Service'); const { sendError, sendOk } = require('../middleware/errorHandler'); const { decrypt } = require('../utils/encryption'); const { readServersFromS3 } = require('./serversRoutes'); const { buildMikrotikConfig, buildMikrotikInterfaceBlocks, buildMikrotikRecursiveRoutes, getParentGateway, } = require('../utils/mikrotikInterfaceGenerator'); const { createRosClient, applyBlock } = require('../services/mikrotikApplyService'); const IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json'; const NETWORK_CONFIG_KEY = 'network-config.json'; /** Загрузить network-config из S3 */ async function loadNetworkConfig() { try { const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: NETWORK_CONFIG_KEY })); const body = await streamToString(data.Body); const parsed = JSON.parse(body || '{}'); return parsed && typeof parsed === 'object' ? parsed : {}; } catch (e) { if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) return {}; throw e; } } /** Загрузить и расшифровать IPSec пароли → { id: password } */ async function loadPasswordMap() { const map = {}; try { const data = await readS3TextObject(IPSEC_PASSWORDS_KEY).catch(() => ({ body: '[]' })); const passwords = JSON.parse(data.body || '[]'); if (!Array.isArray(passwords)) return map; for (const p of passwords) { if (p.id && p.encryptedPassword) { try { map[p.id] = decrypt(p.encryptedPassword); } catch (_) { // пропускаем при ошибке расшифровки } } } } catch (_) {} return map; } /** Получить MikroTik credentials из сервера (jumphost) */ function getMikrotikCredentials(server) { if (!server || server.type !== 'jumphost') return null; const host = server.mikrotikHost || server.ip || server.dns; if (!host) return null; const port = server.mikrotikPort; // REST API: порт 80 (HTTP, www) или 443 (HTTPS, www-ssl). По умолчанию 80 const restPort = (port === 80 || port === 443 || port === 8443) ? port : 80; const user = server.mikrotikUser || 'admin'; let password = ''; if (server.encryptedMikrotikPassword) { try { password = decrypt(server.encryptedMikrotikPassword); } catch (_) { return null; } } return { host, port: restPort, user, password, secure: false }; } /** * POST /api/mikrotik/generate * Body: { format?, type?, serverId?, config?, servers? } */ async function generateMikrotikConfig(req, res) { try { const { format = 'text', type = 'all', serverId, config: bodyConfig, servers: bodyServers } = req.body || {}; const fmt = String(format).toLowerCase(); const typ = String(type).toLowerCase(); let config = bodyConfig; let servers = bodyServers; let passwordMap = {}; if (!config) config = await loadNetworkConfig(); if (!Array.isArray(servers)) servers = await readServersFromS3(); if (typ !== 'recursive') passwordMap = await loadPasswordMap(); const opts = { format: fmt === 'json' ? 'json' : 'text', serverId: serverId || undefined, includeInterfaces: typ === 'interfaces' || typ === 'all', includeRecursive: typ === 'recursive' || typ === 'all', }; const blocks = await buildMikrotikConfig(config, servers, passwordMap, opts); return res.json({ blocks }); } catch (error) { console.error('generateMikrotikConfig:', error); return sendError(res, 500, error.message || 'Error generating config', 'E_GENERATE'); } } /** * GET /api/mikrotik/generate-interfaces?format=text|json&serverId= */ async function generateInterfaces(req, res) { try { const format = (req.query.format || 'text').toLowerCase(); const serverId = req.query.serverId || undefined; const config = await loadNetworkConfig(); const servers = await readServersFromS3(); const passwordMap = await loadPasswordMap(); const blocks = await buildMikrotikInterfaceBlocks(config, servers, passwordMap, { format: format === 'json' ? 'json' : 'text', serverId, }); return res.json({ blocks }); } catch (error) { console.error('generateInterfaces:', error); return sendError(res, 500, error.message || 'Error generating interfaces', 'E_GENERATE'); } } /** * GET /api/mikrotik/generate-recursive-routes?format=text|json&serverId= */ async function generateRecursiveRoutes(req, res) { try { const format = (req.query.format || 'text').toLowerCase(); const serverId = req.query.serverId || undefined; const config = await loadNetworkConfig(); const servers = await readServersFromS3(); const blocks = await buildMikrotikRecursiveRoutes(config, servers, { format: format === 'json' ? 'json' : 'text', serverId, }); return res.json({ blocks }); } catch (error) { console.error('generateRecursiveRoutes:', error); return sendError(res, 500, error.message || 'Error generating routes', 'E_GENERATE'); } } /** * POST /api/mikrotik/test-connection * Body: { serverId } или { host, port?, user?, password } */ async function testMikrotikConnection(req, res) { try { const { serverId, host, port, user, password } = req.body || {}; let creds; if (serverId) { const servers = await readServersFromS3(); const server = servers.find(s => (s.id || s.dns || s.ip) === serverId); if (!server || server.type !== 'jumphost') { return sendError(res, 400, 'Jumphost server not found', 'E_NOT_FOUND'); } creds = getMikrotikCredentials(server); if (!creds) { return sendError(res, 400, 'MikroTik credentials not configured', 'E_CREDENTIALS'); } } else if (host && user !== undefined) { creds = { host, port: port || 80, user, password: password || '', secure: false, }; } else { return sendError(res, 400, 'Provide serverId or (host, user, password)', 'E_BAD_REQUEST'); } const client = createRosClient(creds); await client.print('system/resource'); return sendOk(res, { ok: true, message: 'Connection successful' }); } catch (error) { const msg = error.response?.data?.message || error.message || 'Connection failed'; const status = error.response?.status; console.error('testMikrotikConnection:', error); return sendError(res, status && status >= 400 ? status : 502, msg, 'E_CONNECTION'); } } /** * POST /api/mikrotik/apply * Body: { serverId, type?: 'interfaces'|'recursive'|'all', dryRun?: boolean } */ 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 readServersFromS3(); const server = servers.find(s => (s.id || s.dns || s.ip) === serverId); if (!server || server.type !== 'jumphost') { return sendError(res, 400, 'Jumphost server not found', 'E_NOT_FOUND'); } const creds = getMikrotikCredentials(server); if (!creds) { return sendError(res, 400, 'MikroTik credentials not configured for this server', 'E_CREDENTIALS'); } const config = await loadNetworkConfig(); const passwordMap = await loadPasswordMap(); const typ = String(type).toLowerCase(); const opts = { format: 'json', serverId, includeInterfaces: typ === 'interfaces' || typ === 'all', includeRecursive: typ === 'recursive' || typ === 'all', }; const blocks = await buildMikrotikConfig(config, servers, passwordMap, opts); const blocksWithOps = blocks.filter(b => Array.isArray(b.operations) && b.operations.length > 0); if (blocksWithOps.length === 0) { return res.json({ ok: true, dryRun: !!dryRun, summary: { created: 0, updated: 0, skipped: 0, errors: [] }, results: [], message: 'No operations to apply', }); } const client = createRosClient(creds); const allResults = []; const mikrotikRequests = []; for (const block of blocksWithOps) { mikrotikRequests.push({ block: block.type || 'unknown', serverName: block.serverName, operationCount: block.operations?.length || 0, }); const blockResults = await applyBlock(client, block, !!dryRun); allResults.push({ block: block.type || block.serverName, results: blockResults }); } const summary = { created: allResults.flatMap(r => r.results).filter(r => r.status === 'created').length, updated: allResults.flatMap(r => r.results).filter(r => r.status === 'updated').length, skipped: allResults.flatMap(r => r.results).filter(r => r.status === 'skip' || r.status === 'skipped' || r.status === 'would_create' || r.status === 'would_update' || r.status === 'would_remove').length, errors: allResults.flatMap(r => r.results).filter(r => r.status === 'error').map(r => r.error), }; return res.json({ ok: summary.errors.length === 0, dryRun: !!dryRun, summary, results: allResults, mikrotikRequests, }); } catch (error) { console.error('applyMikrotikConfig:', error); return sendError(res, 500, error.message || 'Error applying config', 'E_APPLY'); } } /** * POST /api/mikrotik/ping * Пингует внешний ресурс через указанный MikroTik (jumphost), * по возможности привязываясь к интерфейсу, соответствующему gateway. * * Body: * - serverId: ID/имя сервера из servers.json (обязательно) * - gatewayIp?: IP шлюза (remote IP туннеля или прямой gateway) * - interfaceName?: имя интерфейса RouterOS (если знаем его заранее) * - target?: адрес для ping (по умолчанию www.gstatic.com) * - count?: количество пакетов (по умолчанию 5) */ async function pingViaInterface(req, res) { try { const { serverId, gatewayIp, interfaceName, target = 'www.gstatic.com', count = 5, } = req.body || {}; if (!serverId) { return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); } const servers = await readServersFromS3(); const server = servers.find((s) => (s.id || s.dns || s.ip) === serverId); if (!server || server.type !== 'jumphost') { return sendError(res, 400, 'Jumphost server not found', 'E_NOT_FOUND'); } const creds = getMikrotikCredentials(server); if (!creds) { return sendError(res, 400, 'MikroTik credentials not configured for this server', 'E_CREDENTIALS'); } const client = createRosClient(creds); // Пытаемся найти интерфейс по gatewayIp и serverId, если interfaceName не передан let ifaceName = interfaceName || null; if (!ifaceName && gatewayIp) { const config = await loadNetworkConfig(); const tunnelIfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : []; // 1) Пытаемся сопоставить gatewayIp с локальным/удалённым IP туннельного интерфейса const iface = tunnelIfaces.find((i) => { const sameServer = i.serverId === serverId || i.serverId === server.ip || i.serverId === server.dns; const sameIp = i.remoteIp === gatewayIp || i.localIp === gatewayIp; return sameServer && sameIp; }); if (iface) { ifaceName = iface.name || null; } // 2) Если по tunnelInterfaces не нашли — пробуем взять интерфейс из gateways в /network-config if (!ifaceName && Array.isArray(config.gateways)) { const gw = config.gateways.find((g) => { if (!g || !g.ip) return false; const sameServer = g.serverId === serverId || g.serverId === server.ip || g.serverId === server.dns; return sameServer && g.ip === gatewayIp; }); if (gw) { // Вариант А: gateway сам хранит имя интерфейса if (gw.interfaceName) { ifaceName = gw.interfaceName; } // Вариант Б: gateway ссылается на интерфейс через parentGatewayId / parentGateways if (!ifaceName && (gw.parentGatewayId || (Array.isArray(gw.parentGateways) && gw.parentGateways.length > 0))) { const parentRefs = (gw.parentGateways && gw.parentGateways.length > 0) ? gw.parentGateways : [{ id: gw.parentGatewayId }]; for (const pref of parentRefs) { const parent = getParentGateway(pref.id, config); if (parent && parent.parentType === 'interface' && parent.name) { ifaceName = parent.name; break; } } } } } // 3) Если даже из /network-config не смогли получить interface — // пробуем вытащить его напрямую из маршрутов MikroTik по полю gateway. if (!ifaceName) { try { const routeRes = await client.command('ip/route/print', { '.proplist': ['gateway'], '.query': [`gateway~${gatewayIp}`], }); const data = routeRes?.data; const routes = Array.isArray(data) ? data : (data ? [data] : []); const match = routes.find((r) => typeof r.gateway === 'string' && r.gateway.includes(gatewayIp)); if (match && typeof match.gateway === 'string') { const gwStr = match.gateway; const percentIdx = gwStr.indexOf('%'); if (percentIdx >= 0 && percentIdx < gwStr.length - 1) { ifaceName = gwStr.slice(percentIdx + 1); } } } catch (_) { // Если чтение маршрутов не удалось — просто продолжаем без interface, // чтобы не ломать сам ping. } } } // 4) Последний фоллбек: если ifaceName до сих пор не определён, // пробуем взять первый физический порт ether1/ether2 (если есть), // иначе любой интерфейс с префиксом "ether". if (!ifaceName) { try { const ifRes = await client.command('interface/print', { '.proplist': ['name'], }); const data = ifRes?.data; const interfaces = Array.isArray(data) ? data : (data ? [data] : []); const names = interfaces .map((it) => (typeof it.name === 'string' ? it.name : null)) .filter(Boolean); let candidate = null; if (names.includes('ether1')) candidate = 'ether1'; else if (names.includes('ether2')) candidate = 'ether2'; else { candidate = names.find((n) => n.toLowerCase().startsWith('ether')) || null; } if (candidate) { ifaceName = candidate; } } catch (_) { // Если не получилось прочитать интерфейсы — просто продолжаем без interface. } } const body = {}; body.address = target; body.count = Number(count) > 0 ? Number(count) : 5; if (ifaceName) body.interface = ifaceName; // Логируем фактический запрос и CLI-эквивалент для отладки try { const cliParts = [ '/tool/ping', `address=${body.address}`, `count=${body.count}`, ]; if (ifaceName) cliParts.push(`interface=${ifaceName}`); console.log('[mikrotik][pingViaInterface]', { serverId, gatewayIp, body, cli: cliParts.join(' '), }); } catch (_) {} const pingRes = await client.command('tool/ping', body); const raw = pingRes?.data; const rows = Array.isArray(raw) ? raw : raw ? [raw] : []; const parseMs = (val) => { if (val == null) return null; const s = String(val).trim(); const m = s.match(/([\d.]+)/); return m ? Number(m[1]) : null; }; // Ищем строку с аггрегированными полями (avg-rtt, min-rtt, max-rtt, packet-loss) let summary = rows.find((r) => r['avg-rtt'] || r['packet-loss']) || rows[rows.length - 1] || {}; const avgMs = parseMs(summary['avg-rtt'] || summary.time); const minMs = parseMs(summary['min-rtt']); const maxMs = parseMs(summary['max-rtt']); const loss = summary['packet-loss'] != null ? Number(String(summary['packet-loss']).replace('%', '')) : null; // ВАЖНО: возвращаем данные напрямую, без sendOk, // чтобы фронтенд (EasySwitch) мог читать поля avgMs/minMs/maxMs/loss return res.json({ ok: true, avgMs, minMs, maxMs, loss, }); } catch (error) { const msg = error.response?.data?.message || error.message || 'Ping failed'; const status = error.response?.status; console.error('pingViaInterface:', error); return sendError(res, status && status >= 400 ? status : 502, msg, 'E_PING'); } } /** * POST /api/mikrotik/run-script * Body: { serverId, script?: string } — по умолчанию script=update_bgp_filter */ async function runScript(req, res) { try { const { serverId, script = 'update_bgp_filter' } = req.body || {}; if (!serverId) { return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); } const servers = await readServersFromS3(); const server = servers.find(s => (s.id || s.dns || s.ip) === serverId); if (!server || server.type !== 'jumphost') { return sendError(res, 400, 'Jumphost server not found', 'E_NOT_FOUND'); } const creds = getMikrotikCredentials(server); if (!creds) { return sendError(res, 400, 'MikroTik credentials not configured for this server', 'E_CREDENTIALS'); } const client = createRosClient(creds); // Сначала находим скрипт по имени, затем запускаем по .id const scripts = await client.print('system/script'); const list = Array.isArray(scripts?.data) ? scripts.data : (scripts?.data ? [scripts.data] : []); const found = list.find(s => (s.name || s['.id']) === script); const scriptId = found ? (found['.id'] || found.name) : script; await client.command('system/script/run', { '.id': scriptId }); return sendOk(res, { ok: true, message: `Скрипт ${script} запущен` }); } catch (error) { const msg = error.response?.data?.message || error.message || 'Ошибка запуска скрипта'; const status = error.response?.status; console.error('runScript:', error); return sendError(res, status && status >= 400 ? status : 502, msg, 'E_SCRIPT'); } } module.exports = { generateMikrotikConfig, generateInterfaces, generateRecursiveRoutes, testMikrotikConnection, applyMikrotikConfig, runScript, pingViaInterface, };