Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m46s
1692 lines
62 KiB
JavaScript
1692 lines
62 KiB
JavaScript
/**
|
||
* Роуты для генерации и применения конфигурации 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,
|
||
};
|