Files
router-lists-ui/backend/routes/mikrotikConfigRoutes.js
T
denozord cc92502de2
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m46s
feat(mikrotik): optimize address list entry addition with chunked execution and improved error handling
2026-02-24 15:10:54 +07:00

1692 lines
62 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, rosPrint, rosAdd, 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<string, { ok, lastCheckTs, ms }>, 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');
}
}
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,
applyAddressListSummary,
uptimeCheck,
getUptimeCache,
getUptimeCacheData,
};