diff --git a/backend/src/routes/probes.test.ts b/backend/src/routes/probes.test.ts index 9bbf1850..bef8f82e 100644 --- a/backend/src/routes/probes.test.ts +++ b/backend/src/routes/probes.test.ts @@ -6,11 +6,12 @@ import type {servers} from "../db/schema.js" const server={id:1,name:"Тест",host:"192.0.2.1",enabled:true,username:"test",password:"test"} as typeof servers.$inferSelect let exists=true,dbFailure=false,upstreamFailure=false,dnsOutput=";; QUESTION: example.test A\nexample.test IN A 192.0.2.2",resolves=0,lookups=0,peerEnabled=true let postOutput:unknown=[{seq:"1",received:"1",time:"1ms",address:"192.0.2.2"}] +let routeOutput:unknown=[],bwOutput:unknown=[{"tx-current":"0","rx-current":"42"}] const calls:unknown[][]=[] const client={ post:async(...args:unknown[])=>{calls.push(args);if(upstreamFailure)throw new Error("RouterOS недоступен");return postOutput}, - getIpRoutes:async()=>{if(upstreamFailure)throw new Error("Routes недоступны");return []}, - bandwidthTest:async(...args:unknown[])=>{calls.push(["bw",...args]);if(upstreamFailure)throw new Error("BW недоступен");return [{"tx-current":"0","rx-current":"42"}]}, + getIpRoutes:async()=>{if(upstreamFailure)throw new Error("Routes недоступны");return routeOutput}, + bandwidthTest:async(...args:unknown[])=>{calls.push(["bw",...args]);if(upstreamFailure)throw new Error("BW недоступен");return bwOutput}, } as ReturnType> const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler) await app.register(createProbeRoutes({getServer:async()=>{lookups++;if(dbFailure)throw new Error("DB unavailable");return exists?server:undefined},createClient:()=>client,resolveSource:async()=>{resolves++;return "192.0.2.1"},lookupDns:async()=>dnsOutput,findPeer:async()=>({...server,enabled:peerEnabled})}),{prefix:"/api"}) @@ -33,5 +34,15 @@ postOutput=[{seq:"1",received:"1",time:"1ms"}];await send({tool:"ping",target:"1 await send({tool:"bandwidth",bwRemoteAddress:"192.0.2.2",bwDuration:999});assert.equal((calls.at(-1)![1] as {durationSec:number}).durationSec,120) await send({tool:"traceroute",target:"192.0.2.2",traceHopTimeout:"800ms",traceUseDns:false});{const body=calls.at(-1)![1] as Record;assert.equal(body.timeout,"00:00:00.800");assert.equal(body["use-dns"],"false")} peerEnabled=false;{const before=calls.length;assert.equal((await send({tool:"bandwidth",bwRemoteAddress:"192.0.2.2"})).statusCode,409);assert.equal(calls.length,before)}peerEnabled=true +postOutput=[{seq:0,ttl:0,time:"0ms"},{sent:"0",received:"0"}];{const output=(await send({tool:"ping",target:"192.0.2.2"})).json().output;assert.match(output,/seq=0 ttl=0 time=0ms/);assert.match(output,/sent=0 received=0 packet-loss=—/)} +postOutput=[{seq:"junk",ttl:"bad",time:""},{sent:"junk",received:"bad"}];{const output=(await send({tool:"ping",target:"192.0.2.2"})).json().output;assert.match(output,/seq=\? ttl=\? time=—/);assert.match(output,/sent=\? received=\? packet-loss=—/)} +postOutput=[];assert.match((await send({tool:"ping",target:"192.0.2.2"})).json().output,/не вернул измерений/) +bwOutput=[{"tx-current":"0","rx-current":""}];assert.match((await send({tool:"bandwidth",bwRemoteAddress:"192.0.2.2"})).json().output,/tx=0 rx=—/) +for(const target of ["example.test","192.0.2.2junk","192.0.2.2/32","192.000.2.2","::1"]){const before=lookups;assert.equal((await send({tool:"route",target})).statusCode,400);assert.equal(lookups,before)} +routeOutput=[{}];assert.match((await send({tool:"route",target:"192.0.2.2"})).json().output,/Маршрут не подтверждён/) +routeOutput=[{active:"true","dst-address":"192.0.2.0/24junk",gateway:"198.51.100.9"}];assert.doesNotMatch((await send({tool:"route",target:"192.0.2.2"})).json().output,/best match/) +routeOutput=[{active:"true","dst-address":"0.0.0.0/0",gateway:"198.51.100.1",distance:"1"},{active:"true","dst-address":"192.0.2.0/24",gateway:"198.51.100.2",distance:"bad"},{active:"true","dst-address":"192.0.2.0/24",gateway:"198.51.100.3",distance:"0"}];{const output=(await send({tool:"route",target:"192.0.2.2"})).json().output;assert.match(output,/gateway: 198.51.100.3/);assert.match(output,/distance: 0/);assert.match(output,/routing-mark: —/)} +routeOutput=[{active:"true","dst-address":"192.0.2.0/24",gateway:"198.51.100.2"},{active:"unknown","dst-address":"192.0.2.2/32",gateway:"198.51.100.5"}];assert.match((await send({tool:"route",target:"192.0.2.2"})).json().output,/best confirmed match/) +routeOutput=[];assert.match((await send({tool:"route",target:"192.0.2.2"})).json().output,/no route/) dbFailure=true;assert.equal((await send({tool:"ping",target:"192.0.2.2"})).statusCode,500) await app.close();console.log("Probes HTTP: strict id, existing allowlist, success/upstream errors, DNS inline error, trace budget and source binding; no DB/RouterOS/DNS") diff --git a/backend/src/routes/probes.ts b/backend/src/routes/probes.ts index ba01fe84..b5a53cbd 100644 --- a/backend/src/routes/probes.ts +++ b/backend/src/routes/probes.ts @@ -1,3 +1,4 @@ +import { isIPv4 } from "node:net" import { z } from "zod" import dns from "node:dns/promises" import { eq } from "drizzle-orm" @@ -30,6 +31,7 @@ const probeRequestSchema = z.object({ dnsType: z.string().trim().toUpperCase().pipe(z.enum(["A","AAAA","MX","NS","TXT","CNAME","PTR"])).optional(), bwRemoteAddress: targetText.optional(), dstServerId: positiveInteger.optional(), bwProto: z.enum(["tcp","udp"]).optional(), bwDuration: positiveInteger.optional(), }).passthrough().superRefine((body, ctx) => { + if (body.tool === "route" && body.target && !isIPv4(body.target)) ctx.addIssue({ code: "custom", path: ["target"], message: "Поиск маршрута требует IPv4" }) if (body.tool === "bandwidth" ? !body.bwRemoteAddress : !body.target) ctx.addIssue({ code: "custom", path: [body.tool === "bandwidth" ? "bwRemoteAddress" : "target"], message: "Обязательная цель диагностики" }) }) @@ -76,8 +78,8 @@ function parseServerId(raw: string): number | 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 + if (!isIPv4(ip)) return null + const p = ip.split(".").map(Number) return (((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0) } @@ -94,9 +96,12 @@ function parseDstRoute(dst: string): { net: number; maskBits: number } | null { const ip = ipv4ToUint(t) return ip === null ? null : { net: ip, maskBits: 32 } } - const [addr, mb] = t.split("/") + const parts = t.split("/") + if (parts.length !== 2) return null + const [addr, mb] = parts const ip = ipv4ToUint(addr.trim()) - const maskBits = Number.parseInt((mb ?? "").trim(), 10) + if (!/^(?:[0-9]|[12][0-9]|3[0-2])$/.test((mb ?? "").trim())) return null + const maskBits = Number((mb ?? "").trim()) if (ip === null || !Number.isFinite(maskBits) || maskBits < 0 || maskBits > 32) return null const mask = maskFromLen(maskBits) return { net: ip & mask, maskBits } @@ -111,22 +116,26 @@ function ipMatchesRoute(destIp: string, dstAddressField: string): boolean { return (ip & mask) === (cidr.net & mask) } +function integerMetric(raw: unknown): number | null { + if (typeof raw !== "number" && typeof raw !== "string") return null + if (typeof raw === "string" && !/^\d+$/.test(raw)) return null + const value = Number(raw) + return Number.isSafeInteger(value) && value >= 0 ? value : null +} +function reportedMetric(raw: unknown): string { + return (typeof raw === "string" && raw.trim()) || (typeof raw === "number" && Number.isFinite(raw) ? String(raw) : "—") +} 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") + const lines = ["PING " + host] + for (const r of results) { + if (r.seq === undefined && r.status === undefined) continue + const seq = integerMetric(r.seq) ?? "?" + lines.push(r.status === "timeout" ? " seq=" + seq + " timeout" : " seq=" + seq + " ttl=" + (integerMetric(r.ttl) ?? "?") + " time=" + reportedMetric(r.time) + (r.status ? " status=" + r.status : "")) + } + const sum = [...results].reverse().find(r => r.sent !== undefined) + if (sum) lines.push(" sent=" + (integerMetric(sum.sent) ?? "?") + " received=" + (integerMetric(sum.received) ?? "?") + " packet-loss=" + reportedMetric(sum["packet-loss"])) + if (results.length === 0) lines.push(" API не вернул измерений ping.") + return lines.join("\n") } function fmtTraceroute(rows: unknown): string { @@ -135,10 +144,10 @@ function fmtTraceroute(rows: unknown): string { const lines = [hdr] rows.forEach((row, i) => { const r = row as Record - 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"] ?? "—") + const addr = reportedMetric(r.address ?? r.host ?? r["from-address"]) + const loss = reportedMetric(r.loss ?? r["packet-loss"]) + const last = reportedMetric(r["last"] ?? r.time) + const avg = reportedMetric(r.avg ?? r["avg-rtt"]) lines.push( ` ${String(i + 1).padStart(2)} ${addr.padEnd(40)} ${loss.padEnd(6)} ${last.padStart(8)} ${avg.padStart(8)}`, ) @@ -149,8 +158,8 @@ function fmtTraceroute(rows: unknown): string { function fmtBandwidth(rows: Array>): 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 tx = reportedMetric(r["tx-current"] ?? r["tx-total-average"]) + const rx = reportedMetric(r["rx-current"] ?? r["rx-total-average"]) const sec = r["test-duration"] ?? "" const parts = [`tx=${tx}`, `rx=${rx}`] if (sec) parts.push(`t=${sec}`) @@ -164,6 +173,7 @@ function fmtRouteLookup(destIp: string, routes: RosIpRoute[]): string { const s = String(v ?? "").trim().toLowerCase() return s === "true" || s === "yes" } + const unconfirmed = routes.filter(r => !["true","yes","false","no"].includes(String(r.active ?? "").toLowerCase()) || !parseDstRoute(String(r["dst-address"] ?? ""))).length const matches = routes.filter((r) => { const dst = String(r["dst-address"] ?? "").trim() if (!dst) return false @@ -176,27 +186,28 @@ function fmtRouteLookup(destIp: string, routes: RosIpRoute[]): string { return ipMatchesRoute(destIp, dst) }) if (matches.length === 0) { - return `no route for ${destIp} (active routes checked: ${routes.length})` + if (unconfirmed) return "Маршрут не подтверждён: неизвестных записей " + unconfirmed + "." + return `no route for ${destIp} (route records 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 distA = integerMetric(a.distance) ?? Infinity + const distB = integerMetric(b.distance) ?? Infinity + if (distA !== distB) return distA < distB ? -1 : 1 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:`, + "lookup " + destIp + " → " + (unconfirmed ? "best confirmed match (неизвестных записей: " + unconfirmed + "):" : "best match:"), ` dst-address: ${best["dst-address"] ?? "—"}`, ` gateway: ${best.gateway ?? best.interface ?? "—"}`, - ` distance: ${best.distance ?? "—"}`, - ` routing-mark: ${best["routing-mark"] ?? "main"}`, + ` distance: ${integerMetric(best.distance) ?? "—"}`, + ` routing-mark: ${best["routing-mark"] ?? "—"}`, ] return lines.join("\n") } diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 50d02c1d..6f2946c8 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,7 +26,7 @@ | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | -| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.66; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.67; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 10.67 — неизвестные измерения и маршрутный срез (2026-10-01) + +- Ping сохраняет seq=0/TTL=0/sent=0/received=0, отсутствующие/невалидные счётчики помечает ?, пустой ответ подписан без измерений. Нет ложного avg-rtt из поля time суммарной строки. BW/trace пустые показатели показывают —, известный ноль сохранён. +- Route требует IPv4 target до lookup. IPv4/CIDR парсятся целиком, без parseInt префиксов/лишних сегментов. Неизвестные active/prefix записи не превращаются в no route: неполнота явно показана, при наличии подтверждённого маршрута он подписан best confirmed match. Distance unknown не становится 255/0; routing-mark missing не называется main. +- Existing main/active filtering, longest-prefix/distance ordering и сетевые методы сохранены; malformed/unknown записи не используются как подтверждённые маршруты. +- Backend test:probes/build/lint прошли; DI HTTP проверяет нули/unknown ping/BW, плохой target/CIDR, mixed known/unknown маршруты и приоритеты. Моки не проверяют реальную FIB/RouterOS/IP. +- Далее 10.68: корректная привязка отмены к жизненному циклу HTTP ответа и прекращение последующих MTU проб; затем клавиатура/адаптивность и локальная приёмка /probes. Этап 10 ещё в работе. + ## Блок 10.66 — аргументы и достоверность MTU (2026-10-01) - Zod проверяет typed tool/target/числа/boolean/enum/timeout до lookup; число не берётся из строки, false не превращается в true. Неполная цель/невалидный timeout/небезопасные значения дают 400. Существующие defaults и caps ping 100/8192/255, trace 64 hops/3 probes/10–3000 ms/budget 58 s и BW 3–120 s сохранены для валидных аргументов. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 02aee1b0..22c7f433 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -415,3 +415,12 @@ Shared контракт и backend observed исходные значения/д - Backend test:probes/test:exec, build и lint прошли; HTTP DI покрывает bad types, disabled, unknown MTU, успешный seq=0, все timeout, caps/trace boolean/time. 2 frontend E2E подтвердили 403/502 и сохранение цели/повтор. JWT тест отправляет валидный probes payload; реальные RouterOS/БД/сеть не проверены. - Далее 10.67: достоверные ping/BW/route показатели и строгий IPv4/CIDR; затем отмена backend запроса, клавиатура/адаптивность, прежние эффекты и локальная приёмка /probes. Этап 10 ещё в работе. + +## Блок 10.67 — неизвестные измерения и маршрутный срез (2026-10-01) + +- Ping сохраняет seq=0/TTL=0/sent=0/received=0, отсутствующие/невалидные счётчики помечает ?, пустой ответ подписан без измерений. Нет ложного avg-rtt из поля time суммарной строки. BW/trace пустые показатели показывают —, известный ноль сохранён. +- Route требует IPv4 target до lookup. IPv4/CIDR парсятся целиком, без parseInt префиксов/лишних сегментов. Неизвестные active/prefix записи не превращаются в no route: неполнота явно показана, при наличии подтверждённого маршрута он подписан best confirmed match. Distance unknown не становится 255/0; routing-mark missing не называется main. +- Existing main/active filtering, longest-prefix/distance ordering и сетевые методы сохранены; malformed/unknown записи не используются как подтверждённые маршруты. +- Backend test:probes/build/lint прошли; DI HTTP проверяет нули/unknown ping/BW, плохой target/CIDR, mixed known/unknown маршруты и приоритеты. Моки не проверяют реальную FIB/RouterOS/IP. +- Далее 10.68: корректная привязка отмены к жизненному циклу HTTP ответа и прекращение последующих MTU проб; затем клавиатура/адаптивность и локальная приёмка /probes. Этап 10 ещё в работе. +