Files
router-lists-ui/backend/routes/mikrotikConfigRoutes.js
T
2026-02-16 23:04:43 +07:00

849 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Роуты для генерации и применения конфигурации 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 } = 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/';
/** Загрузить 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';
}
/** Загрузить 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 };
}
/**
* 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 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 {
let {
serverId,
gatewayIp,
interfaceName,
target,
count = 5,
} = 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;
if (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 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;
const result = { ok: true, avgMs, minMs, maxMs, loss };
if (cacheKey) {
writeS3JsonObject(cacheKey, {
avgMs,
minMs,
maxMs,
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 туннельного интерфейса
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,
// чтобы не ломать сам traceroute.
}
}
}
// 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 = {
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');
}
}
/**
* 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,
tracerouteViaGateway,
pingViaInterface,
};