Files
router-lists-ui/backend/routes/mikrotikConfigRoutes.js
T
2026-02-18 12:24:51 +07:00

1159 lines
40 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/';
const SPEEDTEST_CACHE_PREFIX = 'speed-test-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';
}
/** Ключ кеша 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 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');
}
}
/**
* Выполнить один пинг через 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 туннельного интерфейса
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) Последний фоллбек (только для jumphost или home с указанным шлюзом): ether1/ether2.
// Для home без шлюза интерфейс не задаём — пинг идёт по маршруту по умолчанию.
if (!ifaceName && !isHomeNoGateway) {
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,
} = 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 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 туннельного интерфейса
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');
}
}
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.
*/
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
*/
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,
runPingViaRouter,
speedTestViaTunnel,
};