fix(api): прекращать диагностику при отмене HTTP
This commit is contained in:
1 parent
9d2101d700
commit
440e10c646
6 files changed
+89
-11
No files matched your search
@@ -13,7 +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:probes": "tsx src/routes/probes.test.ts && tsx src/routes/probes.cancel.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",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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 mode="normal",calls=0,aborts=0,resolveStarted!:()=>void,started=Promise.resolve(),resolveAborted!:()=>void,aborted=Promise.resolve()
|
||||
function waitSignal(signal?:AbortSignal){
|
||||
assert.ok(signal,"HTTP отмена передана в RouterOS клиент");assert.equal(signal.aborted,false,"тело HTTP прочитано, но ответ ещё ожидается");calls++;resolveStarted()
|
||||
if(mode==="normal")return Promise.resolve([{seq:"0",received:"1",time:"1ms"}])
|
||||
return new Promise<unknown>((_resolve,reject)=>{const timer=setTimeout(()=>reject(new Error("Отмена не доставлена")),2000);signal.addEventListener("abort",()=>{clearTimeout(timer);aborts++;resolveAborted();reject(new DOMException("aborted","AbortError"))},{once:true})})
|
||||
}
|
||||
const client={post:async(_path:unknown,_body:unknown,_timeout:unknown,signal?:AbortSignal)=>waitSignal(signal),getIpRoutes:async()=>[],bandwidthTest:async(_params:unknown,signal?:AbortSignal)=>waitSignal(signal)} as ReturnType<NonNullable<ProbeDependencies["createClient"]>>
|
||||
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler)
|
||||
await app.register(createProbeRoutes({getServer:async()=>server,createClient:()=>client,resolveSource:async()=>"192.0.2.1",findPeer:async()=>server}),{prefix:"/api"})
|
||||
const address=await app.listen({host:"127.0.0.1",port:0})
|
||||
async function send(tool:string,signal?:AbortSignal){return fetch(address+"/api/servers/1/probes/run",{method:"POST",headers:{"content-type":"application/json",connection:"close"},body:JSON.stringify({tool,target:"192.0.2.2",bwRemoteAddress:"192.0.2.2"}),signal})}
|
||||
try{
|
||||
resolveStarted=()=>{};const normal=await send("traceroute");assert.equal(normal.status,200);assert.ok((await normal.json()).output)
|
||||
mode="cancel"
|
||||
for(const tool of ["ping","traceroute","mtu","bandwidth"]){
|
||||
started=new Promise<void>(r=>resolveStarted=r);aborted=new Promise<void>(r=>resolveAborted=r);const before=calls,beforeAborts=aborts,controller=new AbortController()
|
||||
const pending=send(tool,controller.signal).then(()=>{throw new Error("Отменённый HTTP не должен завершаться успехом")},error=>{assert.equal(error.name,"AbortError")})
|
||||
await started;controller.abort();await Promise.race([aborted,new Promise<void>((_r,reject)=>setTimeout(()=>reject(new Error("Нет backend abort")),2500))]);await pending
|
||||
assert.equal(aborts,beforeAborts+1);assert.equal(calls,before+1,tool+": следующие пробы не запущены")
|
||||
}
|
||||
}finally{await app.close()}
|
||||
console.log("Probes local HTTP: normal trace body does not abort; browser disconnect reaches ping/trace/MTU/BW client and stops further probes; no RouterOS")
|
||||
|
||||
const {createServer}=await import("node:http")
|
||||
const {MikrotikClient}=await import("../services/mikrotik.js")
|
||||
let arrived!:()=>void,disconnected!:()=>void
|
||||
const arrival=new Promise<void>(r=>arrived=r),disconnection=new Promise<void>(r=>disconnected=r)
|
||||
const ros=createServer((req,res)=>{req.resume();req.once("end",arrived);res.once("close",disconnected)})
|
||||
await new Promise<void>(r=>ros.listen(0,"127.0.0.1",r))
|
||||
try{
|
||||
const addr=ros.address();assert.ok(addr&&typeof addr!=="string")
|
||||
const liveClient=new MikrotikClient({host:"127.0.0.1",port:addr.port,useSsl:false,verifySsl:false,username:"fixture",password:"fixture"})
|
||||
const controller=new AbortController()
|
||||
const pending=liveClient.bandwidthTest({address:"192.0.2.2",user:"fixture",password:"fixture"},controller.signal).then(()=>{throw new Error("BW не должен завершаться успехом")},error=>assert.match(error.message,/abort/i))
|
||||
await arrival;controller.abort();await pending
|
||||
await Promise.race([disconnection,new Promise<void>((_r,reject)=>setTimeout(()=>reject(new Error("RouterOS HTTP не закрыт")),2000))])
|
||||
}finally{await new Promise<void>((resolve,reject)=>ros.close(error=>error?reject(error):resolve()))}
|
||||
console.log("BW transport AbortSignal: real MikrotikClient POST closed against local fake RouterOS")
|
||||
@@ -254,11 +254,12 @@ async function dnsLookupText(name: string, type: string): Promise<string> {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
async function mtuDiscover(client: ProbeClient, address: string, srcIpv4: string | null): Promise<string> {
|
||||
async function mtuDiscover(client: ProbeClient, address: string, srcIpv4: string | null, signal?: AbortSignal): 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: number | null = null
|
||||
for (const size of sizes) {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
const body: Record<string, string> = {
|
||||
address,
|
||||
@@ -269,7 +270,7 @@ async function mtuDiscover(client: ProbeClient, address: string, srcIpv4: string
|
||||
"do-not-fragment": "true",
|
||||
}
|
||||
if (srcIpv4) body["src-address"] = srcIpv4
|
||||
const rows = await client.post<RosPingResult[]>("/tool/ping", body, 25_000)
|
||||
const rows = await client.post<RosPingResult[]>("/tool/ping", body, 25_000, signal)
|
||||
const timeout = rows.some((r) => r.status === "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 ?? "")))
|
||||
@@ -327,9 +328,18 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
|
||||
if (!server) return reply.status(404).send({ error: "Server not found" })
|
||||
if (!server.enabled) return reply.status(409).send({ error: "Устройство выключено. Диагностика недоступна." })
|
||||
const tool = body.tool
|
||||
const requestAbort = new AbortController()
|
||||
const onRequestAborted = () => requestAbort.abort()
|
||||
const onResponseClose = () => { if (!reply.raw.writableEnded) requestAbort.abort() }
|
||||
req.raw.once("aborted", onRequestAborted)
|
||||
reply.raw.once("close", onResponseClose)
|
||||
if (req.raw.aborted || reply.raw.destroyed) requestAbort.abort()
|
||||
const signal = requestAbort.signal
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
const client = createClient(server)
|
||||
const srcIpv4 = ["ping", "traceroute", "mtu"].includes(tool) ? await resolveSource(server.host) : null
|
||||
signal.throwIfAborted()
|
||||
let output = ""
|
||||
|
||||
switch (tool) {
|
||||
@@ -347,7 +357,7 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
|
||||
interval: "0.2s",
|
||||
}
|
||||
if (srcIpv4) pingBody["src-address"] = srcIpv4
|
||||
const rows = await client.post<RosPingResult[]>("/tool/ping", pingBody, 120_000)
|
||||
const rows = await client.post<RosPingResult[]>("/tool/ping", pingBody, 120_000, signal)
|
||||
output = fmtPingRouterOs(rows, target)
|
||||
break
|
||||
}
|
||||
@@ -376,7 +386,8 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
|
||||
if (srcIpv4) traceBody["src-address"] = srcIpv4
|
||||
const disconnectAbort = new AbortController()
|
||||
const onClientClose = () => disconnectAbort.abort()
|
||||
req.raw.once("close", onClientClose)
|
||||
signal.addEventListener("abort", onClientClose, { once: true })
|
||||
if (signal.aborted) disconnectAbort.abort()
|
||||
const traceTimeout = setTimeout(() => disconnectAbort.abort(), traceWallMs)
|
||||
try {
|
||||
const sig = disconnectAbort.signal
|
||||
@@ -388,7 +399,7 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
|
||||
output = note + fmtTraceroute(rows)
|
||||
} finally {
|
||||
clearTimeout(traceTimeout)
|
||||
req.raw.off("close", onClientClose)
|
||||
signal.removeEventListener("abort", onClientClose)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -402,6 +413,7 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
|
||||
})
|
||||
}
|
||||
if (!dst.enabled) return reply.status(409).send({ error: "Устройство назначения выключено. BW-тест недоступен." })
|
||||
signal.throwIfAborted()
|
||||
const protocol = body.bwProto === "udp" ? "udp" : "tcp"
|
||||
const durationSec = Math.max(3, Math.min(120, Number(body.bwDuration) || 10))
|
||||
const rows = await client.bandwidthTest({
|
||||
@@ -411,7 +423,7 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
|
||||
protocol,
|
||||
direction: "both",
|
||||
durationSec,
|
||||
})
|
||||
}, signal)
|
||||
output = fmtBandwidth(rows)
|
||||
break
|
||||
}
|
||||
@@ -432,13 +444,14 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
|
||||
case "mtu": {
|
||||
const target = String(body.target ?? "").trim()
|
||||
if (!target) return reply.status(400).send({ error: "target required" })
|
||||
output = await mtuDiscover(client, target, srcIpv4)
|
||||
output = await mtuDiscover(client, target, srcIpv4, signal)
|
||||
break
|
||||
}
|
||||
default:
|
||||
return reply.status(400).send({ error: `unknown tool: ${tool}` })
|
||||
}
|
||||
|
||||
signal.throwIfAborted()
|
||||
if (/^;;\s*ERROR:/im.test(output) || /^error:/i.test(output.trimStart())) return reply.status(502).send({ error: output })
|
||||
return reply.send({ output })
|
||||
} catch (err) {
|
||||
@@ -448,7 +461,11 @@ export function createProbeRoutes(deps: ProbeDependencies = {}): FastifyPluginAs
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
if (reply.raw.destroyed) return
|
||||
return reply.status(502).send({ error: msg })
|
||||
} finally {
|
||||
req.raw.off("aborted", onRequestAborted)
|
||||
reply.raw.off("close", onResponseClose)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -704,7 +704,7 @@ export class MikrotikClient {
|
||||
protocol?: "tcp" | "udp"
|
||||
direction?: "transmit" | "receive" | "both"
|
||||
durationSec?: number
|
||||
}): Promise<Array<Record<string, string>>> {
|
||||
}, signal?: AbortSignal): Promise<Array<Record<string, string>>> {
|
||||
const body: Record<string, string> = {
|
||||
address: params.address,
|
||||
user: params.user,
|
||||
@@ -713,7 +713,7 @@ export class MikrotikClient {
|
||||
direction: params.direction ?? "both",
|
||||
duration: `${Math.max(3, params.durationSec ?? 10)}s`,
|
||||
}
|
||||
return this.post<Array<Record<string, string>>>("/tool/bandwidth-test", body, 30_000)
|
||||
return this.post<Array<Record<string, string>>>("/tool/bandwidth-test", body, 30_000, signal)
|
||||
}
|
||||
|
||||
async getCertificates(): Promise<Array<Record<string, string | undefined>>> {
|
||||
|
||||
Reference in new issue
Block a user