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

This commit is contained in:
Denozordec committed 2026-10-01 03:43:35 +07:00
1 parent a2eae189be
commit ea4095d1f1
6 files changed
+124 -66

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: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",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
@@ -24,7 +25,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: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: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"
},
+16
View File
@@ -0,0 +1,16 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
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 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"})
const send=(token?:string)=>app.inject({method:"POST",url:"/api/servers/1/exec",payload:{command:"interface print"},headers:token?{authorization:"Bearer "+token}:{}})
assert.equal((await send()).statusCode,401);assert.equal(lookups,0)
for(const permission of ["mm:servers:read","mm:network:write","mm:servers:write","mm:servers:admin"]){
const token=await new SignJWT({sub:"test",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("terminal-test-secret"))
const before=lookups,response=await send(token);const allowed=permission==="mm:servers:write"||permission==="mm:servers:admin";assert.equal(response.statusCode,allowed?404:403);assert.equal(lookups,before+(allowed?1:0))
}
await app.close();console.log("Exec RBAC: signed JWT requires existing servers:write/admin before device lookup; no DB/RouterOS")
+31
View File
@@ -0,0 +1,31 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import {validatorCompiler,serializerCompiler} from "@fastify/type-provider-zod"
import {createExecRoutes,type ExecDependencies} from "./exec.js"
import type {servers} from "../db/schema.js"
const outputs:Record<string,unknown>={getIdentity:{name:"test"},getResource:{}}
const calls:unknown[][]=[];let exists=true,lookups=0
const names=["getIpAddresses","getInterfaces","getResource","getIdentity","getIpRoutes","getFirewallFilters","getBgpSessions","getOspfNeighbors","getOspfAreas","getOspfInstances","getOspfInterfaceTemplates","getBfdSessions","getLogs","ping"]
const client=Object.fromEntries(names.map(key=>[key,async(...args:unknown[])=>{calls.push([key,...args]);if(outputs[key] instanceof Error)throw outputs[key];return outputs[key]??[]}])) as unknown as ReturnType<ExecDependencies["createClient"]>
const app=Fastify();app.setValidatorCompiler(validatorCompiler);app.setSerializerCompiler(serializerCompiler)
await app.register(createExecRoutes({getServer:async()=>{lookups++;return exists?{id:1,name:"Тест",host:"192.0.2.1",enabled:true} as typeof servers.$inferSelect:undefined},createClient:()=>client}),{prefix:"/api"})
const send=(command:string)=>app.inject({method:"POST",url:"/api/servers/1/exec",payload:{command}})
for(const command of ["ip address print","interface print","system resource print","system identity print","ip route print","ip firewall filter print","routing bgp session print","routing ospf neighbor print","routing ospf area print","routing ospf instance print","routing ospf interface print","routing bfd session print","log print","/IP ADDRESS PRINT","help"]){
const response=await send(command);assert.equal(response.statusCode,200,command);assert.equal(typeof response.json().output,"string")
}
outputs.getBfdSessions=new Error("BFD недоступен");const failed=await send("routing bfd session print");assert.equal(failed.statusCode,502);assert.equal(failed.json().error,"BFD недоступен");assert.equal(failed.json().output,undefined)
outputs.getBfdSessions=[];assert.equal((await send("routing bfd session print")).statusCode,200)
outputs.getBfdSessions=[{".id":"*1"}];const bfd=(await send("routing bfd session print")).json().output;assert.ok(bfd.includes("?"));assert.ok(!bfd.includes("down"))
outputs.getBgpSessions=[{".id":"*1"}];assert.ok(!(await send("routing bgp session print")).json().output.includes("active"))
outputs.getOspfInterfaceTemplates=[{".id":"*1"}];const iface=(await send("routing ospf interface print")).json().output;assert.ok(!iface.includes("broadcast"));assert.ok(!iface.includes("no"))
outputs.getOspfAreas=[{name:"test"}];assert.ok(!(await send("routing ospf area print")).json().output.includes("0.0.0.0"))
outputs.getOspfInstances=[{name:"test"}];assert.match((await send("routing ospf instance print")).json().output,/test\s+\?\s+\?/)
outputs.getFirewallFilters=[{".id":"*1",chain:"input",action:"accept",packets:"0"}];assert.ok((await send("ip firewall filter print")).json().output.includes("packets=0 bytes=?"))
outputs.getResource={"free-memory":"junk"};assert.ok(!(await send("system resource print")).json().output.includes("NaN"))
outputs.ping=[{seq:"0",status:"timeout"},{sent:"0",received:"0"}];const ping=(await send("ping 192.0.2.2 count=2")).json().output;assert.ok(ping.includes("seq=0 timeout"));assert.ok(ping.includes("sent=0 received=0 packet-loss=?"));assert.deepEqual(calls.at(-1),["ping","192.0.2.2",2])
assert.equal((await send("ping 192.0.2.2 count=99")).statusCode,200);assert.deepEqual(calls.at(-1),["ping","192.0.2.2",20])
assert.equal((await send("log print limit=500")).statusCode,200);assert.deepEqual(calls.at(-1),["getLogs",200])
for(const command of ["system reboot","pingbad 192.0.2.2","log printable","ping","ping 192.0.2.2 count=0","ping 192.0.2.2 count=1junk","ping 192.0.2.2 count=1 count=2","log print limit=0","log print limit=3junk"]){const before=calls.length;assert.equal((await send(command)).statusCode,400,command);assert.equal(calls.length,before)}
exists=false;assert.equal((await send("interface print")).statusCode,404);exists=true
const before=lookups;for(const payload of [{},{command:""},{command:"x".repeat(1025)}])assert.equal((await app.inject({method:"POST",url:"/api/servers/1/exec",payload})).statusCode,400);assert.equal(lookups,before)
await app.close();console.log("Exec HTTP: readonly allowlist, errors/known empty, unknown versus defaults, bounded arguments and missing/bad requests; no DB/RouterOS")
+56 -64
View File
@@ -15,10 +15,10 @@ import type {
// ── string helpers ─────────────────────────────────────────────────────────────
function p(s: string | undefined, len: number): string {
return (s ?? "").padEnd(len)
return (s?.trim() || "?").padEnd(len)
}
function flag(val: string | undefined, char: string): string {
return val === "true" ? char : " "
return val === "true" ? char : val === "false" ? " " : "?"
}
// ── formatters ─────────────────────────────────────────────────────────────────
@@ -53,7 +53,7 @@ function fmtInterfaces(ifaces: RosInterface[]): string {
function fmtResource(r: RosResource, id: RosIdentity): string {
const kv = (k: string, v: string) => `${k.padStart(30)}: ${v}`
const mib = (b: string | undefined) => b ? `${(parseInt(b) / 1024 / 1024).toFixed(1)} MiB` : "?"
const mib = (b: string | undefined) => b && /^\d+$/.test(b) && Number.isSafeInteger(Number(b)) ? `${(parseInt(b) / 1024 / 1024).toFixed(1)} MiB` : "?"
return [
kv("uptime", r["uptime"] ?? "?"),
kv("version", r["version"] ?? "?"),
@@ -68,8 +68,8 @@ function fmtResource(r: RosResource, id: RosIdentity): string {
kv("total-hdd-space", mib(r["total-hdd-space"])),
kv("architecture-name",r["architecture-name"] ?? "?"),
kv("board-name", r["board-name"] ?? "?"),
kv("platform", r["platform"] ?? "MikroTik"),
kv("identity", id.name),
kv("platform", r["platform"] ?? "?"),
kv("identity", id.name ?? "?"),
].join("\n")
}
@@ -111,7 +111,7 @@ function fmtBgpSessions(sessions: RosBgpSession[]): string {
const name = p(s["name"] ?? s[".id"], 23)
const remAs = p(s["remote.as"], 10)
const remIp = p((s["remote.address"] ?? "").split("/")[0], 22)
const state = est ? "established" : (s["state"] ?? "active")
const state = est ? "established" : (s["state"] ?? "?")
const up = s["uptime"] ?? "—"
return ` ${String(i).padStart(2)} ${fl} ${name} ${remAs} ${remIp} ${p(state, 12)} ${up}`
})
@@ -124,7 +124,7 @@ function fmtOspfNeighbors(neighbors: RosOspfNeighbor[]): string {
" # ROUTER-ID STATE CHANGES ADJACENCY INTERFACE",
]
const rows = neighbors.map((n, i) => {
return ` ${String(i).padStart(2)} ${p(n["router-id"],16)} ${p(n["state"],8)} ${p(n["state-changes"] ?? "0",8)} ${p(n["adjacency"] ?? "—",15)} ${n["interface"] ?? ""}`
return ` ${String(i).padStart(2)} ${p(n["router-id"],16)} ${p(n["state"],8)} ${p(n["state-changes"] ?? "?",8)} ${p(n["adjacency"] ?? "—",15)} ${n["interface"] ?? ""}`
})
return [...hdr, ...rows].join("\n")
}
@@ -132,7 +132,7 @@ function fmtOspfNeighbors(neighbors: RosOspfNeighbor[]): string {
function fmtOspfAreas(areas: RosOspfArea[]): string {
const hdr = [" # NAME AREA-ID TYPE INSTANCE"]
const rows = areas.map((a, i) =>
` ${String(i).padStart(2)} ${p(a.name,20)} ${p(a["area-id"] ?? "0.0.0.0",15)} ${p(a.type ?? "default",12)} ${a.instance}`
` ${String(i).padStart(2)} ${p(a.name,20)} ${p(a["area-id"] ?? "?",15)} ${p(a.type ?? "?",12)} ${a.instance}`
)
return [...hdr, ...rows].join("\n")
}
@@ -140,7 +140,7 @@ function fmtOspfAreas(areas: RosOspfArea[]): string {
function fmtOspfInstances(insts: RosOspfInstance[]): string {
const hdr = [" # NAME ROUTER-ID VER DISTRIBUTE"]
const rows = insts.map((inst, i) =>
` ${String(i).padStart(2)} ${p(inst.name,15)} ${p(inst["router-id"],18)} ${p(inst.version ?? "2",4)} ${inst["redistribute"] ?? "—"}`
` ${String(i).padStart(2)} ${p(inst.name,15)} ${p(inst["router-id"],18)} ${p(inst.version ?? "?",4)} ${inst["redistribute"] ?? "—"}`
)
return [...hdr, ...rows].join("\n")
}
@@ -148,7 +148,7 @@ function fmtOspfInstances(insts: RosOspfInstance[]): string {
function fmtOspfIfaceTemplates(templates: RosOspfInterfaceTemplate[]): string {
const hdr = [" # INTERFACES AREA COST TYPE BFD"]
const rows = templates.map((t, i) =>
` ${String(i).padStart(2)} ${p(t.interfaces ?? "—",21)} ${p(t.area,19)} ${p(t.cost ?? "10",5)} ${p(t.type ?? "broadcast",10)} ${t["use-bfd"] === "true" ? "yes" : "no"}`
` ${String(i).padStart(2)} ${p(t.interfaces ?? "—",21)} ${p(t.area,19)} ${p(t.cost ?? "?",5)} ${p(t.type ?? "?",10)} ${t["use-bfd"] === "true" ? "yes" : t["use-bfd"] === "false" ? "no" : "?"}`
)
return [...hdr, ...rows].join("\n")
}
@@ -156,7 +156,7 @@ function fmtOspfIfaceTemplates(templates: RosOspfInterfaceTemplate[]): string {
function fmtBfdSessions(sessions: RosBfdSession[]): string {
const hdr = [" # LOCAL-ADDRESS REMOTE-ADDRESS STATE UPTIME"]
const rows = sessions.map((s, i) =>
` ${String(i).padStart(2)} ${p(s["local-address"],27)} ${p(s["remote-address"],27)} ${p(s["state"] ?? "down",8)} ${s["uptime"] ?? "—"}`
` ${String(i).padStart(2)} ${p(s["local-address"],27)} ${p(s["remote-address"],27)} ${p(s["state"] ?? "?",8)} ${s["uptime"] ?? "—"}`
)
return [...hdr, ...rows].join("\n")
}
@@ -181,7 +181,7 @@ function fmtFirewallFilter(rules: RosFirewallFilter[]): string {
if (r["out-interface"]) parts.push(`out-interface=${r["out-interface"]}`)
if (r["tls-host"]) parts.push(`tls-host=${r["tls-host"]}`)
lines.push(` ${parts.join(" ")}`)
if (r.packets || r.bytes) lines.push(` packets=${r.packets ?? "0"} bytes=${r.bytes ?? "0"}`)
if (r.packets != null || r.bytes != null) lines.push(` packets=${r.packets ?? "?"} bytes=${r.bytes ?? "?"}`)
})
return lines.join("\n")
}
@@ -197,18 +197,17 @@ function fmtPing(results: RosPingResult[], host: string): string {
const lines = [`PING ${host}`]
// individual replies
for (const r of results) {
if (!r.time && !r.ttl) continue // skip summary-only items
if (r.status === "timeout") {
lines.push(` seq=${r.seq} timeout`)
} else {
lines.push(` seq=${r.seq} ttl=${r.ttl ?? "?"} time=${r.time ?? "?"}`)
lines.push(` seq=${r.seq??"?"} timeout`)
} else if(r.time!=null||r.ttl!=null) {
lines.push(` seq=${r.seq??"?"} ttl=${r.ttl ?? "?"} time=${r.time ?? "?"}`)
}
}
// summary — last item with sent/received fields
const sum = [...results].reverse().find(r => r.sent)
const sum = [...results].reverse().find(r => r.sent != null)
if (sum) {
const loss = sum["packet-loss"] ?? "0%"
lines.push(` sent=${sum.sent} received=${sum.received} packet-loss=${loss}`)
const loss = sum["packet-loss"] ?? "?"
lines.push(` sent=${sum.sent} received=${sum.received??"?"} packet-loss=${loss}`)
if (sum.time) lines.push(` avg-rtt=${sum.time}`)
}
return lines.join("\n")
@@ -236,7 +235,10 @@ const HELP_TEXT = [
// ── command dispatcher ─────────────────────────────────────────────────────────
async function dispatch(client: MikrotikClient, raw: string): Promise<string> {
type ExecClient=Pick<MikrotikClient,"getIpAddresses"|"getInterfaces"|"getResource"|"getIdentity"|"getIpRoutes"|"getFirewallFilters"|"getBgpSessions"|"getOspfNeighbors"|"getOspfAreas"|"getOspfInstances"|"getOspfInterfaceTemplates"|"getBfdSessions"|"getLogs"|"ping">
class ExecValidationError extends Error { readonly statusCode=400 }
async function dispatch(client: ExecClient, raw: string): Promise<string> {
// strip leading slashes, normalize whitespace
const cmd = raw.trim().replace(/^\/+/, "")
const lower = cmd.toLowerCase().replace(/\s+/g, " ")
@@ -279,26 +281,25 @@ async function dispatch(client: MikrotikClient, raw: string): Promise<string> {
return fmtOspfIfaceTemplates(await client.getOspfInterfaceTemplates())
if (lower === "routing bfd session print" || lower === "bfd session print")
return fmtBfdSessions(await client.getBfdSessions().catch(() => []))
return fmtBfdSessions(await client.getBfdSessions())
if (lower.startsWith("log print")) {
const limitArg = lower.match(/limit=(\d+)/)
const limit = limitArg ? parseInt(limitArg[1]) : 50
return fmtLogs(await client.getLogs(Math.min(limit, 200)))
if (/^log print(?:\s|$)/.test(lower)) {
const match=lower.match(/^log print(?: limit=(\d+))?$/)
if(!match)throw new ExecValidationError("Используйте log print [limit=N].")
const limit=match[1]?Number(match[1]):50
if(!Number.isSafeInteger(limit)||limit<1)throw new ExecValidationError("limit должен быть положительным целым числом.")
return fmtLogs(await client.getLogs(Math.min(limit,200)))
}
if (lower.startsWith("ping")) {
const parts = cmd.split(/\s+/)
const host = parts[1]
if (!host) return "Usage: ping <address> [count=N]"
const countArg = parts.find(p => p.toLowerCase().startsWith("count="))
const count = countArg ? parseInt(countArg.split("=")[1]) : 4
const results = await client.ping(host, Math.min(count, 20))
return fmtPing(results, host)
if(lower.split(" ")[0]==="ping") {
const parts=cmd.replace(/\s+/g," ").split(" ")
if(parts.length<2||parts.length>3)throw new ExecValidationError("Используйте ping <host> [count=N].")
const match=parts[2]?.match(/^count=(\d+)$/i)
if(parts[2]&&!match)throw new ExecValidationError("count должен быть положительным целым числом.")
const count=match?Number(match[1]):4
if(!Number.isSafeInteger(count)||count<1)throw new ExecValidationError("count должен быть положительным целым числом.")
return fmtPing(await client.ping(parts[1],Math.min(count,20)),parts[1])
}
const firstToken = cmd.split(" ")[0]
return `bad command name ${firstToken} (line 1 column 1)`
throw new ExecValidationError("Команда не поддерживается REST терминалом. Введите help для списка.")
}
// ── route plugin ───────────────────────────────────────────────────────────────
@@ -307,31 +308,22 @@ const ExecBodySchema = z.object({
command: z.string().min(1).max(1024),
})
const execRoutes: FastifyPluginAsyncZod = async (app) => {
// POST /api/servers/:id/exec — execute a RouterOS CLI-style command via REST API
app.post(
"/servers/:id/exec",
{ schema: { params: ServerIdParamSchema, body: ExecBodySchema } },
async (req, reply) => {
const params = req.params as ServerIdParams
const server = (await db
.select().from(servers)
.where(eq(servers.id, params.id))
.limit(1))[0]
if (!server) return reply.status(404).send({ error: "Server not found" })
const client = MikrotikClient.fromServer(server)
try {
const output = await dispatch(client, req.body.command)
return reply.send({ output })
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
// Return as output (not HTTP error) so the terminal can display it
return reply.send({ output: `error: ${msg}` })
}
}
)
type ExecServer=typeof servers.$inferSelect
export interface ExecDependencies {getServer:(id:number)=>Promise<ExecServer|undefined>;createClient:(server:ExecServer)=>ExecClient}
const defaults:ExecDependencies={getServer:async(id)=>(await db.select().from(servers).where(eq(servers.id,id)).limit(1))[0],createClient:server=>MikrotikClient.fromServer(server)}
export function createExecRoutes(dependencies:ExecDependencies=defaults):FastifyPluginAsyncZod {
return async app=>{
app.post("/servers/:id/exec",{schema:{params:ServerIdParamSchema,body:ExecBodySchema}},async(req,reply)=>{
const server=await dependencies.getServer((req.params as ServerIdParams).id)
if(!server)return reply.status(404).send({error:"Устройство не найдено"})
try {
const output=await dispatch(dependencies.createClient(server),req.body.command)
return reply.send({output})
} catch(err) {
const error=err instanceof Error?err.message:"Не удалось получить вывод команды."
return reply.status(err instanceof ExecValidationError?400:502).send({error})
}
})
}
}
export default execRoutes
export default createExecRoutes()