diff --git a/backend/routes/mikrotikConfigRoutes.js b/backend/routes/mikrotikConfigRoutes.js index 889b86b..096bbed 100644 --- a/backend/routes/mikrotikConfigRoutes.js +++ b/backend/routes/mikrotikConfigRoutes.js @@ -541,6 +541,111 @@ async function pingViaInterface(req, res) { } } +/** + * 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, + } = 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); + + const body = { + address: target, + }; + + if (gatewayIp) { + body.gateway = gatewayIp; + } + + const hopsNum = Number(maxHops); + if (Number.isFinite(hopsNum) && hopsNum > 0) { + body['max-hops'] = hopsNum; + } + + try { + const cliParts = [ + '/tool/traceroute', + `address=${body.address}`, + ]; + if (body.gateway) cliParts.push(`gateway=${body.gateway}`); + 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; + }; + + const hops = rows.map((r, index) => ({ + hop: r.hop != null ? Number(r.hop) : index + 1, + host: r.host || r.address || '', + avgMs: parseMs(r['avg-rtt'] || r.time || r.avg), + bestMs: parseMs(r['best-rtt'] || r['min-rtt']), + worstMs: parseMs(r['worst-rtt'] || r['max-rtt']), + loss: + r['packet-loss'] != null + ? Number(String(r['packet-loss']).replace('%', '')) + : null, + status: r.status || '', + raw: r, + })); + + 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'); + } +} + /** * POST /api/mikrotik/run-script * Body: { serverId, script?: string } — по умолчанию script=update_bgp_filter @@ -587,5 +692,6 @@ module.exports = { testMikrotikConnection, applyMikrotikConfig, runScript, + tracerouteViaGateway, pingViaInterface, }; diff --git a/backend/server.js b/backend/server.js index 0b70198..ec8ec1f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -456,6 +456,8 @@ app.post('/api/mikrotik/test-connection', mikrotikConfigRoutes.testMikrotikConne app.get('/api/mikrotik/ping', (req, res) => res.json({ ok: true, ts: Date.now() })); // Реальный ping через RouterOS по интерфейсу/шлюзу app.post('/api/mikrotik/ping', writeLimiter, mikrotikConfigRoutes.pingViaInterface); +// Traceroute через RouterOS с выбором сервера и шлюза +app.post('/api/mikrotik/traceroute', writeLimiter, mikrotikConfigRoutes.tracerouteViaGateway); app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig); app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 50ab372..24c2e0a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -32,6 +32,7 @@ import AutoUrlManager from './AutoUrlManager'; import BillingManager from './BillingManager'; import CommunitiesManager from './CommunitiesManager'; import NetworkConfigManager from './NetworkConfigManager'; +import MikrotikTools from './MikrotikTools.jsx'; import Dashboard from './Dashboard'; import MikrotikBackupsManager from './MikrotikBackupsManager.jsx'; import './App.css'; @@ -206,7 +207,8 @@ function MainLayout() { icon: IconSettings, items: [ { id: 'auto-urls', title: t('autoUrls'), path: '/auto-urls', icon: IconDownload }, - { id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase } + { id: 'mikrotik-backups', title: t('mikrotikBackups'), path: '/mikrotik-backups', icon: IconDatabase }, + { id: 'mikrotik-tools', title: 'MikroTik Инструменты', path: '/mikrotik-tools', icon: IconNetwork } ] }, // Убрали неиспользуемые/неработающие разделы @@ -407,6 +409,7 @@ function MainLayout() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/MikrotikTools.jsx b/frontend/src/MikrotikTools.jsx new file mode 100644 index 0000000..710f8d0 --- /dev/null +++ b/frontend/src/MikrotikTools.jsx @@ -0,0 +1,420 @@ +import { useEffect, useMemo, useState } from 'react'; +import api from './lib/api.js'; +import { useNotify } from './components/NotifyProvider.jsx'; +import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx'; +import GatewayAutocompleteInput from './components/GatewayAutocompleteInput.jsx'; +import { + IconRoute, + IconWorld, + IconAlertTriangle, + IconRefresh, +} from '@tabler/icons-react'; + +/** + * Раздел "Инструменты → MikroTik": проверка маршрута (traceroute) + * с выбором сервера, gateway и домена/IP. + */ +function MikrotikTools() { + const notify = useNotify(); + + const [servers, setServers] = useState([]); + const [networkConfig, setNetworkConfig] = useState(null); + const [loading, setLoading] = useState(true); + + const [serverId, setServerId] = useState(''); + const [gatewayRef, setGatewayRef] = useState(''); + const [gatewayMeta, setGatewayMeta] = useState(null); + const [target, setTarget] = useState(''); + const [maxHops, setMaxHops] = useState(30); + + const [running, setRunning] = useState(false); + const [hops, setHops] = useState([]); + const [analysis, setAnalysis] = useState([]); + + // Загрузка серверов и сетевого конфига + useEffect(() => { + const load = async () => { + setLoading(true); + try { + const [serversRes, netRes] = await Promise.all([ + api.get('/servers'), + api.get('/network-config'), + ]); + setServers(Array.isArray(serversRes.data) ? serversRes.data : []); + setNetworkConfig(netRes.data && typeof netRes.data === 'object' ? netRes.data : { gateways: [], tunnelInterfaces: [] }); + } catch (error) { + console.error('[MikrotikTools] failed to load initial data', error); + notify.error('Не удалось загрузить данные для инструментов MikroTik'); + } finally { + setLoading(false); + } + }; + load(); + }, [notify]); + + const gateways = useMemo( + () => (networkConfig?.gateways && Array.isArray(networkConfig.gateways) ? networkConfig.gateways : []), + [networkConfig] + ); + + const interfaces = useMemo( + () => (networkConfig?.tunnelInterfaces && Array.isArray(networkConfig.tunnelInterfaces) ? networkConfig.tunnelInterfaces : []), + [networkConfig] + ); + + const handleSelectGatewayMeta = (meta) => { + setGatewayMeta(meta); + // Если цель не задана — подставляем IP gateway как target + if (!target && meta && meta.ip) { + setTarget(meta.ip); + } + }; + + const handleRunTraceroute = async () => { + if (!serverId) { + notify.error('Выберите сервер (jumphost)'); + return; + } + if (!target || String(target).trim() === '') { + notify.error('Укажите домен или IP для трассировки'); + return; + } + + setRunning(true); + setHops([]); + setAnalysis([]); + + try { + const body = { + serverId, + target: String(target).trim(), + }; + // gatewayRef может быть как ID gateway, так и IP/строка — + // для простоты, если в meta есть ip — используем его как gatewayIp. + if (gatewayMeta?.ip) { + body.gatewayIp = gatewayMeta.ip; + } + // Max hops + const hopsNum = Number(maxHops); + if (Number.isFinite(hopsNum) && hopsNum > 0) { + body.maxHops = hopsNum; + } + + const res = await api.post('/mikrotik/traceroute', body); + const data = res?.data || {}; + if (!data.ok) { + notify.error(data.error || 'Трассировка завершилась с ошибкой'); + return; + } + + const hopsList = Array.isArray(data.hops) ? data.hops : []; + setHops(hopsList); + setAnalysis(buildTracerouteAnalysis(hopsList, networkConfig, servers, body.target)); + } catch (error) { + console.error('[MikrotikTools] traceroute failed', error); + notify.error(error?.message || 'Ошибка при выполнении трассировки'); + } finally { + setRunning(false); + } + }; + + const handleReset = () => { + setGatewayRef(''); + setGatewayMeta(null); + setTarget(''); + setHops([]); + setAnalysis([]); + }; + + return ( +
+
+
+
+

+ + Инструменты MikroTik: трассировка +

+
+ Проверка маршрута до домена/IP через выбранный jumphost и gateway с расшифровкой хопов. +
+
+
+
+ +
+
+
+
+

Параметры проверки

+
+
+ {loading ? ( +
Загрузка серверов и сетевых настроек…
+ ) : ( + <> +
+ + String(s.type).toLowerCase() === 'jumphost')} + placeholder="Начните вводить IP, DNS или провайдера…" + /> +
+ Выберите MikroTik‑jumphost, через который будет выполняться трассировка. +
+
+ +
+ + +
+ Можно привязать трассировку к конкретному прямому gateway или туннельному интерфейсу. +
+
+ +
+ +
+ + + + setTarget(e.target.value)} + /> + {gatewayMeta?.ip && ( + + )} +
+
+ Можно трассировать как до внешнего домена, так и до IP gateway/туннеля. +
+
+ +
+ + setMaxHops(e.target.value)} + /> +
+ + )} +
+
+ + +
+
+
+ +
+
+
+

Результаты трассировки

+
+
+ {hops.length === 0 ? ( +
+ Результаты трассировки появятся здесь после запуска проверки. +
+ ) : ( +
+ + + + + + + + + + + + + {hops.map((h) => ( + + + + + + + + + ))} + +
ХопАдрес / узелСреднее, мсМин / Макс, мсПотериСтатус
{h.hop} + {h.host || '—'} + {h._label && ( +
{h._label}
+ )} +
{h.avgMs != null ? h.avgMs.toFixed(1) : '—'} + {h.bestMs != null ? h.bestMs.toFixed(1) : '—'} /{' '} + {h.worstMs != null ? h.worstMs.toFixed(1) : '—'} + {h.loss != null ? `${h.loss}%` : '—'}{h.status || '—'}
+
+ )} +
+
+ +
+
+

+ + Расшифровка трассировки +

+
+
+ {analysis.length === 0 ? ( +
+ После выполнения трассировки здесь появится человекочитаемое описание пути и возможных проблем. +
+ ) : ( +
    + {analysis.map((line, idx) => ( +
  • + {line} +
  • + ))} +
+ )} +
+
+
+
+
+ ); +} + +function buildTracerouteAnalysis(hops, networkConfig, servers, target) { + if (!Array.isArray(hops) || hops.length === 0) return []; + const lines = []; + const gateways = (networkConfig?.gateways && Array.isArray(networkConfig.gateways)) ? networkConfig.gateways : []; + const ifaces = (networkConfig?.tunnelInterfaces && Array.isArray(networkConfig.tunnelInterfaces)) ? networkConfig.tunnelInterfaces : []; + + const findServerLabel = (serverId) => { + if (!serverId) return null; + const s = (servers || []).find((srv) => srv.id === serverId || srv.ip === serverId || srv.dns === serverId); + if (!s) return serverId; + return s.dns || s.ip || serverId; + }; + + const targetStr = String(target || '').trim(); + lines.push(`Цель трассировки: ${targetStr || 'не указана'}. Хопов в маршруте: ${hops.length}.`); + + let firstTimeoutHop = null; + let worstLatencyHop = null; + + hops.forEach((hop, index) => { + const host = hop.host || hop.address || ''; + const avg = hop.avgMs; + const loss = hop.loss; + + let descr = `Хоп ${hop.hop || index + 1}: ${host || 'неизвестный узел'}`; + const details = []; + + if (avg != null) details.push(`среднее время ≈ ${avg.toFixed(1)} мс`); + if (hop.bestMs != null && hop.worstMs != null) { + details.push(`мин/макс ${hop.bestMs.toFixed(1)}/${hop.worstMs.toFixed(1)} мс`); + } + if (loss != null) details.push(`потеря пакетов ${loss}%`); + + // Привязка к gateway + const gw = gateways.find((g) => g.ip && g.ip === host); + if (gw) { + const srvLabel = findServerLabel(gw.serverId); + details.push(`gateway "${gw.description || gw.ip}" на сервере ${srvLabel || gw.serverId}`); + } + + // Привязка к туннельному интерфейсу + const iface = ifaces.find((i) => i.localIp === host || i.remoteIp === host); + if (iface) { + const s1 = findServerLabel(iface.serverId); + const s2 = findServerLabel(iface.serverId2); + details.push( + `туннель ${iface.type || ''} ${iface.name || ''} (${iface.localIp} ⇄ ${iface.remoteIp})` + + (s1 || s2 ? ` между ${s1 || 'сервером 1'} и ${s2 || 'сервером 2'}` : '') + ); + } + + // Статус/таймауты + if (hop.status) { + const st = String(hop.status).toLowerCase(); + if (st.includes('timeout') || st.includes('no reply') || st.includes('unreachable')) { + details.push('узел не отвечает на ICMP (возможная фильтрация или недоступность)'); + if (!firstTimeoutHop) firstTimeoutHop = hop; + } + } + + if (details.length > 0) { + descr += ` — ${details.join('; ')}`; + } + + lines.push(descr); + + if (avg != null) { + if (!worstLatencyHop || avg > (worstLatencyHop.avgMs || 0)) { + worstLatencyHop = hop; + } + } + }); + + if (worstLatencyHop && worstLatencyHop.avgMs != null && worstLatencyHop.avgMs > 150) { + lines.push( + `Максимальная средняя задержка на хопе ${worstLatencyHop.hop}: около ${worstLatencyHop.avgMs.toFixed( + 1 + )} мс — возможен "узкое место" или удалённый сегмент сети.` + ); + } + + if (firstTimeoutHop) { + lines.push( + `Начиная с хопа ${firstTimeoutHop.hop} (${firstTimeoutHop.host || 'неизвестный узел'}) наблюдаются таймауты — цель может быть недоступна или где-то по пути фильтруется ICMP.` + ); + } else { + lines.push('Таймаутов или явной потери пакетов по пути не обнаружено (по данным MikroTik).'); + } + + return lines; +} + +export default MikrotikTools; +