fix(api): сохранять неизвестные показатели диагностики
This commit is contained in:
1 parent
33185fddae
commit
9d2101d700
4 files changed
+74
-35
No files matched your search
@@ -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<NonNullable<ProbeDependencies["createClient"]>>
|
||||
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<string,string>;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")
|
||||
@@ -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<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"] ?? "—")
|
||||
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<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 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")
|
||||
}
|
||||
|
||||
@@ -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 сохранены для валидных аргументов.
|
||||
|
||||
@@ -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 ещё в работе.
|
||||
|
||||
Reference in new issue
Block a user