Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m37s
322 lines
10 KiB
JavaScript
322 lines
10 KiB
JavaScript
/**
|
|
* Роуты для сбора статистики трафика с MikroTik RouterOS (интерфейсы по jumphost).
|
|
* GET /api/traffic/interface-stats — статистика rx/tx по каждому интерфейсу для всех jumphost.
|
|
*/
|
|
|
|
const { sendError } = require('../middleware/errorHandler');
|
|
const { decrypt } = require('../utils/encryption');
|
|
const { readServersFromS3 } = require('./serversRoutes');
|
|
const { createRosClient } = require('../services/mikrotikApplyService');
|
|
const { readS3TextObject, writeS3JsonObject } = require('../services/s3Service');
|
|
|
|
/** Получить 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;
|
|
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 };
|
|
}
|
|
|
|
// UI-настройки (общие с остальным UI)
|
|
const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json';
|
|
const INTERFACE_SPEED_CACHE_PREFIX = 'interface-speed-cache/';
|
|
|
|
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 {};
|
|
}
|
|
}
|
|
|
|
function interfaceSpeedCacheKey(serverId, interfaceName) {
|
|
const key = `${serverId || ''}|${interfaceName || ''}`;
|
|
// Хэш не нужен: ключи короткие, но на всякий случай экранируем странные символы
|
|
return (
|
|
INTERFACE_SPEED_CACHE_PREFIX +
|
|
String(key)
|
|
.trim()
|
|
.replace(/[^a-zA-Z0-9_.:-]+/g, '_') +
|
|
'.json'
|
|
);
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* GET /api/traffic/interface-stats
|
|
* Возвращает по каждому jumphost список интерфейсов с rx-byte, tx-byte (из RouterOS REST /interface).
|
|
*/
|
|
async function getInterfaceStats(req, res) {
|
|
try {
|
|
const servers = await readServersFromS3();
|
|
const jumphosts = (Array.isArray(servers) ? servers : []).filter(
|
|
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
|
|
);
|
|
|
|
const results = await Promise.all(
|
|
jumphosts.map(async (server) => {
|
|
const creds = getMikrotikCredentials(server);
|
|
if (!creds) {
|
|
return {
|
|
serverId: server.id,
|
|
name: server.name || server.dns || server.ip || server.id,
|
|
host: server.mikrotikHost || server.ip || server.dns,
|
|
error: 'MikroTik API не настроен или нет пароля',
|
|
interfaces: [],
|
|
};
|
|
}
|
|
try {
|
|
const client = createRosClient(creds);
|
|
const apiRes = await client.print('interface');
|
|
const raw = apiRes?.data;
|
|
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
|
const interfaces = list.map((iface) => {
|
|
const rx = iface['rx-byte'] != null ? parseInt(String(iface['rx-byte']), 10) : 0;
|
|
const tx = iface['tx-byte'] != null ? parseInt(String(iface['tx-byte']), 10) : 0;
|
|
const total = rx + tx;
|
|
return {
|
|
name: iface.name != null ? String(iface.name) : '',
|
|
type: iface.type != null ? String(iface.type) : '',
|
|
rxBytes: Number.isNaN(rx) ? 0 : rx,
|
|
txBytes: Number.isNaN(tx) ? 0 : tx,
|
|
totalBytes: Number.isNaN(total) ? 0 : total,
|
|
};
|
|
}).filter((i) => i.name && i.totalBytes >= 0);
|
|
return {
|
|
serverId: server.id,
|
|
name: server.name || server.dns || server.ip || server.id,
|
|
host: server.mikrotikHost || server.ip || server.dns,
|
|
interfaces,
|
|
};
|
|
} catch (err) {
|
|
const msg = err.response?.data?.message || err.message || 'Ошибка запроса к RouterOS';
|
|
return {
|
|
serverId: server.id,
|
|
name: server.name || server.dns || server.ip || server.id,
|
|
host: server.mikrotikHost || server.ip || server.dns,
|
|
error: msg,
|
|
interfaces: [],
|
|
};
|
|
}
|
|
})
|
|
);
|
|
|
|
return res.json({ jumphosts: results });
|
|
} catch (error) {
|
|
console.error('getInterfaceStats:', error);
|
|
return sendError(res, 500, error.message || 'Ошибка сбора статистики трафика', 'E_TRAFFIC');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/traffic/interface-speed-test
|
|
* Замеряет среднюю скорость по выбранному интерфейсу RouterOS на jumphost/home.
|
|
*
|
|
* Body:
|
|
* - serverId (обязательный) — id/dns/ip сервера из servers.json
|
|
* - interfaceName (обязательный) — имя интерфейса в RouterOS (как в /interface)
|
|
* - durationSeconds? — время замера (по умолчанию из ui-settings.interfaceSpeedTestDurationSeconds или 10 сек)
|
|
*
|
|
* Использует счётчики rx-byte/tx-byte: берёт значение в начале и в конце интервала
|
|
* и делит дельту на время замера.
|
|
*/
|
|
async function measureInterfaceSpeed(req, res) {
|
|
try {
|
|
const { serverId, interfaceName } = req.body || {};
|
|
let { durationSeconds } = req.body || {};
|
|
|
|
if (!serverId || !interfaceName) {
|
|
return sendError(
|
|
res,
|
|
400,
|
|
'serverId и interfaceName обязательны',
|
|
'E_BAD_REQUEST'
|
|
);
|
|
}
|
|
|
|
const uiSettings = await loadUiSettings();
|
|
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 cacheMinutes = Math.max(
|
|
0,
|
|
parseInt(uiSettings.interfaceSpeedTestCacheMinutes, 10) || 0
|
|
);
|
|
const cacheKey =
|
|
cacheMinutes > 0
|
|
? interfaceSpeedCacheKey(serverId, interfaceName)
|
|
: null;
|
|
|
|
if (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 (_) {
|
|
// Если кеш не прочитался — просто продолжаем без него
|
|
}
|
|
}
|
|
|
|
const servers = await readServersFromS3();
|
|
const server =
|
|
(Array.isArray(servers) ? servers : []).find(
|
|
(s) => (s.id || s.dns || s.ip) === serverId
|
|
) || null;
|
|
|
|
if (
|
|
!server ||
|
|
(server.type !== 'jumphost' &&
|
|
String(server.type || '').toLowerCase() !== 'home')
|
|
) {
|
|
return sendError(
|
|
res,
|
|
400,
|
|
'Сервер (jumphost или home) не найден',
|
|
'E_NOT_FOUND'
|
|
);
|
|
}
|
|
|
|
const creds = getMikrotikCredentials(server);
|
|
if (!creds) {
|
|
return sendError(
|
|
res,
|
|
400,
|
|
'MikroTik API не настроен или нет пароля',
|
|
'E_CREDENTIALS'
|
|
);
|
|
}
|
|
|
|
const client = createRosClient(creds);
|
|
|
|
async function readOne() {
|
|
const apiRes = await client.print('interface');
|
|
const raw = apiRes?.data;
|
|
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
|
const iface = list.find(
|
|
(i) =>
|
|
i &&
|
|
i.name != null &&
|
|
String(i.name).trim() === String(interfaceName).trim()
|
|
);
|
|
if (!iface) {
|
|
return null;
|
|
}
|
|
const rx =
|
|
iface['rx-byte'] != null
|
|
? parseInt(String(iface['rx-byte']), 10)
|
|
: 0;
|
|
const tx =
|
|
iface['tx-byte'] != null
|
|
? parseInt(String(iface['tx-byte']), 10)
|
|
: 0;
|
|
return {
|
|
rxBytes: Number.isNaN(rx) ? 0 : rx,
|
|
txBytes: Number.isNaN(tx) ? 0 : tx,
|
|
};
|
|
}
|
|
|
|
const notFoundMessage = `Интерфейс "${interfaceName}" не найден на RouterOS (${serverId})`;
|
|
|
|
const start = await readOne();
|
|
if (!start) {
|
|
return sendError(res, 400, notFoundMessage, 'E_IFACE_NOT_FOUND');
|
|
}
|
|
await sleep(durationSeconds * 1000);
|
|
const end = await readOne();
|
|
if (!end) {
|
|
return sendError(res, 400, notFoundMessage, 'E_IFACE_NOT_FOUND');
|
|
}
|
|
|
|
const rxBytesDelta = Math.max(0, (end.rxBytes || 0) - (start.rxBytes || 0));
|
|
const txBytesDelta = Math.max(0, (end.txBytes || 0) - (start.txBytes || 0));
|
|
const totalBytesDelta = rxBytesDelta + txBytesDelta;
|
|
const duration = durationSeconds || 1;
|
|
|
|
const rxBps = rxBytesDelta * 8 / duration;
|
|
const txBps = txBytesDelta * 8 / duration;
|
|
const totalBps = totalBytesDelta * 8 / duration;
|
|
|
|
const payload = {
|
|
ok: true,
|
|
serverId,
|
|
interfaceName,
|
|
durationSeconds: duration,
|
|
rxBytesStart: start.rxBytes,
|
|
txBytesStart: start.txBytes,
|
|
rxBytesEnd: end.rxBytes,
|
|
txBytesEnd: end.txBytes,
|
|
rxBytesDelta,
|
|
txBytesDelta,
|
|
totalBytesDelta,
|
|
rxBps,
|
|
txBps,
|
|
totalBps,
|
|
cached: false,
|
|
};
|
|
|
|
if (cacheKey) {
|
|
writeS3JsonObject(cacheKey, {
|
|
...payload,
|
|
cached: false,
|
|
cachedAt: Date.now(),
|
|
}).catch((err) =>
|
|
console.warn(
|
|
'[traffic][interface-speed-test] cache write failed:',
|
|
err?.message || err
|
|
)
|
|
);
|
|
}
|
|
|
|
return res.json(payload);
|
|
} catch (error) {
|
|
console.error('measureInterfaceSpeed:', error);
|
|
return sendError(
|
|
res,
|
|
500,
|
|
error?.message || 'Ошибка замера скорости по интерфейсу',
|
|
'E_IFACE_SPEED'
|
|
);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
getInterfaceStats,
|
|
measureInterfaceSpeed,
|
|
};
|