diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js index a4c08a8..e624535 100644 --- a/backend/routes/mikrotikConfigRoutes.js +++ b/backend/routes/mikrotikConfigRoutes.js @@ -21,6 +21,7 @@ const IPSEC_PASSWORDS_KEY = 'network-config/ipsec-passwords.json'; const NETWORK_CONFIG_KEY = 'network-config.json'; const UI_SETTINGS_KEY = 'bgp_data/rt_ui_settings.json'; const PING_CACHE_PREFIX = 'ping-cache/'; +const SPEEDTEST_CACHE_PREFIX = 'speed-test-cache/'; /** Загрузить UI-настройки из S3 (для pingDomain, pingCacheMinutes и др.) */ async function loadUiSettings() { @@ -39,6 +40,16 @@ function pingCacheKey(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 { @@ -791,6 +802,284 @@ async function tracerouteViaGateway(req, res) { } } +function parseSpeedToBps(val) { + if (val == null) return null; + const s = String(val).trim(); + const m = s.match(/([\d.]+)\s*([kKmMgG])?[bB]ps/); + if (!m) return null; + const num = Number(m[1]); + if (!Number.isFinite(num)) return null; + const unit = (m[2] || '').toLowerCase(); + let mult = 1; + if (unit === 'k') mult = 1e3; + else if (unit === 'm') mult = 1e6; + else if (unit === 'g') mult = 1e9; + return num * mult; +} + +/** + * POST /api/mikrotik/speed-test + * Замер TCP download/upload между двумя MikroTik по туннельному интерфейсу. + * + * Body: + * - serverId (обязательный) — локальный сервер (jumphost/home) + * - interfaceName (обязательный) — имя туннельного интерфейса (как в /network-config.tunnelInterfaces и RouterOS) + * - durationSeconds? — время теста для каждого этапа (по умолчанию из ui-settings.interfaceSpeedTestDurationSeconds или 10 сек) + * + * Локальный роутер выполняет /tool/speed-test до IP удалённого конца туннеля (localIp/remoteIp), + * используя учетные данные удалённого MikroTik из servers.json. Берём tcp-download и tcp-upload. + */ +async function speedTestViaTunnel(req, res) { + try { + const { serverId, interfaceName } = 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 + ? speedTestCacheKey(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 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 = getMikrotikCredentials(remoteServer); + + if (!localCreds) { + return sendError( + res, + 400, + 'MikroTik credentials not configured for local server', + 'E_LOCAL_CREDENTIALS' + ); + } + if (!remoteCreds) { + return sendError( + res, + 400, + 'MikroTik credentials not configured for remote server', + '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 || '', + protocol: 'tcp', + direction: 'both', + 'test-duration': `${durationSeconds}s`, + }; + + try { + const cliParts = [ + '/tool/speed-test', + `address=${body.address}`, + `user=${body.user}`, + 'protocol=tcp', + 'direction=both', + `test-duration=${durationSeconds}s`, + ]; + 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 @@ -840,4 +1129,5 @@ module.exports = { tracerouteViaGateway, pingViaInterface, runPingViaRouter, + speedTestViaTunnel, }; diff --git a/backend/server.js b/backend/server.js index 6f05bda..6d9932a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -461,6 +461,8 @@ app.get('/api/mikrotik/ping', (req, res) => res.json({ ok: true, ts: Date.now() app.post('/api/mikrotik/ping', writeLimiter, mikrotikConfigRoutes.pingViaInterface); // Traceroute через RouterOS с выбором сервера и шлюза app.post('/api/mikrotik/traceroute', writeLimiter, mikrotikConfigRoutes.tracerouteViaGateway); +// Speed-test (TCP upload/download) между MikroTik по туннельному интерфейсу +app.post('/api/mikrotik/speed-test', writeLimiter, mikrotikConfigRoutes.speedTestViaTunnel); app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig); app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript); diff --git a/frontend/src/InterfaceSpeedTest.jsx b/frontend/src/InterfaceSpeedTest.jsx index 8983d78..398de50 100644 --- a/frontend/src/InterfaceSpeedTest.jsx +++ b/frontend/src/InterfaceSpeedTest.jsx @@ -186,7 +186,7 @@ export default function InterfaceSpeedTest() { interfaceName: selectedInterfaceName, durationSeconds: speedSettings.durationSeconds, }; - const res = await api.post('/traffic/interface-speed-test', body); + const res = await api.post('/mikrotik/speed-test', body); const data = res?.data || {}; if (data.ok === false) { notify.error( @@ -197,7 +197,9 @@ export default function InterfaceSpeedTest() { setSpeedResult(data); } catch (e) { console.error('[InterfaceSpeedTest] test failed', e); - notify.error(e?.response?.data?.message || e?.message || 'Ошибка замера скорости'); + notify.error( + e?.response?.data?.message || e?.message || 'Ошибка замера скорости' + ); } finally { setRunning(false); } @@ -446,14 +448,17 @@ export default function InterfaceSpeedTest() {