Files
MikrotikManager/backend/src/routes/probes.ts
T
Denozordec b9a75b6831 feat: implement internet path functionality with backend support
Added internet path settings and snapshot management to the application. This includes new database tables for internet path settings and snapshots, API routes for fetching and managing internet path data, and integration into the dashboard and data collection pages. Enhanced the scheduler to support internet path jobs, ensuring regular data collection and updates. Updated relevant types and interfaces to accommodate the new functionality.
2026-05-08 00:40:41 +07:00

429 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import dns from "node:dns/promises"
import { eq } from "drizzle-orm"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import type { RosIpRoute, RosPingResult } from "../types/server.js"
import { resolveRosSrcIpv4 } from "../utils/ros-src-address.js"
import { abortAfterMs, mergeAbortSignals } from "../utils/abort-signals.js"
/**
* Документация REST API RouterOS: если команда «бесконечна», сессия всё равно закрывается
* через ~60 с; параметры команды **не** продлевают этот лимит.
* @see https://help.mikrotik.com/docs/display/ROS/REST+API — раздел «Timeout»
*/
const ROS_REST_SESSION_MAX_MS = 58_000
/** Парсинг ввода пользователя (800ms, 1s, 00:00:01) → миллисекунды (10–3000). */
function parseTraceHopInputToMs(raw: unknown): number {
const s = String(raw ?? "").trim().toLowerCase()
if (!s) return 1000
const msM = /^(\d+)ms$/.exec(s)
if (msM) return Math.min(3000, Math.max(10, Number(msM[1])))
const secM = /^(\d+(?:\.\d+)?)s$/.exec(s)
if (secM) return Math.min(3000, Math.max(10, Number(secM[1]) * 1000))
const hm = /^(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/.exec(String(raw ?? "").trim())
if (hm) {
const h = Number(hm[1]), m = Number(hm[2]), sec = Number(hm[3])
const frac = hm[4] ? Number(hm[4].padEnd(3, "0").slice(0, 3)) : 0
const t = ((h * 60 + m) * 60 + sec) * 1000 + frac
return Math.min(3000, Math.max(10, t))
}
return 1000
}
/** В REST JSON для /tool/traceroute нужен формат времени HH:MM:SS (не «1s»). */
function formatMsAsRosTracerouteTimeout(ms: number): string {
const capped = Math.min(3000, Math.max(10, Math.round(ms)))
const totalSeconds = Math.floor(capped / 1000)
const milli = capped % 1000
const ss = totalSeconds % 60
const mmTotal = Math.floor(totalSeconds / 60)
const mm = mmTotal % 60
const hh = Math.floor(mmTotal / 60)
const pad = (n: number) => String(n).padStart(2, "0")
if (milli === 0) return `${pad(hh)}:${pad(mm)}:${pad(ss)}`
return `${pad(hh)}:${pad(mm)}:${pad(ss)}.${String(milli).padStart(3, "0")}`
}
function traceHopTimeoutForApi(raw: unknown): string {
return formatMsAsRosTracerouteTimeout(parseTraceHopInputToMs(raw))
}
function parseServerId(raw: string): number | null {
const n = Number.parseInt(raw, 10)
return Number.isFinite(n) ? n : null
}
function ipv4ToUint(ip: string): number | null {
const p = ip.split(".").map((x) => Number.parseInt(x, 10))
if (p.length !== 4 || p.some((x) => !Number.isFinite(x) || x < 0 || x > 255)) return null
return (((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0)
}
function maskFromLen(len: number): number {
if (len <= 0) return 0
if (len >= 32) return 0xffffffff
return (~((1 << (32 - len)) - 1)) >>> 0
}
function parseDstRoute(dst: string): { net: number; maskBits: number } | null {
const t = dst.trim()
if (!t) return null
if (!t.includes("/")) {
const ip = ipv4ToUint(t)
return ip === null ? null : { net: ip, maskBits: 32 }
}
const [addr, mb] = t.split("/")
const ip = ipv4ToUint(addr.trim())
const maskBits = Number.parseInt((mb ?? "").trim(), 10)
if (ip === null || !Number.isFinite(maskBits) || maskBits < 0 || maskBits > 32) return null
const mask = maskFromLen(maskBits)
return { net: ip & mask, maskBits }
}
function ipMatchesRoute(destIp: string, dstAddressField: string): boolean {
const ip = ipv4ToUint(destIp.trim())
if (ip === null) return false
const cidr = parseDstRoute(dstAddressField)
if (!cidr) return false
const mask = maskFromLen(cidr.maskBits)
return (ip & mask) === (cidr.net & mask)
}
function fmtPingRouterOs(results: RosPingResult[], host: string): string {
const lines = [`PING ${host}`]
for (const r of results) {
if (!r.seq && !r.sent && r.status !== "timeout") continue
if (r.status === "timeout") {
lines.push(` seq=${r.seq ?? "?"} timeout`)
} else if (r.seq && r.time !== undefined) {
lines.push(` seq=${r.seq} ttl=${r.ttl ?? "?"} time=${r.time}`)
}
}
const sum = [...results].reverse().find((r) => r.sent)
if (sum) {
lines.push(` sent=${sum.sent} received=${sum.received ?? "?"} packet-loss=${sum["packet-loss"] ?? "?"}`)
if (sum.time && sum.sent) lines.push(` avg-rtt=${sum.time}`)
}
return lines.join("\n")
}
function fmtTraceroute(rows: unknown): string {
if (!Array.isArray(rows)) return typeof rows === "string" ? rows : JSON.stringify(rows, null, 2)
const hdr = " # ADDRESS LOSS LAST AVG"
const lines = [hdr]
rows.forEach((row, i) => {
const r = row as Record<string, string | undefined>
const addr = String(r.address ?? r.host ?? r["from-address"] ?? "?")
const loss = String(r.loss ?? r["packet-loss"] ?? "—")
const last = String(r["last"] ?? r.time ?? "—")
const avg = String(r.avg ?? r["avg-rtt"] ?? "—")
lines.push(
` ${String(i + 1).padStart(2)} ${addr.padEnd(40)} ${loss.padEnd(6)} ${last.padStart(8)} ${avg.padStart(8)}`,
)
})
return lines.join("\n")
}
function fmtBandwidth(rows: Array<Record<string, string>>): string {
if (rows.length === 0) return "(no bandwidth-test output)"
const lines = rows.map((r) => {
const tx = r["tx-current"] ?? r["tx-total-average"] ?? ""
const rx = r["rx-current"] ?? r["rx-total-average"] ?? ""
const sec = r["test-duration"] ?? ""
const parts = [`tx=${tx}`, `rx=${rx}`]
if (sec) parts.push(`t=${sec}`)
return ` ${parts.join(" ")}`
})
return ["bandwidth-test:", ...lines].join("\n")
}
function fmtRouteLookup(destIp: string, routes: RosIpRoute[]): string {
const isTrue = (v: unknown) => {
const s = String(v ?? "").trim().toLowerCase()
return s === "true" || s === "yes"
}
const matches = routes.filter((r) => {
const dst = String(r["dst-address"] ?? "").trim()
if (!dst) return false
// Критично: учитывать только реально ACTIVE маршруты из /ip/route.
if (!isTrue(r.active)) return false
const rt = String((r as unknown as Record<string, unknown>)["routing-table"] ?? "").trim().toLowerCase()
if (!(rt === "" || rt === "main")) return false
if (String((r as unknown as Record<string, unknown>).disabled ?? "false").toLowerCase() === "true") return false
if (String((r as unknown as Record<string, unknown>).inactive ?? "false").toLowerCase() === "true") return false
return ipMatchesRoute(destIp, dst)
})
if (matches.length === 0) {
return `no route for ${destIp} (active routes checked: ${routes.length})`
}
matches.sort((a, b) => {
const da = parseDstRoute(String(a["dst-address"] ?? ""))
const db = parseDstRoute(String(b["dst-address"] ?? ""))
const maskCmp = (db?.maskBits ?? 0) - (da?.maskBits ?? 0)
if (maskCmp !== 0) return maskCmp
const distA = Number(a.distance ?? 255)
const distB = Number(b.distance ?? 255)
if (distA !== distB) return distA - distB
const aHasGw = String(a.gateway ?? "").trim().length > 0 ? 0 : 1
const bHasGw = String(b.gateway ?? "").trim().length > 0 ? 0 : 1
return aHasGw - bHasGw
})
const best = matches[0]!
const lines = [
`lookup ${destIp} → best match:`,
` dst-address: ${best["dst-address"] ?? "—"}`,
` gateway: ${best.gateway ?? best.interface ?? "—"}`,
` distance: ${best.distance ?? "—"}`,
` routing-mark: ${best["routing-mark"] ?? "main"}`,
]
return lines.join("\n")
}
async function dnsLookupText(name: string, type: string): Promise<string> {
const t = type.toUpperCase()
const lines: string[] = [`;; QUESTION: ${name} ${t}`, ""]
try {
if (t === "A") {
const addrs = await dns.resolve4(name)
addrs.forEach((a) => lines.push(`${name}. IN A ${a}`))
} else if (t === "AAAA") {
const addrs = await dns.resolve6(name)
addrs.forEach((a) => lines.push(`${name}. IN AAAA ${a}`))
} else if (t === "MX") {
const mx = await dns.resolveMx(name)
mx.sort((a, b) => a.priority - b.priority)
mx.forEach((m) => lines.push(`${name}. IN MX ${m.priority} ${m.exchange}`))
} else if (t === "NS") {
const ns = await dns.resolveNs(name)
ns.forEach((n) => lines.push(`${name}. IN NS ${n}`))
} else if (t === "TXT") {
const tx = await dns.resolveTxt(name)
tx.forEach((chunks) => lines.push(`${name}. IN TXT "${chunks.join("")}"`))
} else if (t === "CNAME") {
const c = await dns.resolveCname(name)
lines.push(`${name}. IN CNAME ${c}`)
} else if (t === "PTR") {
const raw = name.trim()
if (/^[\d.]+$/.test(raw)) {
const hosts = await dns.reverse(raw)
hosts.forEach((h) => lines.push(`${raw}.in-addr.arpa. IN PTR ${h}`))
} else {
const ptr = await dns.resolvePtr(raw.includes(".arpa") ? raw : `${raw}.in-addr.arpa`)
ptr.forEach((p) => lines.push(`${raw}. IN PTR ${p}`))
}
} else {
lines.push(`;; тип «${t}» не поддержан в живом режиме (используйте A, AAAA, MX, NS, TXT, CNAME, PTR)`)
}
} catch (e) {
lines.push(`;; ERROR: ${e instanceof Error ? e.message : String(e)}`)
lines.push(";; (резолв выполняется на хосте бекенда, не на MikroTik)")
}
return lines.join("\n")
}
async function mtuDiscover(client: MikrotikClient, address: string, srcIpv4: string | null): Promise<string> {
const sizes = [1500, 1492, 1480, 1476, 1472, 1468, 1400, 1280, 1024, 576]
const lines: string[] = [`MTU discovery (do-not-fragment ping) → ${address}`, ""]
let mtuFound = 0
for (const size of sizes) {
try {
const body: Record<string, string> = {
address,
count: "1",
size: String(size),
interval: "0.2s",
/** REST /tool/ping: как в CLI — `do-not-fragment=yes`, не `dont-fragment` */
"do-not-fragment": "true",
}
if (srcIpv4) body["src-address"] = srcIpv4
const rows = await client.post<RosPingResult[]>("/tool/ping", body, 25_000)
const timeout = rows.some((r) => r.status === "timeout")
if (!timeout) {
lines.push(` ${String(size).padStart(4)} ✓ ok`)
mtuFound = size
break
}
lines.push(` ${String(size).padStart(4)} ✗ fragment needed / timeout`)
} catch (e) {
lines.push(` ${String(size).padStart(4)}${e instanceof Error ? e.message : String(e)}`)
}
}
lines.push("")
lines.push(mtuFound > 0 ? `MTU (DF): ~${mtuFound} bytes` : "MTU: could not determine")
return lines.join("\n")
}
function resolveBtestPeer(remoteHost: string, explicitDstId?: number) {
if (explicitDstId != null && Number.isFinite(explicitDstId)) {
const s = db.select().from(servers).where(eq(servers.id, explicitDstId)).limit(1).all()[0]
if (s) return s
}
const norm = remoteHost.trim().toLowerCase()
return db.select().from(servers).where(eq(servers.enabled, true)).all()
.find((x) => x.host.trim().toLowerCase() === norm)
}
const probesRoutes: FastifyPluginAsyncZod = async (app) => {
app.post("/servers/:id/probes/run", async (req, reply) => {
const sid = parseServerId(String((req.params as { id?: string }).id ?? ""))
if (sid === null) return reply.status(400).send({ error: "Invalid server id" })
const server = db.select().from(servers).where(eq(servers.id, sid)).limit(1).all()[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const body = req.body as {
tool?: string
target?: string
pingCount?: number
pingSize?: number
pingTtl?: number
traceProto?: string
traceMaxHops?: number
/** Таймаут одной пробы: 800ms, 1s или 00:00:01 (в API уходит как HH:MM:SS) */
traceHopTimeout?: string
/** Число проб на хоп (1–3). Для REST рекомендуется 1 из‑за лимита сессии ~60 с */
traceProbeCount?: number
/** Резолвить адреса хопов в имена (RouterOS: use-dns yes|no → REST true|false) */
traceUseDns?: boolean
dnsType?: string
bwRemoteAddress?: string
dstServerId?: number
bwProto?: string
bwDuration?: number
}
const tool = String(body.tool ?? "").trim() as
| "ping"
| "traceroute"
| "bandwidth"
| "dns"
| "route"
| "mtu"
const client = MikrotikClient.fromServer(server)
const srcIpv4 = await resolveRosSrcIpv4(server.host)
try {
let output = ""
switch (tool) {
case "ping": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
const count = Math.min(100, Math.max(1, Number(body.pingCount) || 5))
const size = Math.min(8192, Math.max(28, Number(body.pingSize) || 64))
const ttl = Math.min(255, Math.max(1, Number(body.pingTtl) || 64))
const pingBody: Record<string, string> = {
address: target,
count: String(count),
size: String(size),
ttl: String(ttl),
interval: "0.2s",
}
if (srcIpv4) pingBody["src-address"] = srcIpv4
const rows = await client.post<RosPingResult[]>("/tool/ping", pingBody, 120_000)
output = fmtPingRouterOs(rows, target)
break
}
case "traceroute": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
const proto = body.traceProto === "udp" || body.traceProto === "tcp" ? body.traceProto : "icmp"
const maxHopsRequested = Math.min(64, Math.max(1, Number(body.traceMaxHops) || 30))
const probeCount = Math.min(3, Math.max(1, Number(body.traceProbeCount) || 1))
const hopMs = parseTraceHopInputToMs(body.traceHopTimeout)
const hopTimeoutRos = traceHopTimeoutForApi(body.traceHopTimeout)
const budgetMs = ROS_REST_SESSION_MAX_MS - 5000
const worstMsPerHop = probeCount * hopMs
const maxFeasibleHops = Math.max(1, Math.floor(budgetMs / worstMsPerHop))
const effectiveMaxHops = Math.min(maxHopsRequested, maxFeasibleHops)
const traceWallMs = ROS_REST_SESSION_MAX_MS
const useDns = Boolean(body.traceUseDns)
const traceBody: Record<string, string> = {
address: target,
protocol: proto,
"max-hops": String(effectiveMaxHops),
timeout: hopTimeoutRos,
count: String(probeCount),
"use-dns": useDns ? "true" : "false",
}
if (srcIpv4) traceBody["src-address"] = srcIpv4
const disconnectAbort = new AbortController()
const onClientClose = () => disconnectAbort.abort()
req.raw.once("close", onClientClose)
try {
const sig = mergeAbortSignals(disconnectAbort.signal, abortAfterMs(traceWallMs))
const rows = await client.post<unknown>("/tool/traceroute", traceBody, traceWallMs, sig)
const note =
effectiveMaxHops < maxHopsRequested
? `;; REST API RouterOS: сессия ~60 с (параметры traceroute не продлевают лимит). max-hops снижен с ${maxHopsRequested} до ${effectiveMaxHops} (count=${probeCount}, timeout=${hopTimeoutRos}).\n`
: ""
output = note + fmtTraceroute(rows)
} finally {
req.raw.off("close", onClientClose)
}
break
}
case "bandwidth": {
const remote = String(body.bwRemoteAddress ?? "").trim()
if (!remote) return reply.status(400).send({ error: "bwRemoteAddress required" })
const dst = resolveBtestPeer(remote, body.dstServerId)
if (!dst) {
return reply.status(400).send({
error: "Не найден сервер назначения для bandwidth-test: добавьте узел с host = GRE remote или укажите dstServerId",
})
}
const protocol = body.bwProto === "udp" ? "udp" : "tcp"
const durationSec = Math.max(3, Math.min(120, Number(body.bwDuration) || 10))
const rows = await client.bandwidthTest({
address: remote,
user: dst.username,
password: dst.password,
protocol,
direction: "both",
durationSec,
})
output = fmtBandwidth(rows)
break
}
case "dns": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
const dtype = String(body.dnsType ?? "A").trim() || "A"
output = await dnsLookupText(target, dtype)
break
}
case "route": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
const routes = await client.getIpRoutes()
output = fmtRouteLookup(target, routes)
break
}
case "mtu": {
const target = String(body.target ?? "").trim()
if (!target) return reply.status(400).send({ error: "target required" })
output = await mtuDiscover(client, target, srcIpv4)
break
}
default:
return reply.status(400).send({ error: `unknown tool: ${tool}` })
}
return reply.send({ output })
} catch (err) {
const msg =
err instanceof DOMException && err.name === "AbortError"
? "Запрос отменён (клиент закрыл соединение или истёк лимит времени для traceroute)."
: err instanceof Error
? err.message
: String(err)
return reply.send({ output: `error: ${msg}` })
}
})
}
export default probesRoutes