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()
|
||||
@@ -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 приложения не запрашивает этот источник.
|
||||
|
||||
@@ -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 ещё не завершён.
|
||||
|
||||
Reference in new issue
Block a user