fix(api): сохранять достоверные параметры рекурсивных маршрутов
This commit is contained in:
1 parent
50fad3ac80
commit
54ef752a5b
17 files changed
+198
-45
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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()]
|
||||
}
|
||||
Reference in new issue
Block a user