diff --git a/backend/package.json b/backend/package.json index 5321dfbf..8974f144 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "db:studio": "drizzle-kit studio", "db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts", "facts:rebuild": "tsx src/scripts/rebuild-flow-facts.ts", + "test:probes": "tsx src/routes/probes.test.ts", "test:exec": "tsx src/routes/exec.test.ts && tsx src/plugins/auth.exec.test.ts", "test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts", "test:wireguard": "npx tsx src/services/wireguard-config.test.ts", @@ -25,7 +26,7 @@ "test:ros-file": "tsx src/services/ros-file-contents.test.ts", "test:backups": "tsx src/services/s3-backup-client.test.ts", "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts", - "test": "npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file", + "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file", "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts", "test:ospf-optimizer": "tsx src/services/ospf-optimizer-identity.test.ts && tsx src/services/ospf-optimizer-metrics.test.ts && tsx src/services/ospf-optimizer-plan.test.ts && tsx src/routes/ospf-optimizer.test.ts" }, diff --git a/backend/src/plugins/auth.exec.test.ts b/backend/src/plugins/auth.exec.test.ts index 3324cd9a..5f7e7987 100644 --- a/backend/src/plugins/auth.exec.test.ts +++ b/backend/src/plugins/auth.exec.test.ts @@ -4,10 +4,11 @@ import {SignJWT} from "jose" import {validatorCompiler,serializerCompiler} from "@fastify/type-provider-zod" process.env.AUTH_REQUIRED="true";process.env.AUTH_JWT_SECRET="terminal-test-secret";process.env.AUTH_ISSUER="https://auth.test.local";process.env.NODE_ENV="test" const {default:authPlugin,requireAuth}=await import("./auth.js"),{createExecRoutes}=await import("../routes/exec.js") +const {createProbeRoutes}=await import("../routes/probes.js") const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler);await app.register(authPlugin);app.addHook("preHandler",requireAuth) let lookups=0 await app.register(createExecRoutes({getServer:async()=>{lookups++;return undefined},createClient:()=>{throw new Error("unused")}}),{prefix:"/api"}) -app.post("/api/servers/:id/probes/run",async(_req,reply)=>{lookups++;return reply.status(404).send({error:"DI: no device"})}) +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}:{}}) 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"]){ diff --git a/backend/src/routes/probes.test.ts b/backend/src/routes/probes.test.ts new file mode 100644 index 00000000..7254b82f --- /dev/null +++ b/backend/src/routes/probes.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +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 +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"}]}, + getIpRoutes:async()=>{if(upstreamFailure)throw new Error("Routes недоступны");return []}, + bandwidthTest:async()=>{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"}) +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 +for(const payload of [[],{}, {tool:"ssh"},{tool:"ping"}]){const r=await send(payload);assert.equal(r.statusCode,400);assert.equal(typeof r.json().error,"string")} +for(const tool of ["ping","traceroute","dns","route","bandwidth","mtu"]){const before=resolves,r=await send({tool,target:"192.0.2.2",bwRemoteAddress:"192.0.2.2",traceHopTimeout:"3s",traceProbeCount:3,traceMaxHops:64});assert.equal(r.statusCode,200,tool);assert.equal(typeof r.json().output,"string");assert.equal(resolves,before+(["ping","traceroute","mtu"].includes(tool)?1:0))} +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/) +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 bd1c5c8d..11b14ff4 100644 --- a/backend/src/routes/probes.ts +++ b/backend/src/routes/probes.ts @@ -52,8 +52,9 @@ function traceHopTimeoutForApi(raw: unknown): string { } function parseServerId(raw: string): number | null { - const n = Number.parseInt(raw, 10) - return Number.isFinite(n) ? n : null + if (!/^[1-9][0-9]*$/.test(raw)) return null + const n = Number(raw) + return Number.isSafeInteger(n) ? n : null } function ipv4ToUint(ip: string): number | null { @@ -224,7 +225,7 @@ async function dnsLookupText(name: string, type: string): Promise { return lines.join("\n") } -async function mtuDiscover(client: MikrotikClient, address: string, srcIpv4: string | null): 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 @@ -266,12 +267,29 @@ async function resolveBtestPeer(remoteHost: string, explicitDstId?: number) { return enabled.find((x) => x.host.trim().toLowerCase() === norm) } -const probesRoutes: FastifyPluginAsyncZod = async (app) => { +type ProbeClient = Pick +type ProbeServer = typeof servers.$inferSelect +export interface ProbeDependencies { + getServer?: (id: number) => Promise + createClient?: (server: ProbeServer) => ProbeClient + resolveSource?: typeof resolveRosSrcIpv4 + lookupDns?: typeof dnsLookupText + findPeer?: typeof resolveBtestPeer +} + +export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAsyncZod { + const getServer = deps.getServer ?? (async (id: number) => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0]) + const createClient = deps.createClient ?? MikrotikClient.fromServer + const resolveSource = deps.resolveSource ?? resolveRosSrcIpv4 + const lookupDns = deps.lookupDns ?? dnsLookupText + const findPeer = deps.findPeer ?? resolveBtestPeer + return async (app) => { app.post("/servers/:id/probes/run", async (req, reply) => { const sid = parseServerId(String((req.params as { id?: string }).id ?? "")) if (sid === null) return reply.status(400).send({ error: "Invalid server id" }) - const server = (await db.select().from(servers).where(eq(servers.id, sid)).limit(1))[0] + if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) return reply.status(400).send({ error: "Diagnostic request must be an object" }) + const server = await getServer(sid) if (!server) return reply.status(404).send({ error: "Server not found" }) const body = req.body as { @@ -302,10 +320,10 @@ const probesRoutes: FastifyPluginAsyncZod = async (app) => { | "dns" | "route" | "mtu" - const client = MikrotikClient.fromServer(server) - const srcIpv4 = await resolveRosSrcIpv4(server.host) - + if (!["ping", "traceroute", "bandwidth", "dns", "route", "mtu"].includes(tool)) return reply.status(400).send({ error: "unknown tool: " + tool }) try { + const client = createClient(server) + const srcIpv4 = ["ping", "traceroute", "mtu"].includes(tool) ? await resolveSource(server.host) : null let output = "" switch (tool) { @@ -369,7 +387,7 @@ const probesRoutes: FastifyPluginAsyncZod = async (app) => { case "bandwidth": { const remote = String(body.bwRemoteAddress ?? "").trim() if (!remote) return reply.status(400).send({ error: "bwRemoteAddress required" }) - const dst = await resolveBtestPeer(remote, body.dstServerId) + const dst = await findPeer(remote, body.dstServerId) if (!dst) { return reply.status(400).send({ error: "Не найден сервер назначения для bandwidth-test: добавьте узел с host = GRE remote или укажите dstServerId", @@ -392,7 +410,7 @@ const probesRoutes: FastifyPluginAsyncZod = async (app) => { const target = String(body.target ?? "").trim() if (!target) return reply.status(400).send({ error: "target required" }) const dtype = String(body.dnsType ?? "A").trim() || "A" - output = await dnsLookupText(target, dtype) + output = await lookupDns(target, dtype) break } case "route": { @@ -412,6 +430,7 @@ const probesRoutes: FastifyPluginAsyncZod = async (app) => { return reply.status(400).send({ error: `unknown tool: ${tool}` }) } + if (/^;;\s*ERROR:/im.test(output) || /^error:/i.test(output.trimStart())) return reply.status(502).send({ error: output }) return reply.send({ output }) } catch (err) { const msg = @@ -420,9 +439,10 @@ const probesRoutes: FastifyPluginAsyncZod = async (app) => { : err instanceof Error ? err.message : String(err) - return reply.send({ output: `error: ${msg}` }) + return reply.status(502).send({ error: msg }) } }) + } } -export default probesRoutes +export default createProbeRoutes() diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index bc9769ca..189a55a8 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.64; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.65; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 10.65 — честный HTTP контракт backend диагностики (2026-10-01) + +- createProbeRoutes допускает DI поиска устройства/клиента/источника/DNS/BW peer; production defaults сохранены. Строгий положительный целый serverId запрещает parseInt префиксы; body должен быть объектом. Allowlist ping/traceroute/bandwidth/dns/route/mtu не расширен. +- Upstream/client/resolve отказ возвращает 502/error, DNS inline ;; ERROR также не считается успешным output. Неизвестный сервер — 404, неподдерживаемый инструмент/отсутствующая цель — 400. DB ошибки сохраняют стандартный 500. Frontend уже поддерживает error ответы и legacy output. +- IPv4 определяется только для ping/traceroute/mtu; DNS приложения/route/BW не требуют этого независимого DNS запроса. Параметры/лимит traceroute и прежние инструменты сохранены. +- 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.64 — IPv4 источника preview (2026-10-01) - ros-src-address проверяет host текущего устройства и канонический IPv4/null. Preview привязан к id+host; поздний ответ/ошибка отменяются при смене устройства/источника. Запрос идёт только для enabled устройства и инструментов ping/traceroute/mtu; DNS приложения не запрашивает этот источник. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 85d4a06c..4f3004e8 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -397,3 +397,12 @@ Shared контракт и backend observed исходные значения/д - 36 основных E2E (29 данных + 7 IPv4), 4 source E2E, unit decoder, профильный lint, TypeScript и production frontend прошли. Реальный DNS/RouterOS не проверен. - Далее 10.65: DI backend probes, честные HTTP ошибки вместо legacy error output, сохранение allowlist/методов; затем строгие аргументы и MTU/неизвестные измерения. Доступность, адаптивная приёмка /probes и прежние lint эффекты ещё открыты. Расписание остаётся 11Б. + +## Блок 10.65 — честный HTTP контракт backend диагностики (2026-10-01) + +- createProbeRoutes допускает DI поиска устройства/клиента/источника/DNS/BW peer; production defaults сохранены. Строгий положительный целый serverId запрещает parseInt префиксы; body должен быть объектом. Allowlist ping/traceroute/bandwidth/dns/route/mtu не расширен. +- Upstream/client/resolve отказ возвращает 502/error, DNS inline ;; ERROR также не считается успешным output. Неизвестный сервер — 404, неподдерживаемый инструмент/отсутствующая цель — 400. DB ошибки сохраняют стандартный 500. Frontend уже поддерживает error ответы и legacy output. +- IPv4 определяется только для ping/traceroute/mtu; DNS приложения/route/BW не требуют этого независимого DNS запроса. Параметры/лимит traceroute и прежние инструменты сохранены. +- 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 ещё не завершён. +