fix(api): отделить отказ диагностики от успешного вывода

This commit is contained in:
Denozordec committed 2026-10-01 08:42:06 +07:00
1 parent b60b7ac8e8
commit 68ccc49135
6 files changed
+79 -15

No files matched your search

+2 -1
View File
@@ -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"
},
+2 -1
View File
@@ -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"]){
+25
View File
@@ -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<NonNullable<ProbeDependencies["createClient"]>>
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<string,string>)["max-hops"],"5");assert.equal((trace[1] as Record<string,string>).timeout,"00:00:03");assert.equal((trace[1] as Record<string,string>)["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")
+32 -12
View File
@@ -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<string> {
return lines.join("\n")
}
async function mtuDiscover(client: MikrotikClient, address: string, srcIpv4: string | null): Promise<string> {
async function mtuDiscover(client: ProbeClient, address: string, srcIpv4: string | null): Promise<string> {
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<MikrotikClient, "post" | "getIpRoutes" | "bandwidthTest">
type ProbeServer = typeof servers.$inferSelect
export interface ProbeDependencies {
getServer?: (id: number) => Promise<ProbeServer | undefined>
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()
+9 -1
View File
@@ -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 приложения не запрашивает этот источник.
+9
View File
@@ -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 ещё не завершён.