/** * Роуты для генерации и применения конфигурации MikroTik * Использует RouterOS REST API — требует RouterOS 7.1+ с www-ssl (443) или www (80) */ const crypto = require('crypto'); const { GetObjectCommand } = require('@aws-sdk/client-s3'); const { s3, BUCKET_NAME, streamToString, readS3TextObject, writeS3JsonObject } = 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, rosPrint, rosAdd, rosSet, rosRemove } = require('../services/mikrotikApplyService'); const IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json'; const NETWORK_CONFIG_KEY = 'network-config.json'; const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json'; const PING_CACHE_PREFIX = 'ping-cache/'; const SPEEDTEST_CACHE_PREFIX = 'speed-test-cache/'; const UPTIME_CACHE_KEY = 'uptime-monitor-cache/latest.json'; /** Загрузить UI-настройки из S3 (для pingDomain, pingCacheMinutes и др.) */ async function loadUiSettings() { try { const data = await readS3TextObject(UI_SETTINGS_KEY).catch(() => ({ body: '{}' })); const parsed = JSON.parse(data?.body || '{}'); return parsed && typeof parsed === 'object' ? parsed : {}; } catch (_) { return {}; } } /** Ключ кеша пинга по serverId, gatewayIp, target */ function pingCacheKey(serverId, gatewayIp, target) { const payload = `${serverId}|${gatewayIp || ''}|${target || ''}`; return PING_CACHE_PREFIX + crypto.createHash('sha256').update(payload, 'utf8').digest('hex') + '.json'; } /** Ключ кеша speed-test по serverId и interfaceName */ function speedTestCacheKey(serverId, interfaceName) { const payload = `${serverId}|${interfaceName || ''}`; return ( SPEEDTEST_CACHE_PREFIX + crypto.createHash('sha256').update(payload, 'utf8').digest('hex') + '.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 или home — входной роутер) */ function getMikrotikCredentials(server) { if (!server || (server.type !== 'jumphost' && server.type !== 'home')) 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 }; } /** * Учётные данные для удалённого конца (speed-test): любой сервер с host, * пароль может быть пустым (доступ по IP без пароля). */ function getRemoteMikrotikCredentials(server) { if (!server) return null; const host = server.mikrotikHost || server.ip || server.dns; if (!host) return null; const user = server.mikrotikUser || 'admin'; let password = ''; if (server.encryptedMikrotikPassword) { try { password = decrypt(server.encryptedMikrotikPassword); } catch (_) { return null; } } return { user, password }; } /** * 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' && server.type !== 'home')) { return sendError(res, 400, 'Сервер (jumphost или входной роутер) не найден', '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 res.json({ 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'); } } /** Записать результат проверки в кеш Uptime Monitor (S3). Не блокирует ответ. */ function updateUptimeCache(serverId, entry) { const { ok, lastCheckTs, ms } = entry; return readS3TextObject(UPTIME_CACHE_KEY) .catch(() => ({ body: '{}' })) .then((data) => { let cache = { results: {}, updatedAt: null }; try { const parsed = JSON.parse(data?.body || '{}'); if (parsed && typeof parsed === 'object' && parsed.results) cache = parsed; } catch (_) {} cache.results[serverId] = { ok, lastCheckTs, ms }; cache.updatedAt = Date.now(); return writeS3JsonObject(UPTIME_CACHE_KEY, cache); }) .catch((e) => console.warn('[uptime] cache write failed:', e?.message)); } /** * Получить данные кеша Uptime Monitor (для оповещений и др.). * Учитывает TTL из ui-settings.uptimeMonitorCacheSeconds. * @returns {Promise<{ results: Record, updatedAt: number|null }>} */ async function getUptimeCacheData() { try { const uiSettings = await loadUiSettings(); const ttlSec = Math.max(0, parseInt(uiSettings.uptimeMonitorCacheSeconds, 10) || 120); if (ttlSec === 0) return { results: {}, updatedAt: null }; const data = await readS3TextObject(UPTIME_CACHE_KEY).catch(() => null); if (!data?.body) return { results: {}, updatedAt: null }; let cache = { results: {}, updatedAt: null }; try { const parsed = JSON.parse(data.body); if (parsed && typeof parsed === 'object') cache = parsed; } catch (_) {} const now = Date.now(); if (!cache.updatedAt || now - cache.updatedAt > ttlSec * 1000) return { results: {}, updatedAt: null }; return { results: cache.results && typeof cache.results === 'object' ? cache.results : {}, updatedAt: cache.updatedAt, }; } catch (e) { console.warn('[uptime] getUptimeCacheData', e?.message); return { results: {}, updatedAt: null }; } } /** * GET /api/uptime/cache * Возвращает закешированные результаты проверок, если кеш младше TTL из настроек. */ async function getUptimeCache(req, res) { try { const { results, updatedAt } = await getUptimeCacheData(); return res.json({ results: Object.keys(results).length ? results : {}, updatedAt, }); } catch (e) { console.error('[uptime] getUptimeCache', e); return res.json({ results: {}, updatedAt: null }); } } /** * POST /api/uptime/check * Проверка доступности одного сервера (jumphost/home). Тип проверки берётся из ui-settings.uptimeMonitorCheckType. * Body: { serverId } * Returns: { ok: boolean, ms?: number } * Результат пишется в кеш (S3) для отображения при заходе на страницу. */ async function uptimeCheck(req, res) { const t0 = Date.now(); try { const { serverId } = req.body || {}; if (!serverId) { return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); } const uiSettings = await loadUiSettings(); const checkType = String(uiSettings.uptimeMonitorCheckType || 'http').toLowerCase(); const effectiveType = checkType === 'internal-ping' || checkType === 'external-ping' ? checkType : 'http'; const servers = await readServersFromS3(); const server = servers.find((s) => (s.id || s.dns || s.ip) === serverId); if (!server || !['jumphost', 'home'].includes(String(server.type || '').toLowerCase())) { return sendError(res, 400, 'Jumphost или Home сервер не найден', 'E_NOT_FOUND'); } let ok = false; let ms = null; if (effectiveType === 'http') { const creds = getMikrotikCredentials(server); if (!creds) { return sendError(res, 400, 'MikroTik credentials not configured', 'E_CREDENTIALS'); } const client = createRosClient(creds); await client.print('system/resource'); ok = true; ms = Date.now() - t0; const lastCheckTs = Date.now(); updateUptimeCache(serverId, { ok, lastCheckTs, ms }); return res.json({ ok, ms }); } if (effectiveType === 'internal-ping') { const config = await loadNetworkConfig(); const tunnelInterfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : []; const iface = tunnelInterfaces.find( (i) => (i.serverId && (i.serverId === serverId || i.serverId === server.ip || i.serverId === server.dns)) || (i.serverId2 && (i.serverId2 === serverId || i.serverId2 === server.ip || i.serverId2 === server.dns)) ); if (!iface || (!iface.remoteIp && !iface.localIp)) { ms = Date.now() - t0; const lastCheckTs = Date.now(); updateUptimeCache(serverId, { ok: false, lastCheckTs, ms }); return res.json({ ok: false, ms, error: 'Нет туннеля с внутренним адресом для этого сервера' }); } const target = iface.serverId === serverId || iface.serverId === server.ip || iface.serverId === server.dns ? iface.remoteIp : iface.localIp; const gatewayIp = target; if (!target) { ms = Date.now() - t0; const lastCheckTs = Date.now(); updateUptimeCache(serverId, { ok: false, lastCheckTs, ms }); return res.json({ ok: false, ms, error: 'Нет целевого адреса для пинга' }); } try { const result = await runPingViaRouter(serverId, gatewayIp, target, 3); ms = Date.now() - t0; ok = typeof result.avgMs === 'number'; const lastCheckTs = Date.now(); updateUptimeCache(serverId, { ok, lastCheckTs, ms: ok ? result.avgMs : ms }); return res.json({ ok, ms: ok ? result.avgMs : ms }); } catch (err) { ms = Date.now() - t0; const lastCheckTs = Date.now(); updateUptimeCache(serverId, { ok: false, lastCheckTs, ms }); return res.json({ ok: false, ms, error: err?.message || String(err) }); } } if (effectiveType === 'external-ping') { const others = servers.filter( (s) => ['jumphost', 'home'].includes(String(s.type || '').toLowerCase()) && (s.id || s.dns || s.ip) !== serverId && s.ip !== server.ip ); const sourceServer = others[0]; if (!sourceServer) { ms = Date.now() - t0; const lastCheckTs = Date.now(); updateUptimeCache(serverId, { ok: false, lastCheckTs, ms }); return res.json({ ok: false, ms, error: 'Нет другого jumphost для внешнего пинга' }); } const targetIp = server.ip || server.extIp; if (!targetIp) { ms = Date.now() - t0; const lastCheckTs = Date.now(); updateUptimeCache(serverId, { ok: false, lastCheckTs, ms }); return res.json({ ok: false, ms, error: 'У сервера нет внешнего IP' }); } const sourceId = sourceServer.id || sourceServer.dns || sourceServer.ip; try { const result = await runPingViaRouter(sourceId, null, targetIp, 3); ms = Date.now() - t0; ok = typeof result.avgMs === 'number'; const lastCheckTs = Date.now(); updateUptimeCache(serverId, { ok, lastCheckTs, ms: ok ? result.avgMs : ms }); return res.json({ ok, ms: ok ? result.avgMs : ms }); } catch (err) { ms = Date.now() - t0; const lastCheckTs = Date.now(); updateUptimeCache(serverId, { ok: false, lastCheckTs, ms }); return res.json({ ok: false, ms, error: err?.message || String(err) }); } } ms = Date.now() - t0; const lastCheckTs = Date.now(); updateUptimeCache(serverId, { ok: false, lastCheckTs, ms }); return res.json({ ok: false, ms }); } catch (error) { const ms = Date.now() - t0; const msg = error?.message || String(error); console.error('uptimeCheck:', error); return res.status(502).json({ ok: false, ms, error: msg }); } } /** * 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'); } } /** * Выполнить один пинг через RouterOS (без HTTP, без кеша). * @returns Promise<{ avgMs, minMs, maxMs, loss }> */ async function runPingViaRouter(serverId, gatewayIp, target, count = 5, interfaceName = null) { const servers = await readServersFromS3(); const server = servers.find((s) => (s.id || s.dns || s.ip) === serverId); if (!server || (server.type !== 'jumphost' && server.type !== 'home')) { throw new Error('Сервер (jumphost или входной роутер) не найден'); } const creds = getMikrotikCredentials(server); if (!creds) { throw new Error('MikroTik credentials not configured for this server'); } const client = createRosClient(creds); let ifaceName = interfaceName || null; const isHomeNoGateway = server.type === 'home' && !gatewayIp && !interfaceName; if (!ifaceName && gatewayIp) { const config = await loadNetworkConfig(); const tunnelIfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : []; // 1) Пытаемся сопоставить gatewayIp с локальным/удалённым IP туннельного интерфейса (сервер может быть serverId или serverId2) const iface = tunnelIfaces.find((i) => { const sameServer = i.serverId === serverId || i.serverId === server.ip || i.serverId === server.dns || i.serverId2 === serverId || i.serverId2 === server.ip || i.serverId2 === 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 || (g.serverId2 && (g.serverId2 === serverId || g.serverId2 === server.ip || g.serverId2 === 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 в формате "IP%interface" или отдельное поле interface). if (!ifaceName) { try { const routeRes = await client.command('ip/route/print', { '.proplist': ['gateway', 'interface'], }); const data = routeRes?.data; const routes = Array.isArray(data) ? data : (data ? [data] : []); const routeList = Array.isArray(routes) ? routes : (routes && typeof routes === 'object' && routes.ret ? routes.ret : []); const match = routeList.find((r) => { const gw = r.gateway ?? r.Gateway; return typeof gw === 'string' && (gw.includes(gatewayIp) || gw.startsWith(gatewayIp)); }); if (match) { const gwStr = String(match.gateway ?? match.Gateway ?? ''); const ifaceFromRoute = match.interface ?? match.Interface; if (typeof ifaceFromRoute === 'string' && ifaceFromRoute.trim()) { ifaceName = ifaceFromRoute.trim(); } else { const percentIdx = gwStr.indexOf('%'); if (percentIdx >= 0 && percentIdx < gwStr.length - 1) { ifaceName = gwStr.slice(percentIdx + 1).trim(); } } } } catch (_) { // Если чтение маршрутов не удалось — просто продолжаем без interface. } } } // 4) При указанном gatewayIp не подставляем ether1/ether2 — иначе пинг пойдёт не в туннель. Оставляем interface пустым, RouterOS выберет маршрут. // Фоллбек ether1/ether2 только если шлюз не указан (редкий кейс). if (!ifaceName && !isHomeNoGateway && !gatewayIp) { 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; return { ok: true, avgMs, minMs, maxMs, loss }; } /** * 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 { let { serverId, gatewayIp, interfaceName, target, count = 5, forceRefresh = false, } = req.body || {}; if (!serverId) { return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); } const uiSettings = await loadUiSettings(); if (target == null || String(target).trim() === '') { const pingDomain = (uiSettings.pingDomain && String(uiSettings.pingDomain).trim()) || ''; target = pingDomain || 'www.gstatic.com'; } else { target = String(target).trim(); } const pingCacheMinutes = Math.max(0, parseInt(uiSettings.pingCacheMinutes, 10) || 0); const cacheKey = pingCacheMinutes > 0 ? pingCacheKey(serverId, gatewayIp, target) : null; const skipCacheRead = forceRefresh === true; if (!skipCacheRead && cacheKey) { try { const data = await readS3TextObject(cacheKey).catch(() => null); if (data?.body) { const cached = JSON.parse(data.body); const cachedAt = typeof cached.cachedAt === 'number' ? cached.cachedAt : 0; const ttlMs = pingCacheMinutes * 60 * 1000; if (cachedAt && Date.now() - cachedAt < ttlMs) { return res.json({ ok: true, avgMs: cached.avgMs, minMs: cached.minMs, maxMs: cached.maxMs, loss: cached.loss, }); } } } catch (_) {} } const result = await runPingViaRouter(serverId, gatewayIp, target, count, interfaceName); if (cacheKey) { writeS3JsonObject(cacheKey, { avgMs: result.avgMs, minMs: result.minMs, maxMs: result.maxMs, loss: result.loss, cachedAt: Date.now(), }).catch((err) => console.warn('[mikrotik][pingViaInterface] cache write failed:', err?.message)); } return res.json(result); } 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/traceroute * Трассировка до адреса через указанный MikroTik (jumphost) и, при желании, конкретный gateway. * * Body: * - serverId: ID/имя сервера из servers.json (обязательно) * - target: адрес назначения (обязательно) * - gatewayIp?: IP шлюза, через который выполнять трассировку * - maxHops?: максимальное количество хопов (по умолчанию 30) */ async function tracerouteViaGateway(req, res) { try { let { serverId, target, gatewayIp, maxHops = 30, useDns = true, } = req.body || {}; if (!serverId) { return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); } if (target == null || String(target).trim() === '') { return sendError(res, 400, 'target is required', 'E_BAD_REQUEST'); } target = String(target).trim(); 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); // Пытаемся определить interface по gatewayIp (как в pingViaInterface) let ifaceName = null; if (gatewayIp) { const config = await loadNetworkConfig(); const tunnelIfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : []; // 1) Сопоставляем gatewayIp с локальным/удалённым IP туннельного интерфейса (сервер может быть serverId или serverId2) const iface = tunnelIfaces.find((i) => { const sameServer = i.serverId === serverId || i.serverId === server.ip || i.serverId === server.dns || i.serverId2 === serverId || i.serverId2 === server.ip || i.serverId2 === 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 || (g.serverId2 && (g.serverId2 === serverId || g.serverId2 === server.ip || g.serverId2 === 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 в формате "IP%interface" или отдельное поле interface). if (!ifaceName) { try { const routeRes = await client.command('ip/route/print', { '.proplist': ['gateway', 'interface'], }); const data = routeRes?.data; const routes = Array.isArray(data) ? data : (data ? [data] : []); const routeList = Array.isArray(routes) ? routes : (routes && typeof routes === 'object' && routes.ret ? routes.ret : []); const match = routeList.find((r) => { const gw = r.gateway ?? r.Gateway; return typeof gw === 'string' && (gw.includes(gatewayIp) || gw.startsWith(gatewayIp)); }); if (match) { const gwStr = String(match.gateway ?? match.Gateway ?? ''); const ifaceFromRoute = match.interface ?? match.Interface; if (typeof ifaceFromRoute === 'string' && ifaceFromRoute.trim()) { ifaceName = ifaceFromRoute.trim(); } else { const percentIdx = gwStr.indexOf('%'); if (percentIdx >= 0 && percentIdx < gwStr.length - 1) { ifaceName = gwStr.slice(percentIdx + 1).trim(); } } } } catch (_) { // Если чтение маршрутов не удалось — просто продолжаем без interface. } } } // 4) При указанном gatewayIp не подставляем ether1/ether2. Фоллбек только если шлюз не указан. if (!ifaceName && !gatewayIp) { 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 = { address: target, }; if (ifaceName) { body.interface = ifaceName; } // Ограничиваем количество пакетов на каждый хоп (как в ручной команде count=1) let countNum = Number(req.body?.count ?? 1); if (!Number.isFinite(countNum) || countNum <= 0) countNum = 1; body.count = countNum; // Включаем/отключаем reverse DNS (как use-dns=yes/no) body['use-dns'] = useDns ? 'yes' : 'no'; const hopsNum = Number(maxHops); if (Number.isFinite(hopsNum) && hopsNum > 0) { body['max-hops'] = hopsNum; } try { const cliParts = [ '/tool/traceroute', `address=${body.address}`, ]; if (ifaceName) cliParts.push(`interface=${ifaceName}`); if (body['max-hops']) cliParts.push(`max-hops=${body['max-hops']}`); console.log('[mikrotik][tracerouteViaGateway]', { serverId, gatewayIp, body, cli: cliParts.join(' '), }); } catch (_) {} const trRes = await client.command('tool/traceroute', body); const raw = trRes?.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; }; // MikroTik REST для traceroute может отдавать несколько строк // по одному и тому же хопу. Дедуплицируем по host+avg+loss и // выбрасываем пустые служебные строки без host. const hops = []; const seen = new Set(); for (const r of rows) { const ip = (r.address && String(r.address).trim()) || ''; const hostField = (r.host && String(r.host).trim()) || ''; // Если host есть и отличается от IP, считаем его DNS-именем, // иначе всё, что есть, считаем IP. const dns = hostField && hostField !== ip ? hostField : ''; const host = dns || ip; // Пропускаем полностью пустые строки без адреса и статуса if (!host && !r.status) continue; const avgField = r.avg || r['avg-rtt'] || r.time; const lossField = r['packet-loss']; const key = `${host}|${avgField || ''}|${lossField || ''}`; if (host && seen.has(key)) continue; if (host) seen.add(key); hops.push({ hop: null, // заполним ниже последовательной нумерацией host, // отображаемое значение по умолчанию ip: ip || null, dns: dns || null, avgMs: parseMs(avgField), bestMs: parseMs(r.best || r['best-rtt'] || r['min-rtt']), worstMs: parseMs(r.worst || r['worst-rtt'] || r['max-rtt']), loss: lossField != null ? Number(String(lossField).replace('%', '')) : null, status: r.status || '', raw: r, }); } // Финальная нумерация хопов 1..N hops.forEach((h, idx) => { h.hop = idx + 1; }); return res.json({ ok: true, hops }); } catch (error) { const msg = error.response?.data?.message || error.message || 'Traceroute failed'; const status = error.response?.status; console.error('tracerouteViaGateway:', error); return sendError(res, status && status >= 400 ? status : 502, msg, 'E_TRACEROUTE'); } } function parseSpeedToBps(val) { if (val == null) return null; const s = String(val).trim(); const m = s.match(/([\d.]+)\s*([kKmMgG])?[bB]ps/); if (!m) return null; const num = Number(m[1]); if (!Number.isFinite(num)) return null; const unit = (m[2] || '').toLowerCase(); let mult = 1; if (unit === 'k') mult = 1e3; else if (unit === 'm') mult = 1e6; else if (unit === 'g') mult = 1e9; return num * mult; } /** * POST /api/mikrotik/speed-test * Замер TCP download/upload между двумя MikroTik по туннельному интерфейсу. * * Body: * - serverId (обязательный) — локальный сервер (jumphost/home) * - interfaceName (обязательный) — имя туннельного интерфейса (как в /network-config.tunnelInterfaces и RouterOS) * - durationSeconds? — время теста для каждого этапа (по умолчанию из ui-settings.interfaceSpeedTestDurationSeconds или 10 сек) * * Локальный роутер выполняет /tool/speed-test до IP удалённого конца туннеля (localIp/remoteIp), * используя учетные данные удалённого MikroTik из servers.json. Берём tcp-download и tcp-upload. * * Важно: этот маршрут не использует блокировок S3 и не блокирует другие API (статистика ресурсов, * трафика и т.д.) — они выполняются параллельно на бэкенде. Задержки при открытии других страниц * во время замера чаще связаны с лимитом одновременных соединений браузера к одному домену. */ async function speedTestViaTunnel(req, res) { try { const { serverId, interfaceName, cacheOnly, forceRefresh } = req.body || {}; let { durationSeconds } = req.body || {}; if (!serverId || !interfaceName) { return sendError( res, 400, 'serverId и interfaceName обязательны', 'E_BAD_REQUEST' ); } const uiSettings = await loadUiSettings(); const cacheMinutes = Math.max( 0, parseInt(uiSettings.interfaceSpeedTestCacheMinutes, 10) || 0 ); const cacheKey = cacheMinutes > 0 ? speedTestCacheKey(serverId, interfaceName) : null; // При выборе сервера/интерфейса — только кеш. При нажатии «Замерить» — принудительно новый замер (forceRefresh). const skipCacheRead = forceRefresh === true; if (!skipCacheRead && cacheKey) { try { const raw = await readS3TextObject(cacheKey).catch(() => null); if (raw?.body) { const cached = JSON.parse(raw.body); const cachedAt = typeof cached.cachedAt === 'number' ? cached.cachedAt : 0; const ttlMs = cacheMinutes * 60 * 1000; if (cachedAt && Date.now() - cachedAt < ttlMs) { return res.json({ ...cached, cached: true }); } } } catch (_) { // Если кеш не прочитался — продолжаем без него } } if (cacheOnly === true) { return res.json({ ok: false, notInCache: true }); } const defaultDuration = Math.max( 1, Math.min( 600, parseInt(uiSettings.interfaceSpeedTestDurationSeconds, 10) || 10 ) ); durationSeconds = Math.max( 1, Math.min( 600, parseInt( durationSeconds != null ? durationSeconds : defaultDuration, 10 ) || defaultDuration ) ); const config = await loadNetworkConfig(); const tunnelIfaces = Array.isArray(config.tunnelInterfaces) ? config.tunnelInterfaces : []; const tun = tunnelIfaces.find( (i) => i && i.name === interfaceName && (i.serverId === serverId || i.serverId2 === serverId) ); if (!tun) { return sendError( res, 400, `Интерфейс "${interfaceName}" не найден в tunnelInterfaces для сервера ${serverId}`, 'E_TUN_IFACE_NOT_FOUND' ); } const servers = await readServersFromS3(); const allServers = Array.isArray(servers) ? servers : []; const localServer = allServers.find((s) => (s.id || s.dns || s.ip) === serverId) || null; if ( !localServer || (localServer.type !== 'jumphost' && String(localServer.type || '').toLowerCase() !== 'home') ) { return sendError( res, 400, 'Сервер (jumphost или home) не найден', 'E_NOT_FOUND' ); } const remoteKey = tun.serverId === serverId ? tun.serverId2 : tun.serverId; const remoteServer = allServers.find((s) => (s.id || s.dns || s.ip) === remoteKey) || null; if (!remoteServer) { return sendError( res, 400, 'Удалённый сервер туннеля не найден', 'E_REMOTE_NOT_FOUND' ); } const localCreds = getMikrotikCredentials(localServer); const remoteCreds = getRemoteMikrotikCredentials(remoteServer); if (!localCreds) { return sendError( res, 400, 'MikroTik credentials not configured for local server', 'E_LOCAL_CREDENTIALS' ); } if (!remoteCreds) { return sendError( res, 400, 'Удалённый сервер не найден или не задан host (ip/dns)', 'E_REMOTE_CREDENTIALS' ); } const client = createRosClient(localCreds); const address = tun.serverId === serverId ? tun.remoteIp || remoteServer.mikrotikHost || remoteServer.ip || remoteServer.dns : tun.localIp || remoteServer.mikrotikHost || remoteServer.ip || remoteServer.dns; if (!address) { return sendError( res, 400, 'Не удалось определить адрес удалённого конца туннеля для speed-test', 'E_NO_ADDRESS' ); } const body = { address, user: remoteCreds.user, password: remoteCreds.password || '', 'test-duration': `${durationSeconds}s`, proplist: 'tcp-download,tcp-upload', }; try { const cliParts = [ '/tool/speed-test', `address=${body.address}`, `user=${body.user}`, `test-duration=${durationSeconds}s`, 'proplist=tcp-download,tcp-upload', ]; console.log('[mikrotik][speedTestViaTunnel]', { serverId, interfaceName, remoteServerId: remoteKey, body: { ...body, password: '***' }, cli: cliParts.join(' '), }); } catch (_) {} const stRes = await client.command('tool/speed-test', body); const raw = stRes?.data; const rows = Array.isArray(raw) ? raw : raw ? [raw] : []; if (!rows.length) { return sendError( res, 502, 'RouterOS speed-test не вернул данных', 'E_SPEEDTEST_EMPTY' ); } const summary = [...rows] .reverse() .find((r) => r['tcp-download'] || r['tcp-upload']) || rows[rows.length - 1]; const tcpDownloadStr = summary['tcp-download'] || null; const tcpUploadStr = summary['tcp-upload'] || null; const tcpDownloadBps = parseSpeedToBps(tcpDownloadStr); const tcpUploadBps = parseSpeedToBps(tcpUploadStr); const totalBps = (tcpDownloadBps || 0) + (tcpUploadBps || 0); const payload = { ok: true, serverId, remoteServerId: remoteKey || null, interfaceName, address, durationSeconds, tcpDownload: tcpDownloadStr, tcpUpload: tcpUploadStr, tcpDownloadBps, tcpUploadBps, totalBps, cached: false, }; if (cacheKey) { writeS3JsonObject(cacheKey, { ...payload, cached: false, cachedAt: Date.now(), }).catch((err) => console.warn( '[mikrotik][speedTestViaTunnel] cache write failed:', err?.message || err ) ); } return res.json(payload); } catch (error) { const msg = error.response?.data?.message || error.message || 'Speed-test failed'; const status = error.response?.status; console.error('speedTestViaTunnel:', error); return sendError( res, status && status >= 400 ? status : 502, msg, 'E_SPEEDTEST' ); } } /** * POST /api/mikrotik/run-script * Body: { serverId, script?: string } — по умолчанию script=update_bgp_filter */ const SCRIPT_NAME_REGEX = /^[a-zA-Z0-9_-]{1,64}$/; 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 scriptStr = String(script).trim(); if (!SCRIPT_NAME_REGEX.test(scriptStr)) { return sendError(res, 400, 'script: only alphanumeric, underscore, hyphen, max 64 chars', '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']) === scriptStr); const scriptId = found ? (found['.id'] || found.name) : scriptStr; await client.command('system/script/run', { '.id': scriptId }); return res.json({ ok: true, message: `Скрипт ${scriptStr} запущен` }); } 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'); } } /** Логирование в контейнер: JSON запроса/ответа и эквивалент CLI MikroTik */ function logAddressListRequest(creds, method, path, query) { const protocol = creds.secure ? 'https' : 'http'; const url = `${protocol}://${creds.host}:${creds.port}/rest/${path}${query ? `?${query}` : ''}`; const cli = '/ip firewall address-list print'; const payload = { component: 'address-lists', request: { method, path, url, cli } }; console.log(JSON.stringify(payload)); console.log(`[address-lists] MikroTik CLI: ${cli}`); } function logAddressListResponse(raw, listRawLength, banCount, banCounterCount) { const firstKeys = Array.isArray(raw) && raw[0] && typeof raw[0] === 'object' ? Object.keys(raw[0]) : (raw && typeof raw === 'object' && !Array.isArray(raw) ? Object.keys(raw) : []); let rawSnippet; try { const s = JSON.stringify(raw); rawSnippet = s.length > 800 ? s.slice(0, 800) + '...' : s; } catch (_) { rawSnippet = String(raw); } const payload = { component: 'address-lists', response: { rawIsArray: Array.isArray(raw), rawLength: Array.isArray(raw) ? raw.length : undefined, listRawLength: listRawLength, banCount, banCounterCount, firstItemKeys: firstKeys, rawSnippet: rawSnippet?.slice(0, 600), }, }; console.log(JSON.stringify(payload)); } /** * GET /api/mikrotik/address-lists?serverId=xxx * Возвращает address-list списки ban и ban_counter с выбранного роутера (MikroTik). */ async function getAddressLists(req, res) { try { const serverId = req.query?.serverId; 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' && server.type !== 'home')) { return sendError(res, 400, 'Jumphost or home 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); const path = 'ip/firewall/address-list'; logAddressListRequest(creds, 'GET', path, ''); let all = await rosPrint(client, path); if (!Array.isArray(all) || all.length === 0) { logAddressListRequest(creds, 'POST', `${path}/print`, ''); try { const postRes = await client.command(`${path}/print`, {}); const data = postRes?.data; if (data && typeof data === 'object' && Array.isArray(data.ret)) { all = data.ret; } else if (Array.isArray(data)) { all = data; } else if (data && typeof data === 'object') { all = Array.isArray(data) ? data : [data]; } } catch (postErr) { console.error(JSON.stringify({ component: 'address-lists', postFallbackError: postErr?.message || String(postErr) })); } } if (Array.isArray(all) && all.length === 1 && all[0] && typeof all[0] === 'object' && Array.isArray(all[0].ret)) { all = all[0].ret; } else if (all && typeof all === 'object' && !Array.isArray(all) && Array.isArray(all.ret)) { all = all.ret; } const listRaw = Array.isArray(all) ? all : []; // Имя списка: RouterOS REST отдаёт "list" (или "List"), возможен kebab "address-list" const getListName = (entry) => { if (!entry || typeof entry !== 'object') return ''; const name = entry.list ?? entry.List ?? entry['address-list'] ?? ''; return String(name).trim().toLowerCase(); }; const ban = listRaw.filter((e) => getListName(e) === 'ban'); const banCounter = listRaw.filter((e) => getListName(e) === 'ban_counter'); logAddressListResponse(all, listRaw.length, ban.length, banCounter.length); return res.json({ ok: true, serverId, serverLabel: server.dns || server.ip || serverId, ban, ban_counter: banCounter, }); } catch (error) { const msg = error.response?.data?.detail || error.response?.data?.message || error.message || 'Ошибка получения address-list'; const status = error.response?.status; console.error(JSON.stringify({ component: 'address-lists', error: msg, stack: error?.stack })); return sendError(res, status && status >= 400 ? status : 502, msg, 'E_ADDRESS_LISTS'); } } function normalizeOspfInterfaceName(entry) { if (!entry || typeof entry !== 'object') return ''; return String(entry.interfaces ?? entry.interface ?? '').trim(); } function parseOspfCost(value) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed < 0) return null; return parsed; } function sameOspfInterface(a, b) { return String(a || '').trim().toUpperCase() === String(b || '').trim().toUpperCase(); } function sameOspfArea(a, b) { const left = String(a || '').trim().toUpperCase() || 'BACKBONE'; const right = String(b || '').trim().toUpperCase() || 'BACKBONE'; return left === right; } /** * GET /api/mikrotik/ospf-interface-templates?serverId= * Возвращает текущие OSPF interface-template с HOME роутеров. */ async function getOspfInterfaceTemplates(req, res) { try { const requestedServerId = String(req.query?.serverId || '').trim(); const servers = await readServersFromS3(); const homeServers = servers.filter((s) => String(s?.type || '').toLowerCase() === 'home'); const targets = requestedServerId ? homeServers.filter((s) => [s.id, s.ip, s.dns].map((x) => String(x || '')).includes(requestedServerId)) : homeServers; if (requestedServerId && targets.length === 0) { return sendError(res, 404, 'HOME server not found', 'E_NOT_FOUND'); } const results = []; const errors = []; for (const server of targets) { const serverId = server.id || server.ip || server.dns; const creds = getMikrotikCredentials(server); if (!creds) { errors.push({ serverId, serverLabel: server.dns || server.ip || serverId, error: 'MikroTik credentials not configured for this server', }); continue; } try { const client = createRosClient(creds); const raw = await rosPrint(client, 'routing/ospf/interface-template'); const templates = (Array.isArray(raw) ? raw : []) .map((item) => { const interfaceName = normalizeOspfInterfaceName(item); if (!interfaceName) return null; return { id: item['.id'] || null, interfaceName, cost: parseOspfCost(item.cost), area: item.area || '', networks: item.networks || '', networkType: item['type'] || item['network-type'] || '', disabled: String(item.disabled || '').toLowerCase() === 'true', }; }) .filter(Boolean); results.push({ serverId, serverLabel: server.dns || server.ip || serverId, templates, }); } catch (error) { errors.push({ serverId, serverLabel: server.dns || server.ip || serverId, error: error?.response?.data?.detail || error?.response?.data?.message || error.message || 'Failed to read OSPF templates', }); } } return res.json({ ok: errors.length === 0, results, errors, }); } catch (error) { return sendError(res, 500, error.message || 'Failed to load OSPF templates', 'E_OSPF_LOAD'); } } /** * POST /api/mikrotik/ospf-interface-templates/apply * Body: { servers: [{ serverId, templates: [{ rosId?, interfaceName, area?, cost }] }] } */ async function applyOspfInterfaceTemplates(req, res) { try { const payloadServers = Array.isArray(req.body?.servers) ? req.body.servers : []; if (payloadServers.length === 0) { return sendError(res, 400, 'servers array is required', 'E_BAD_REQUEST'); } const servers = await readServersFromS3(); const results = []; const errors = []; const summary = { updated: 0, skipped: 0, missing: 0, invalid: 0 }; for (const payload of payloadServers) { const serverRef = String(payload?.serverId || '').trim(); const desiredTemplates = Array.isArray(payload?.templates) ? payload.templates : []; const server = servers.find((s) => [s?.id, s?.ip, s?.dns].map((x) => String(x || '')).includes(serverRef) ); if (!server || String(server.type || '').toLowerCase() !== 'home') { errors.push({ serverId: serverRef, error: 'HOME server not found' }); continue; } const serverId = server.id || server.ip || server.dns; const creds = getMikrotikCredentials(server); if (!creds) { errors.push({ serverId, error: 'MikroTik credentials not configured for this server' }); continue; } try { const client = createRosClient(creds); const raw = await rosPrint(client, 'routing/ospf/interface-template'); const existing = (Array.isArray(raw) ? raw : []).filter((item) => normalizeOspfInterfaceName(item)); let updated = 0; let skipped = 0; let missing = 0; let invalid = 0; for (const desired of desiredTemplates) { const desiredRosId = String(desired?.rosId || '').trim(); const interfaceName = String(desired?.interfaceName || '').trim(); const desiredArea = String(desired?.area || '').trim(); const desiredCost = parseOspfCost(desired?.cost); if ((!interfaceName && !desiredRosId) || desiredCost == null) { invalid += 1; continue; } let match = null; if (desiredRosId) { match = existing.find((item) => String(item?.['.id'] || '') === desiredRosId); } if (!match && interfaceName) { match = existing.find((item) => { if (!sameOspfInterface(normalizeOspfInterfaceName(item), interfaceName)) return false; if (!desiredArea) return true; return sameOspfArea(item?.area, desiredArea); }); } if (!match || !match['.id']) { missing += 1; continue; } const currentCost = parseOspfCost(match.cost); if (currentCost === desiredCost) { skipped += 1; continue; } await rosSet(client, 'routing/ospf/interface-template', match['.id'], { cost: String(desiredCost) }); updated += 1; } summary.updated += updated; summary.skipped += skipped; summary.missing += missing; summary.invalid += invalid; results.push({ serverId, serverLabel: server.dns || server.ip || serverId, updated, skipped, missing, invalid, }); } catch (error) { errors.push({ serverId, serverLabel: server.dns || server.ip || serverId, error: error?.response?.data?.detail || error?.response?.data?.message || error.message || 'Failed to apply OSPF templates', }); } } return res.json({ ok: errors.length === 0, summary, results, errors, }); } catch (error) { return sendError(res, 500, error.message || 'Failed to apply OSPF templates', 'E_OSPF_APPLY'); } } const ADDRESS_LIST_PATH = 'ip/firewall/address-list'; const ADDRESS_LIST_REMOVE_CHUNK_SIZE = 100; const ADDRESS_LIST_ADD_CONCURRENCY = 8; const ADDRESS_LIST_ADD_EXECUTE_CHUNK_SIZE = 80; function chunkArray(items, size) { if (!Array.isArray(items) || items.length === 0) return []; const chunkSize = Math.max(1, Number(size) || 1); const chunks = []; for (let i = 0; i < items.length; i += chunkSize) { chunks.push(items.slice(i, i + chunkSize)); } return chunks; } /** * Удаление address-list по .id: * 1) пытаемся батчем через POST .../remove { numbers: "*1,*2" } * 2) при ошибке откатываемся к поштучному DELETE, чтобы не ломать совместимость. */ async function removeAddressListIds(client, ids) { const normalizedIds = [...new Set( (Array.isArray(ids) ? ids : []) .map((id) => String(id || '').trim()) .filter(Boolean) )]; if (normalizedIds.length === 0) return 0; let removed = 0; const chunks = chunkArray(normalizedIds, ADDRESS_LIST_REMOVE_CHUNK_SIZE); for (const chunk of chunks) { try { await client.command(`${ADDRESS_LIST_PATH}/remove`, { numbers: chunk.join(',') }); removed += chunk.length; continue; } catch (err) { console.warn( `[address-lists] batch remove fallback (${chunk.length} ids):`, err?.message || String(err) ); } for (const id of chunk) { try { await rosRemove(client, ADDRESS_LIST_PATH, id); removed++; } catch (err) { console.warn(`[address-lists] remove ${id}:`, err?.message); } } } return removed; } function normalizeAddressListEntries(entries) { return [...new Map( (Array.isArray(entries) ? entries : []) .map((entry) => { const address = String(entry?.address || '').trim(); const list = String(entry?.list || 'ban').trim(); const comment = entry?.comment != null ? String(entry.comment).trim() : ''; if (!address || !list) return null; const params = { address, list }; if (comment) params.comment = comment; const key = `${list}|${address}|${comment}`; return [key, params]; }) .filter(Boolean) ).values()]; } function escapeRouterOsString(value) { return `"${String(value || '') .replace(/\\/g, '\\\\') .replace(/"/g, '\\"')}"`; } function buildAddressListAddScript(entriesChunk) { return entriesChunk .map((params) => { const parts = [ '/ip/firewall/address-list/add', `address=${escapeRouterOsString(params.address)}`, `list=${escapeRouterOsString(params.list)}`, ]; if (params.comment != null && String(params.comment).trim()) { parts.push(`comment=${escapeRouterOsString(params.comment)}`); } return parts.join(' '); }) .join(';\n'); } async function addAddressListEntriesParallel(client, normalized) { if (!Array.isArray(normalized) || normalized.length === 0) return { added: 0, failed: 0 }; let index = 0; let added = 0; let failed = 0; const workersCount = Math.min(ADDRESS_LIST_ADD_CONCURRENCY, normalized.length); const workers = Array.from({ length: workersCount }, async () => { while (true) { const current = index; index += 1; if (current >= normalized.length) break; const params = normalized[current]; try { await rosAdd(client, ADDRESS_LIST_PATH, params); added++; } catch (err) { failed++; console.warn('[address-lists] add:', err?.message); } } }); await Promise.all(workers); return { added, failed }; } async function addAddressListEntries(client, entries) { const normalized = normalizeAddressListEntries(entries); if (normalized.length === 0) return { added: 0, failed: 0 }; let added = 0; let failed = 0; const chunks = chunkArray(normalized, ADDRESS_LIST_ADD_EXECUTE_CHUNK_SIZE); for (const chunk of chunks) { const script = buildAddressListAddScript(chunk); try { // Один execute на пачку резко снижает HTTP overhead. await client.command('execute', { script }); added += chunk.length; continue; } catch (err) { console.warn( `[address-lists] execute add fallback (${chunk.length} entries):`, err?.message || String(err) ); } const fallbackResult = await addAddressListEntriesParallel(client, chunk); added += fallbackResult.added; failed += fallbackResult.failed; } return { added, failed }; } /** * POST /api/mikrotik/address-lists/apply-summary * Body: { serverId, removeIds: string[], addEntries: { address, list, comment? }[] } * Удаляет записи по .id и добавляет новые (суммаризованные) в address-list. */ async function applyAddressListSummary(req, res) { try { const { serverId, removeIds = [], addEntries = [] } = req.body || {}; if (!serverId) { return sendError(res, 400, 'serverId is required', 'E_BAD_REQUEST'); } const ids = Array.isArray(removeIds) ? [...new Set(removeIds.map((id) => String(id || '').trim()).filter(Boolean))] : []; const entries = Array.isArray(addEntries) ? addEntries.filter((e) => e && (e.address || e.list)) : []; const servers = await readServersFromS3(); const server = servers.find((s) => (s.id || s.dns || s.ip) === serverId); if (!server || (server.type !== 'jumphost' && server.type !== 'home')) { return sendError(res, 400, 'Jumphost or home 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); let removed = 0; let added = 0; removed = await removeAddressListIds(client, ids); const addResult = await addAddressListEntries(client, entries); added = addResult.added; return res.json({ ok: true, removed, added }); } catch (error) { const msg = error.response?.data?.detail || error.response?.data?.message || error.message || 'Ошибка применения суммаризации'; const status = error.response?.status; console.error(JSON.stringify({ component: 'address-lists-apply', error: msg })); return sendError(res, status && status >= 400 ? status : 502, msg, 'E_APPLY_SUMMARY'); } } module.exports = { generateMikrotikConfig, generateInterfaces, generateRecursiveRoutes, testMikrotikConnection, applyMikrotikConfig, runScript, tracerouteViaGateway, pingViaInterface, runPingViaRouter, speedTestViaTunnel, loadNetworkConfig, getAddressLists, getOspfInterfaceTemplates, applyOspfInterfaceTemplates, applyAddressListSummary, uptimeCheck, getUptimeCache, getUptimeCacheData, };