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()