Files
MikrotikManager/backend/src/services/server-probe-execution-model.ts
T

118 lines
15 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 { isIP, isIPv4 } from "node:net"
import { z } from "zod"
const integer = z.number().int().positive().safe(), text = z.string().trim().min(1).max(1024)
const traceTime = z.string().trim().min(1).max(32).refine(value => {
const unit = /^(\d+(?:\.\d+)?)s$|^(\d+)ms$/.exec(value)
if (unit) return Number.isFinite(Number(unit[1] ?? unit[2])) && Number(unit[1] ?? unit[2]) > 0
const clock = /^(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/.exec(value)
return !!clock && Number(clock[2]) < 60 && Number(clock[3]) < 60 && [clock[1], clock[2], clock[3], clock[4]].some(part => Number(part) > 0)
})
/** Complete documented POST /servers/:id/probes/run inputs. Unknown fields are
* rejected in this typed foundation; existing REST is not changed by import. */
export const serverProbeExecutionRequest = z.object({
tool: z.string().trim().pipe(z.enum(["ping", "traceroute", "bandwidth", "dns", "route", "mtu"])),
target: text.optional(), pingCount: integer.optional(), pingSize: integer.optional(), pingTtl: integer.optional(),
traceProto: z.enum(["icmp", "udp", "tcp"]).optional(), traceMaxHops: integer.optional(), traceHopTimeout: traceTime.optional(), traceProbeCount: integer.optional(), traceUseDns: z.boolean().optional(),
dnsType: z.string().trim().toUpperCase().pipe(z.enum(["A", "AAAA", "MX", "NS", "TXT", "CNAME", "PTR"])).optional(),
bwRemoteAddress: text.optional(), dstServerId: integer.optional(), bwProto: z.enum(["tcp", "udp"]).optional(), bwDuration: integer.optional(),
}).strict().superRefine((value, context) => {
if (value.tool === "bandwidth" ? !value.bwRemoteAddress : !value.target) context.addIssue({ code: "custom", path: [value.tool === "bandwidth" ? "bwRemoteAddress" : "target"], message: "Обязательная цель диагностики" })
if (value.tool === "route" && value.target && !isIPv4(value.target)) context.addIssue({ code: "custom", path: ["target"], message: "Поиск маршрута требует IPv4" })
})
export type ServerProbeRequest = z.infer<typeof serverProbeExecutionRequest>
export type ServerProbeTool = ServerProbeRequest["tool"]
export interface ServerProbePlan { request: ServerProbeRequest; nativeBody?: Record<string, string>; wireTimeoutMs: number; wallBudgetMs: number; trace?: { requestedHops: number; effectiveHops: number; count: number; timeout: string }; mtuSizes?: number[] }
export function planServerProbeExecution(raw: unknown): ServerProbePlan {
const request = serverProbeExecutionRequest.parse(raw)
const clamp = (value: number | undefined, fallback: number, min: number, max: number) => Math.min(max, Math.max(min, value ?? fallback))
if (request.tool === "ping") return { request, nativeBody: { address: request.target!, count: String(clamp(request.pingCount, 5, 1, 100)), size: String(clamp(request.pingSize, 64, 28, 8192)), ttl: String(clamp(request.pingTtl, 64, 1, 255)), interval: "0.2s" }, wireTimeoutMs: 120000, wallBudgetMs: 130000 }
if (request.tool === "traceroute") {
const raw = request.traceHopTimeout ?? "1s", unit = /^(\d+(?:\.\d+)?)s$|^(\d+)ms$/.exec(raw), clock = /^(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/.exec(raw)
const milliseconds = Math.min(3000, Math.max(10, unit ? Number(unit[1] ?? unit[2]) * (unit[1] !== undefined ? 1000 : 1) : clock ? ((Number(clock[1]) * 3600 + Number(clock[2]) * 60 + Number(clock[3])) * 1000 + Number((clock[4] ?? "").padEnd(3, "0"))) : 1000))
const rounded = Math.round(milliseconds), timeout = `00:00:${String(Math.floor(rounded / 1000)).padStart(2, "0")}${rounded % 1000 ? "." + String(rounded % 1000).padStart(3, "0") : ""}`
const count = clamp(request.traceProbeCount, 1, 1, 3), requestedHops = clamp(request.traceMaxHops, 30, 1, 64), effectiveHops = Math.min(requestedHops, Math.max(1, Math.floor(53000 / (count * milliseconds))))
return { request, nativeBody: { address: request.target!, protocol: request.traceProto ?? "icmp", "max-hops": String(effectiveHops), timeout, count: String(count), "use-dns": request.traceUseDns ? "true" : "false" }, wireTimeoutMs: 58000, wallBudgetMs: 68000, trace: { requestedHops, effectiveHops, count, timeout } }
}
if (request.tool === "bandwidth") return { request, nativeBody: { address: request.bwRemoteAddress!, protocol: request.bwProto ?? "tcp", direction: "both", duration: `${clamp(request.bwDuration, 10, 3, 120)}s` }, wireTimeoutMs: 30000, wallBudgetMs: 30000 }
if (request.tool === "mtu") return { request, wireTimeoutMs: 25000, wallBudgetMs: 260000, mtuSizes: [1500, 1492, 1480, 1476, 1472, 1468, 1400, 1280, 1024, 576] }
return { request, wireTimeoutMs: request.tool === "dns" ? 30000 : 10000, wallBudgetMs: request.tool === "dns" ? 30000 : 10000 }
}
export class ServerProbeDataError extends Error { constructor() { super("Устройство не подтвердило структурированные измерения диагностики") } }
const bad = (): never => { throw new ServerProbeDataError() }
export function serverProbeRows(raw: unknown, ceiling: number): Record<string, unknown>[] {
if (!Array.isArray(raw) || raw.length >= ceiling || Buffer.byteLength(JSON.stringify(raw)) > 1048576 || raw.some(row => !row || typeof row !== "object" || Array.isArray(row) || Object.getPrototypeOf(row) !== Object.prototype)) return bad()
return raw as Record<string, unknown>[]
}
function scalar(value: unknown, maximum = 1024): string | undefined {
if (value === undefined || value === null || value === "") return undefined
if (typeof value === "string" && value.length <= maximum && !/[\x00-\x08\x0b-\x1f\x7f]/.test(value)) return value
if (typeof value === "number" && Number.isFinite(value)) return String(value)
return bad()
}
const metric = (value: unknown) => scalar(value, 128)?.trim() || "—"
const number = (value: unknown): number | undefined => { if (value === undefined) return undefined; const text = scalar(value, 32); if (!text || !/^\d+$/.test(text) || !Number.isSafeInteger(Number(text))) return bad(); return Number(text) }
export interface ServerProbeView { output: string; data: Record<string, unknown> }
export function projectServerProbePing(raw: unknown, target: string): ServerProbeView {
const rows = serverProbeRows(raw, 202).map(row => ({ ...(row.seq !== undefined ? { seq: number(row.seq) } : {}), ...(row.ttl !== undefined ? { ttl: number(row.ttl) } : {}), ...(row.time !== undefined ? { time: scalar(row.time, 64)?.trim() || undefined } : {}), ...(row.status !== undefined ? { status: scalar(row.status, 128) ?? "" } : {}), ...(row.sent !== undefined ? { sent: number(row.sent), received: number(row.received), packetLoss: scalar(row["packet-loss"], 64)?.trim() || undefined } : {}) }))
const lines = ["PING " + target]
for (const row of rows) if (row.seq !== undefined || row.status !== undefined) lines.push(row.status === "timeout" ? ` seq=${row.seq ?? "?"} timeout` : ` seq=${row.seq ?? "?"} ttl=${row.ttl ?? "?"} time=${row.time ?? "—"}${row.status ? ` status=${row.status}` : ""}`)
const summary = [...rows].reverse().find(row => row.sent !== undefined)
if (summary) lines.push(` sent=${summary.sent ?? "?"} received=${summary.received ?? "?"} packet-loss=${summary.packetLoss ?? "—"}`)
if (!rows.length) lines.push(" API не вернул измерений ping.")
return { output: lines.join("\n"), data: { kind: "ping", rows } }
}
export function projectServerProbeTrace(raw: unknown, plan: ServerProbePlan): ServerProbeView {
const rows = serverProbeRows(raw, 258).map(row => ({ address: metric(row.address ?? row.host ?? row["from-address"]), loss: metric(row.loss ?? row["packet-loss"]), last: metric(row.last ?? row.time), avg: metric(row.avg ?? row["avg-rtt"]) }))
const trace = plan.trace!, note = trace.effectiveHops < trace.requestedHops ? `;; REST API RouterOS: сессия ~60 с (параметры traceroute не продлевают лимит). max-hops снижен с ${trace.requestedHops} до ${trace.effectiveHops} (count=${trace.count}, timeout=${trace.timeout}).\n` : ""
return { output: note + [" # ADDRESS LOSS LAST AVG", ...rows.map((row, index) => ` ${String(index + 1).padStart(2)} ${row.address.padEnd(40)} ${row.loss.padEnd(6)} ${row.last.padStart(8)} ${row.avg.padStart(8)}`)].join("\n"), data: { kind: "traceroute", rows, ...trace } }
}
export function projectServerProbeBandwidth(raw: unknown): ServerProbeView {
const rows = serverProbeRows(raw, 5002).map(row => ({ tx: metric(row["tx-current"] ?? row["tx-total-average"]), rx: metric(row["rx-current"] ?? row["rx-total-average"]), duration: scalar(row["test-duration"], 128) ?? "" }))
return { output: rows.length ? ["bandwidth-test:", ...rows.map(row => ` tx=${row.tx} rx=${row.rx}${row.duration ? ` t=${row.duration}` : ""}`)].join("\n") : "(no bandwidth-test output)", data: { kind: "bandwidth", rows } }
}
export function classifyServerProbeMtu(raw: unknown): "reply" | "timeout" {
const rows = serverProbeRows(raw, 8)
if (rows.some(row => /^\d+$/.test(String(row.received ?? "")) && Number(row.received) === 1) || rows.some(row => /^[01]$/.test(String(row.seq ?? "")) && !row.status && /^(?:\d+(?:\.\d+)?(?:us|ms|s)|\d{1,2}:\d{2}:\d{2}(?:\.\d+)?)$/.test(String(row.time ?? "")))) return "reply"
if (rows.some(row => row.status === "timeout")) return "timeout"
return bad()
}
/** Only already classified, ordered measurements may be rendered. Unknown
* native results must fail classification rather than inventing an MTU. */
export function projectServerProbeMtu(measurements: readonly { size: number; result: "reply" | "timeout" }[], target: string): ServerProbeView {
const sizes = [1500, 1492, 1480, 1476, 1472, 1468, 1400, 1280, 1024, 576]
if (!measurements.length || measurements.length > sizes.length || measurements.some((row, index) => row.size !== sizes[index] || !["reply", "timeout"].includes(row.result) || row.result === "reply" && index !== measurements.length - 1) || measurements.at(-1)!.result !== "reply" && measurements.length !== sizes.length) return bad()
const found = measurements.at(-1)!.result === "reply" ? measurements.at(-1)!.size : null
return { output: [`MTU discovery (do-not-fragment ping) → ${target}`, "", ...measurements.map(row => row.result === "reply" ? ` ${String(row.size).padStart(4)} ✓ ok` : `${String(row.size).padStart(4)} timeout; причина не определена`), "", found !== null ? `MTU (DF): ~${found} bytes` : "MTU: could not determine"].join("\n"), data: { kind: "mtu", mtu: found, measurements: measurements.map(row => ({ ...row })) } }
}
function cidr(raw: unknown): { net: number; bits: number } | undefined {
if (typeof raw !== "string") return undefined
const parts = raw.trim().split("/").map(part => part.trim()), bits = parts.length === 1 ? 32 : /^(?:[0-9]|[12][0-9]|3[0-2])$/.test(parts[1] ?? "") ? Number(parts[1]) : -1
if (parts.length > 2 || !isIPv4(parts[0]) || bits < 0) return undefined
const value = parts[0].split(".").reduce((n, octet) => ((n << 8) | Number(octet)) >>> 0, 0), mask = bits ? (0xffffffff << (32 - bits)) >>> 0 : 0
return { net: value & mask, bits }
}
export function projectServerProbeRoute(raw: unknown, target: string): ServerProbeView {
const rows = serverProbeRows(raw, 10002), destination = cidr(target)!, unknown = rows.filter(row => !["true", "yes", "false", "no"].includes(String(row.active ?? "").toLowerCase()) || !cidr(row["dst-address"])).length
const matches = rows.filter(row => { const parsed = cidr(row["dst-address"]), mask = parsed?.bits ? (0xffffffff << (32 - parsed.bits)) >>> 0 : 0; return parsed && ["true", "yes"].includes(String(row.active).trim().toLowerCase()) && ["", "main"].includes(String(row["routing-table"] ?? "").trim().toLowerCase()) && String(row.disabled ?? "false").toLowerCase() !== "true" && String(row.inactive ?? "false").toLowerCase() !== "true" && (destination.net & mask) === parsed.net })
const safeDistance = (value: unknown) => { try { return number(value) ?? Infinity } catch { return Infinity } }
matches.sort((a, b) => cidr(b["dst-address"])!.bits - cidr(a["dst-address"])!.bits || safeDistance(a.distance) - safeDistance(b.distance) || Number(!String(a.gateway ?? "").trim()) - Number(!String(b.gateway ?? "").trim()))
if (!matches.length) return { output: unknown ? `Маршрут не подтверждён: неизвестных записей ${unknown}.` : `no route for ${target} (route records checked: ${rows.length})`, data: { kind: "route", confirmed: false, checked: rows.length, unknown } }
const display = (value: unknown) => value === "" ? "" : scalar(value) ?? "—"
const row = matches[0], best = { destination: display(row["dst-address"]), gateway: display(row.gateway ?? row.interface), distance: safeDistance(row.distance) === Infinity ? null : safeDistance(row.distance), routingMark: display(row["routing-mark"]) }
return { output: [`lookup ${target} → ${unknown ? `best confirmed match (неизвестных записей: ${unknown}):` : "best match:"}`, ` dst-address: ${best.destination}`, ` gateway: ${best.gateway}`, ` distance: ${best.distance ?? "—"}`, ` routing-mark: ${best.routingMark}`].join("\n"), data: { kind: "route", confirmed: true, checked: rows.length, unknown, best } }
}
export function projectServerProbeDns(raw: unknown, name: string, type: string): ServerProbeView {
if (!["A", "AAAA", "MX", "NS", "TXT", "CNAME", "PTR"].includes(type)) return bad()
const records: Array<string | { priority: number; exchange: string }> = []
if (!Array.isArray(raw) || raw.length >= 1002 || Buffer.byteLength(JSON.stringify(raw)) > 262144) return bad()
for (const item of raw) {
if (type === "MX") { const value = item as { priority?: unknown; exchange?: unknown }; const priority = number(value?.priority), exchange = scalar(value?.exchange, 253); if (priority === undefined || !exchange) return bad(); records.push({ priority, exchange }) }
else if (type === "TXT") { if (!Array.isArray(item) || item.length > 100 || item.some(chunk => typeof chunk !== "string")) return bad(); records.push(item.map(chunk => scalar(chunk, 8192) ?? "").join("")) }
else { if (typeof item !== "string") return bad(); const value = scalar(item, 1024); if (!value || type === "A" && isIP(value) !== 4 || type === "AAAA" && isIP(value) !== 6) return bad(); records.push(value) }
}
if (type === "MX") records.sort((a, b) => (a as { priority: number }).priority - (b as { priority: number }).priority)
const lines = type === "CNAME" ? [`${name}. IN CNAME ${records.join(",")}`] : records.map(record => typeof record === "object" ? `${name}. IN MX ${record.priority} ${record.exchange}` : type === "TXT" ? `${name}. IN TXT "${record}"` : `${name}${type === "PTR" && /^[\d.]+$/.test(name) ? ".in-addr.arpa" : ""}. IN ${type} ${record}`)
return { output: [`;; QUESTION: ${name} ${type}`, "", ...lines].join("\n"), data: { kind: "dns", resolver: "manager", type, records } }
}