118 lines
15 KiB
TypeScript
118 lines
15 KiB
TypeScript
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 } }
|
||
}
|