fix(api): сохранять достоверные параметры рекурсивных маршрутов

This commit is contained in:
Denozordec committed 2026-09-30 22:08:22 +07:00
1 parent 50fad3ac80
commit 54ef752a5b
17 files changed
+198 -45

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts",
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts",
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts",
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
"test:backups": "tsx src/services/s3-backup-client.test.ts",
@@ -5,12 +5,16 @@ import { createRecursiveRoutes, type RecursiveRouteDependencies } from "./recurs
const row = { id: "one", dstAddress: "10.0.0.0/24", gateway: "192.0.2.1", distance: 0, scope: 0, targetScope: 0, routingTable: "main", checkGateway: "ping", country: "", comment: "Маршрут", disabled: false }
const server = { id: 1, name: "Тест" } as NonNullable<Awaited<ReturnType<RecursiveRouteDependencies["getServer"]>>>
let lookup = 0, writes = 0, revisions = 0, fail = false, stored = [row], payload: unknown = [row], revisionServer = 1, section = "recursive-routes"
const observed = { ".id": "*1", static: "true", "dst-address": row.dstAddress, gateway: row.gateway, distance: "0", scope: "0", "target-scope": "0", "routing-table": "main", "check-gateway": "ping", disabled: "false", comment: "MikrotikManager:recursive Тест" }
let rawPayload: unknown = [observed], rawFailure = false, savedSnapshots = 0
const applied: Array<{ rows: typeof row[]; source: string }> = []
const app = Fastify()
app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
await app.register(createRecursiveRoutes({
getServer: async id => { lookup++; return id === 1 ? server : undefined },
getRoutes: async () => stored,
readRaw: async () => { if (rawFailure) throw new Error("Тест опроса"); return rawPayload },
saveObserved: async (_id, rows) => { savedSnapshots++; stored = rows as typeof row[] },
apply: async (device, rows, source) => {
assert.equal(device.id, 1); writes++; applied.push({ rows: rows as typeof row[], source })
if (fail) throw new Error("Тест частичного применения")
@@ -65,6 +69,20 @@ assert.equal(restored.json().routes[0].distance, 0)
payload = []
const restoredEmpty = await app.inject({ method: "POST", url: "/api/recursive-routes/revisions/rev/restore", payload: { serverId: 1 } })
assert.deepEqual(restoredEmpty.json(), { ok: true, serverId: 1, routes: [], pushedRoutes: 0 })
const catalog=await app.inject({ method: "GET", url: "/api/recursive-routes/catalog?serverId=1" })
assert.equal(catalog.statusCode,200); assert.equal(catalog.json().routes[0].distance,0); assert.equal(catalog.json().serverId,1)
const savedBefore=savedSnapshots
for(const bad of [[{...observed,distance:undefined}], {}, [{...observed,blackhole:"true"}]]) {
rawPayload=bad
const result=await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})
assert.equal(result.statusCode,503);assert.equal(result.json().routes,undefined);assert.equal(savedSnapshots,savedBefore)
}
rawFailure=true
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})).statusCode,503)
rawFailure=false;rawPayload=[]
assert.deepEqual((await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})).json(),{serverId:1,routes:[],live:true,stale:false})
rawPayload=[{".id":"one",gateway:"192.0.2.1"}]
assert.equal((await app.inject({method:"GET",url:"/api/recursive-routes/gateways/catalog?serverId=1"})).json().gateways[0].status,"unknown")
await app.close()
console.log("recursive routes HTTP: invalid/explicit-empty/zero/copy/error/restore ownership and payload passed without DB/RouterOS")
+33
View File
@@ -1,4 +1,5 @@
import { recursiveApplyBodySchema, recursiveTargetSchema, recursiveInputRowsSchema } from '../services/recursive-route-validation.js'
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog } from "../services/recursive-route-catalog.js"
import { asc, eq } from "drizzle-orm"
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
@@ -175,6 +176,8 @@ const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
export interface RecursiveRouteDependencies {
getServer: (id: number) => Promise<ServerRow | undefined>
readRaw: (server: ServerRow) => Promise<unknown>
saveObserved: (serverId: number, routes: RecursiveRouteDto[]) => Promise<void>
getRoutes: typeof mapDbRoutes
apply: typeof applyRecursiveToServer
getRevision: typeof getRevisionById
@@ -184,10 +187,40 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
const deps: RecursiveRouteDependencies = {
getServer: async id => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0],
getRoutes: mapDbRoutes, apply: applyRecursiveToServer, getRevision: getRevisionById,
readRaw: server => MikrotikClient.fromServer(server).get<unknown>("/ip/route"),
saveObserved: async (serverId, routes) => {
await replaceDbRoutes(serverId, routes)
await appendRevisionIfChanged({ serverId, section: "recursive-routes", source: "observed", payload: canonicalRecursiveRoutes(routes) })
},
...overrides,
}
return async (app) => {
app.get("/recursive-routes/catalog", async (req, reply) => {
const parsed = recursiveTargetSchema.safeParse(req.query)
if (!parsed.success) return reply.status(400).send({ error: "serverId is required" })
const serverId = parsed.data.serverId, server = await deps.getServer(serverId)
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
const routes = mergeCachedCountry(parseRecursiveCatalog(await deps.readRaw(server)), await deps.getRoutes(serverId))
await deps.saveObserved(serverId, routes)
return reply.send({ serverId, routes, live: true, stale: false })
} catch (error) {
return reply.status(503).send({ error: error instanceof Error ? error.message : "Не удалось прочитать каталог маршрутов" })
}
})
app.get("/recursive-routes/gateways/catalog", async (req, reply) => {
const parsed = recursiveTargetSchema.safeParse(req.query)
if (!parsed.success) return reply.status(400).send({ error: "serverId is required" })
const serverId = parsed.data.serverId, server = await deps.getServer(serverId)
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server)) })
} catch (error) {
return reply.status(503).send({ error: error instanceof Error ? error.message : "Не удалось прочитать шлюзы" })
}
})
app.get("/recursive-routes/gateways", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
@@ -0,0 +1,20 @@
import assert from "node:assert/strict"
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog } from "./recursive-route-catalog.js"
const row={".id":"*1",static:"true","dst-address":"10.0.0.0/24",gateway:"192.0.2.1%ether1",distance:"0",scope:"0","target-scope":"0","routing-table":"main","check-gateway":"none",disabled:"false",comment:"MikrotikManager:recursive Тест"}
const known=parseRecursiveCatalog([row])[0]
assert.equal(known.distance,0);assert.equal(known.scope,0);assert.equal(known.targetScope,0);assert.equal(known.disabled,false);assert.equal(known.checkGateway,"none")
const unset=parseRecursiveCatalog([{...row,scope:undefined,"target-scope":undefined}])[0]
assert.equal(unset.scope,null);assert.equal(unset.targetScope,null)
for(const field of ["distance","disabled","routing-table","check-gateway","static"]) assert.throws(()=>parseRecursiveCatalog([{...row,[field]:undefined}]),field)
for(const change of [{distance:"1x"},{distance:"-1"},{distance:"256"},{scope:"1x"},{"target-scope":"256"},{disabled:"unknown"},{"check-gateway":"unknown"},{gateway:"999.0.0.1"},{"dst-address":"10.0.0.0/33"},{dynamic:"true"},{blackhole:"true"}])assert.throws(()=>parseRecursiveCatalog([{...row,...change}]),JSON.stringify(change))
for(const bad of [{},[null],[{...row,".id":""}],[row,row],[{...row,comment:true}]]) assert.throws(()=>parseRecursiveCatalog(bad))
assert.deepEqual(parseRecursiveCatalog([]),[])
assert.deepEqual(parseRecursiveCatalog([{".id":"*other",comment:"user-owned",static:"false"}]),[])
assert.equal(parseRecursiveCatalog([{...row,disabled:"true"}])[0].disabled,true)
const gateway=(id:string,active?:string)=>({".id":id,gateway:"192.0.2.1%ether1",active})
assert.equal(parseRecursiveGatewayCatalog([gateway("a")])[0].status,"unknown")
assert.equal(parseRecursiveGatewayCatalog([gateway("a","false")])[0].status,"inactive")
assert.equal(parseRecursiveGatewayCatalog([gateway("a","false"),gateway("b")])[0].status,"unknown")
assert.equal(parseRecursiveGatewayCatalog([gateway("a"),gateway("b","true")])[0].status,"active")
assert.deepEqual(parseRecursiveGatewayCatalog([{".id":"a",gateway:"999.0.0.1"}]),[])
console.log("recursive catalog: observed zero/unset/disabled, complete ownership, malformed/refused defaults and gateway usage passed")
@@ -0,0 +1,46 @@
import { isIP } from "node:net"
import { observedInteger, observedBoolean } from "./ospf-observed-values.js"
import { isManagedRecursiveRoute, userRecursiveComment, type MappedRecursiveRoute } from "./config-apply-plan.js"
import { isOwnedRecursiveComment } from "../managed-markers.js"
function rawRows(payload: unknown): Array<Record<string, unknown>> {
if (!Array.isArray(payload) || payload.some(row => row === null || typeof row !== "object" || Array.isArray(row))) throw new Error("Некорректный ответ маршрутов RouterOS")
const rows = payload as Array<Record<string, unknown>>
if (rows.some(row => row.comment !== undefined && typeof row.comment !== "string")) throw new Error("Некорректные метки владения маршрутов")
const ids = rows.map(row => row[".id"])
if (ids.some(id => typeof id !== "string" || !id.trim()) || new Set(ids).size !== ids.length) throw new Error("Неоднозначные ID маршрутов RouterOS")
return rows
}
function rosText(value: unknown): string | undefined { return typeof value === "string" ? value : undefined }
function text(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null }
export function parseRecursiveCatalog(payload: unknown): MappedRecursiveRoute[] {
return rawRows(payload).filter(row => typeof row.comment === "string" && isOwnedRecursiveComment(row.comment)).map(row => {
const id = String(row[".id"])
// The editor replaces the entire managed set. Never silently omit an owned row.
const destination = text(row["dst-address"]), gateway = text(row.gateway)
const [ip, prefix] = destination?.split("/") ?? []
const gatewayIp = gateway?.split("%")[0]
if (!destination || !gateway || isIP(ip ?? "") !== 4 || prefix !== undefined && (!/^\d+$/.test(prefix) || Number(prefix) > 32) || isIP(gatewayIp ?? "") !== 4 || !isManagedRecursiveRoute(row) || observedBoolean(rosText(row.static)) !== true) throw new Error("Управляемый маршрут " + id + " не подтверждён как поддерживаемый статический маршрут")
const distance = observedInteger(rosText(row.distance), 0, 255), disabled = observedBoolean(rosText(row.disabled))
const routingTable = text(row["routing-table"]), checkGateway = text(row["check-gateway"])
if (distance === null || disabled === null || routingTable === null || !checkGateway || !["none", "arp", "ping", "bfd"].includes(checkGateway)) throw new Error("Не подтверждены параметры управляемого маршрута " + id)
const scope = row.scope === undefined || row.scope === "" ? null : observedInteger(rosText(row.scope), 0, 255)
const targetScope = row["target-scope"] === undefined || row["target-scope"] === "" ? null : observedInteger(rosText(row["target-scope"]), 0, 255)
if (row.scope !== undefined && row.scope !== "" && scope === null || row["target-scope"] !== undefined && row["target-scope"] !== "" && targetScope === null) throw new Error("Некорректный scope управляемого маршрута " + id)
return { id, dstAddress: String(row["dst-address"]), gateway: String(row.gateway), distance, disabled, routingTable, checkGateway, scope, targetScope, country: "", comment: userRecursiveComment(String(row.comment)) }
})
}
export type RecursiveGatewayObservation = { id: string; ip: string; name: string; status: "active" | "inactive" | "unknown" }
export function parseRecursiveGatewayCatalog(payload: unknown): RecursiveGatewayObservation[] {
const result = new Map<string, RecursiveGatewayObservation>()
for (const row of rawRows(payload)) {
if (typeof row.gateway !== "string") continue
const [ip, name = ip] = row.gateway.trim().split("%")
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(ip) || ip.split(".").some(part => Number(part) > 255) || !name) continue
const id = ip + "%" + name, active = observedBoolean(rosText(row.active))
const status = active === true ? "active" : active === false ? "inactive" : "unknown"
const previous = result.get(id)
result.set(id, { id, ip, name, status: previous?.status === "active" || status === "active" ? "active" : previous?.status === "unknown" || status === "unknown" ? "unknown" : "inactive" })
}
return [...result.values()]
}