fix(api): отделить отказ диагностики от успешного вывода
This commit is contained in:
1 parent
b60b7ac8e8
commit
68ccc49135
6 files changed
+79
-15
No files matched your search
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"]){
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user