diff --git a/backend/src/plugins/auth.exec.test.ts b/backend/src/plugins/auth.exec.test.ts index 5f7e7987..9f35eab4 100644 --- a/backend/src/plugins/auth.exec.test.ts +++ b/backend/src/plugins/auth.exec.test.ts @@ -9,7 +9,7 @@ const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerialize let lookups=0 await app.register(createExecRoutes({getServer:async()=>{lookups++;return undefined},createClient:()=>{throw new Error("unused")}}),{prefix:"/api"}) await app.register(createProbeRoutes({getServer:async()=>{lookups++;return undefined},createClient:()=>{throw new Error("unused")}}),{prefix:"/api"}) -const send=(token?:string,url="/api/servers/1/exec")=>app.inject({method:"POST",url,payload:{command:"interface print"},headers:token?{authorization:"Bearer "+token}:{}}) +const send=(token?:string,url="/api/servers/1/exec")=>app.inject({method:"POST",url,payload:url.endsWith("/probes/run")?{tool:"ping",target:"192.0.2.2"}:{command:"interface print"},headers:token?{authorization:"Bearer "+token}:{}}) assert.equal((await send()).statusCode,401);assert.equal(lookups,0) for(const url of ["/api/servers/1/exec","/api/servers/1/probes/run"])for(const permission of ["mm:servers:read","mm:network:write","mm:servers:write","mm:servers:admin"]){ const token=await new SignJWT({sub:"test",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("terminal-test-secret")) diff --git a/backend/src/routes/probes.test.ts b/backend/src/routes/probes.test.ts index 7254b82f..9bbf1850 100644 --- a/backend/src/routes/probes.test.ts +++ b/backend/src/routes/probes.test.ts @@ -4,15 +4,16 @@ import {validatorCompiler,serializerCompiler} from "@fastify/type-provider-zod" import {createProbeRoutes,type ProbeDependencies} from "./probes.js" 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 +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"}] const calls:unknown[][]=[] const client={ - post:async(...args:unknown[])=>{calls.push(args);if(upstreamFailure)throw new Error("RouterOS недоступен");return [{seq:"1",received:"1",time:"1ms",address:"192.0.2.2"}]}, + 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()=>{if(upstreamFailure)throw new Error("BW недоступен");return [{"tx-current":"0","rx-current":"42"}]}, + bandwidthTest:async(...args:unknown[])=>{calls.push(["bw",...args]);if(upstreamFailure)throw new Error("BW недоступен");return [{"tx-current":"0","rx-current":"42"}]}, } as ReturnType> const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler) -await app.register(createProbeRoutes({getServer:async()=>{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}),{prefix:"/api"}) +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"}) const send=(payload:unknown,id="1")=>app.inject({method:"POST",url:"/api/servers/"+id+"/probes/run",payload:payload as object}) for(const id of ["1oops","0","-1","1.2","9007199254740993"]){assert.equal((await send({tool:"ping",target:"192.0.2.2"},id)).statusCode,400);assert.equal(calls.length,0)} exists=false;assert.equal((await send({tool:"ping",target:"192.0.2.2"})).statusCode,404);exists=true @@ -21,5 +22,16 @@ for(const tool of ["ping","traceroute","dns","route","bandwidth","mtu"]){const b const trace=calls.find(row=>row[0]==="/tool/traceroute")!;assert.equal((trace[1] as Record)["max-hops"],"5");assert.equal((trace[1] as Record).timeout,"00:00:03");assert.equal((trace[1] as Record)["src-address"],"192.0.2.1") upstreamFailure=true;for(const tool of ["ping","traceroute","route","bandwidth"]){const r=await send({tool,target:"192.0.2.2",bwRemoteAddress:"192.0.2.2"});assert.equal(r.statusCode,502,tool);assert.equal(typeof r.json().error,"string");assert.equal(r.json().output,undefined)}upstreamFailure=false dnsOutput=";; QUESTION: example.test A\n;; ERROR: ENOTFOUND";const dns=await send({tool:"dns",target:"example.test"});assert.equal(dns.statusCode,502);assert.match(dns.json().error,/ENOTFOUND/) +for(const bad of [{pingCount:"5"},{pingCount:0},{pingSize:-1},{pingTtl:1.2},{traceUseDns:"false"},{traceProto:"junk"},{traceMaxHops:0},{traceHopTimeout:"junk"},{traceHopTimeout:"00:99:01"},{traceHopTimeout:"0s"},{traceProbeCount:0},{dnsType:"ANY"},{target:{}},{target:" "},{dstServerId:-1},{bwProto:"junk"},{bwDuration:0}]){ + const before=lookups, operations=calls.length; const response=await send({tool:"ping",target:"192.0.2.2",...bad}); assert.equal(response.statusCode,400,JSON.stringify(bad));assert.equal(lookups,before);assert.equal(calls.length,operations) +} +server.enabled=false;{const before=calls.length;assert.equal((await send({tool:"ping",target:"192.0.2.2"})).statusCode,409);assert.equal(calls.length,before)}server.enabled=true +for(const unknown of [[],[{}],[{seq:"0",time:"junk"}],[{received:"99999999999999999999999999"}],[{seq:"junk",time:"1ms"}]]){postOutput=unknown;const before=calls.length;const response=await send({tool:"mtu",target:"192.0.2.2"});assert.equal(response.statusCode,502);assert.match(response.json().error,/не подтвердил/);assert.equal(calls.length,before+1)} +postOutput=[{seq:"0",time:"00:00:00.001"}];assert.match((await send({tool:"mtu",target:"192.0.2.2"})).json().output,/MTU \(DF\): ~1500/) +postOutput=[{seq:"0",status:"timeout"}];{const before=calls.length,response=await send({tool:"mtu",target:"192.0.2.2"});assert.equal(response.statusCode,200);assert.match(response.json().output,/could not determine/);assert.doesNotMatch(response.json().output,/fragment needed|✓ ok|~1500/);assert.equal(calls.length,before+10)} +postOutput=[{seq:"1",received:"1",time:"1ms"}];await send({tool:"ping",target:"192.0.2.2",pingCount:999,pingSize:99999,pingTtl:999});{const body=calls.at(-1)![1] as Record;assert.equal(body.count,"100");assert.equal(body.size,"8192");assert.equal(body.ttl,"255")} +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 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 11b14ff4..ba01fe84 100644 --- a/backend/src/routes/probes.ts +++ b/backend/src/routes/probes.ts @@ -1,3 +1,4 @@ +import { z } from "zod" import dns from "node:dns/promises" import { eq } from "drizzle-orm" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" @@ -6,7 +7,6 @@ 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: если команда «бесконечна», сессия всё равно закрывается @@ -15,6 +15,24 @@ import { abortAfterMs, mergeAbortSignals } from "../utils/abort-signals.js" */ const ROS_REST_SESSION_MAX_MS = 58_000 +const positiveInteger = z.number().int().positive().max(Number.MAX_SAFE_INTEGER) +const targetText = 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) +}, "Некорректный timeout traceroute") +const probeRequestSchema = z.object({ + tool: z.string().trim().pipe(z.enum(["ping","traceroute","bandwidth","dns","route","mtu"])), + target: targetText.optional(), pingCount: positiveInteger.optional(), pingSize: positiveInteger.optional(), pingTtl: positiveInteger.optional(), + traceProto: z.enum(["icmp","udp","tcp"]).optional(), traceMaxHops: positiveInteger.optional(), traceHopTimeout: traceTime.optional(), traceProbeCount: positiveInteger.optional(), traceUseDns: z.boolean().optional(), + 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 === "bandwidth" ? !body.bwRemoteAddress : !body.target) ctx.addIssue({ code: "custom", path: [body.tool === "bandwidth" ? "bwRemoteAddress" : "target"], message: "Обязательная цель диагностики" }) +}) + /** Парсинг ввода пользователя (800ms, 1s, 00:00:01) → миллисекунды (10–3000). */ function parseTraceHopInputToMs(raw: unknown): number { const s = String(raw ?? "").trim().toLowerCase() @@ -228,7 +246,7 @@ async function dnsLookupText(name: string, type: string): Promise { async function mtuDiscover(client: ProbeClient, address: string, srcIpv4: string | null): Promise { 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 + let mtuFound: number | null = null for (const size of sizes) { try { const body: Record = { @@ -242,18 +260,21 @@ async function mtuDiscover(client: ProbeClient, address: string, srcIpv4: string if (srcIpv4) body["src-address"] = srcIpv4 const rows = await client.post("/tool/ping", body, 25_000) const timeout = rows.some((r) => r.status === "timeout") - if (!timeout) { + const received = rows.some(r => /^\d+$/.test(String(r.received ?? "")) && Number(r.received) === 1) + const reply = rows.some(r => /^[01]$/.test(String(r.seq ?? "")) && !r.status && /^(?:\d+(?:\.\d+)?(?:us|ms|s)|\d{1,2}:\d{2}:\d{2}(?:\.\d+)?)$/.test(String(r.time ?? ""))) + if (received || reply) { lines.push(` ${String(size).padStart(4)} ✓ ok`) mtuFound = size break } - lines.push(` ${String(size).padStart(4)} ✗ fragment needed / timeout`) + if (!timeout) throw new Error("RouterOS не подтвердил ответ MTU-пробы.") + lines.push(String(size).padStart(4) + " timeout; причина не определена") } catch (e) { - lines.push(` ${String(size).padStart(4)} ✗ ${e instanceof Error ? e.message : String(e)}`) + throw new Error(lines.join("\n") + "\nMTU-проба " + size + ": " + (e instanceof Error ? e.message : String(e))) } } lines.push("") - lines.push(mtuFound > 0 ? `MTU (DF): ~${mtuFound} bytes` : "MTU: could not determine") + lines.push(mtuFound !== null ? `MTU (DF): ~${mtuFound} bytes` : "MTU: could not determine") return lines.join("\n") } @@ -288,39 +309,13 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs const sid = parseServerId(String((req.params as { id?: string }).id ?? "")) if (sid === null) return reply.status(400).send({ error: "Invalid server id" }) - if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) return reply.status(400).send({ error: "Diagnostic request must be an object" }) + const parsed = probeRequestSchema.safeParse(req.body) + if (!parsed.success) return reply.status(400).send({ error: "Некорректные параметры диагностики: " + [...new Set(parsed.error.issues.map(issue => issue.path.join(".") || "body"))].join(", ") }) + const body = parsed.data const server = await getServer(sid) 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" - if (!["ping", "traceroute", "bandwidth", "dns", "route", "mtu"].includes(tool)) return reply.status(400).send({ error: "unknown tool: " + tool }) + if (!server.enabled) return reply.status(409).send({ error: "Устройство выключено. Диагностика недоступна." }) + const tool = body.tool try { const client = createClient(server) const srcIpv4 = ["ping", "traceroute", "mtu"].includes(tool) ? await resolveSource(server.host) : null @@ -371,8 +366,9 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs const disconnectAbort = new AbortController() const onClientClose = () => disconnectAbort.abort() req.raw.once("close", onClientClose) + const traceTimeout = setTimeout(() => disconnectAbort.abort(), traceWallMs) try { - const sig = mergeAbortSignals(disconnectAbort.signal, abortAfterMs(traceWallMs)) + const sig = disconnectAbort.signal const rows = await client.post("/tool/traceroute", traceBody, traceWallMs, sig) const note = effectiveMaxHops < maxHopsRequested @@ -380,6 +376,7 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs : "" output = note + fmtTraceroute(rows) } finally { + clearTimeout(traceTimeout) req.raw.off("close", onClientClose) } break @@ -393,6 +390,7 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs error: "Не найден сервер назначения для bandwidth-test: добавьте узел с host = GRE remote или укажите dstServerId", }) } + if (!dst.enabled) return reply.status(409).send({ error: "Устройство назначения выключено. BW-тест недоступен." }) const protocol = body.bwProto === "udp" ? "udp" : "tcp" const durationSec = Math.max(3, Math.min(120, Number(body.bwDuration) || 10)) const rows = await client.bandwidthTest({ diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 189a55a8..50d02c1d 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.65; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.66; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 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 сохранены для валидных аргументов. +- Disabled сервер/получатель BW дают 409 до сетевого действия. MTU требует подтверждённый received=1 либо корректный ответ seq/time; пустой/неизвестный/ошибочный ответ даёт 502 и прекращает новые пробы. Timeout не называется fragment needed; полный набор timeout остаётся неизвестным MTU, не 0/1500. +- Таймер traceroute теперь снимается в finally после завершения; общий срок и отмена сохранены, тестовый процесс больше не ждёт лишние 58 секунд. +- 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.65 — честный HTTP контракт backend диагностики (2026-10-01) - createProbeRoutes допускает DI поиска устройства/клиента/источника/DNS/BW peer; production defaults сохранены. Строгий положительный целый serverId запрещает parseInt префиксы; body должен быть объектом. Allowlist ping/traceroute/bandwidth/dns/route/mtu не расширен. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 4f3004e8..02aee1b0 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -406,3 +406,12 @@ Shared контракт и backend observed исходные значения/д - Backend test:probes включён в общий test; DI HTTP тесты, test:exec/test:auth, backend build и lint прошли. JWT тест probes теперь регистрирует настоящий createProbeRoutes с подменой getServer: read/network write запрещены до lookup, servers write/admin допускаются до 404. Это не реальный портал/RouterOS/БД/DNS. - Далее 10.66: строгие typed аргументы с сохранением существующих caps/defaults, отказ disabled устройства, 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 сохранены для валидных аргументов. +- Disabled сервер/получатель BW дают 409 до сетевого действия. MTU требует подтверждённый received=1 либо корректный ответ seq/time; пустой/неизвестный/ошибочный ответ даёт 502 и прекращает новые пробы. Timeout не называется fragment needed; полный набор timeout остаётся неизвестным MTU, не 0/1500. +- Таймер traceroute теперь снимается в finally после завершения; общий срок и отмена сохранены, тестовый процесс больше не ждёт лишние 58 секунд. +- 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 ещё в работе. + diff --git a/tests/e2e/probes-data.spec.ts b/tests/e2e/probes-data.spec.ts index aa361c9e..a44c0d33 100644 --- a/tests/e2e/probes-data.spec.ts +++ b/tests/e2e/probes-data.spec.ts @@ -6,7 +6,7 @@ async function routes(page:Page){ } test.beforeEach(async({page})=>routes(page)) const run=(page:Page)=>page.getByRole("button",{name:"Запустить",exact:true}) -for(const [name,data,status] of [["malformed",{},200],["legacy",{output:" error: RouterOS недоступен"},200],["API",{error:"Нет доступа к диагностике"},403]] as const)test("Диагностика: "+name+" сохраняет цель и допускает повтор",async({page})=>{ +for(const [name,data,status] of [["malformed",{},200],["legacy",{output:" error: RouterOS недоступен"},200],["API",{error:"Нет доступа к диагностике"},403],["upstream",{error:"RouterOS недоступен"},502]] as const)test("Диагностика: "+name+" сохраняет цель и допускает повтор",async({page})=>{ let fail=true;await page.route("**/api/servers/1/probes/run",r=>r.fulfill(fail?{json:data,status}:{json:{output:"PING подтверждён"}})) await page.goto("/probes?server=1");await expect(run(page)).toBeEnabled();const target=page.getByRole("textbox",{name:"Цель диагностики"});await target.fill("198.51.100.9");await run(page).click() await expect(page.getByText("ошибка",{exact:true})).toBeVisible();await expect(target).toHaveValue("198.51.100.9");await expect(run(page)).toBeEnabled()