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

This commit is contained in:
Denozordec committed 2026-10-09 21:08:10 +07:00
1 parent 36c6a21ca5
commit 5b3133ce73
19 files changed
+659 -28

No files matched your search

+1 -1
View File
@@ -23,7 +23,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 && tsx src/modules/users/interface-inventory.test.ts && tsx src/routes/users-catalog.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 && tsx src/services/recursive-route-catalog.test.ts",
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-apply.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:scheduler": "tsx src/services/scheduler-running.test.ts && tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts",
+23 -3
View File
@@ -2,8 +2,10 @@ import assert from "node:assert/strict"
import Fastify from "fastify"
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
import { createRecursiveRoutes, type RecursiveRouteDependencies } from "./recursive-routes.js"
import { RecursiveApplyError } from "../services/recursive-apply.js"
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"]>>>
const server = { id: 1, name: "Тест", enabled: true, type: "home-router" } as NonNullable<Awaited<ReturnType<RecursiveRouteDependencies["getServer"]>>>
let rawReads = 0, lateReadFailure = false, applyError: Error | undefined
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
@@ -12,12 +14,13 @@ 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 },
getRoutes: async () => { if (lateReadFailure) throw Error("Сбой чтения после применения"); return stored },
readRaw: async () => { rawReads++; 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("Тест частичного применения")
if (applyError) throw applyError
stored = rows as typeof row[]; return { pushed: rows.length, deleted: 0 }
},
getRevision: async () => {
@@ -32,6 +35,7 @@ const invalid = [
{ serverId: 1, routes: [{ ...row, distance: -1 }] }, { serverId: 1, routes: [{ ...row, scope: 256 }] },
{ serverId: 1, routes: [{ ...row, targetScope: 1.5 }] }, { serverId: 1, routes: [{ ...row, gateway: "" }] },
{ serverId: 1, routes: [row, row] }, { serverId: 1, routes: [row], source: "rollback" },
...[{...row,dstAddress:"999.1.1.1/24"},{...row,dstAddress:"10.0.0.0/33"},{...row,gateway:"ether1"},{...row,gateway:"192.0.2.1%"},{...row,checkGateway:"anything"}].map(route=>({serverId:1,routes:[route]})),
]
for (const body of invalid) {
const result = await app.inject({ method: "PUT", url: "/api/recursive-routes", payload: body })
@@ -83,6 +87,22 @@ 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")
for (const type of ["home-router", "exit-node"] as const) {
server.enabled=false;server.type=type
const counts={writes,rawReads,savedSnapshots}
for(const url of ["/recursive-routes/catalog?serverId=1","/recursive-routes/gateways/catalog?serverId=1","/recursive-routes?serverId=1","/recursive-routes/gateways?serverId=1"])assert.equal((await app.inject({method:"GET",url:"/api"+url})).statusCode,409)
assert.equal((await app.inject({method:"PUT",url:"/api/recursive-routes",payload:{serverId:1,routes:[]}})).statusCode,409)
assert.equal((await app.inject({method:"POST",url:"/api/recursive-routes/revisions/rev/restore",payload:{serverId:1}})).statusCode,409)
assert.deepEqual({writes,rawReads,savedSnapshots},counts)
}
server.enabled=true
for(const state of ["partial","unknown","applied"] as const){
applyError=new RecursiveApplyError("Сбой операции",{state,confirmedWrites:1,createdIds:["*1"],deletedIds:[],needsReview:true,metadataRecorded:false})
for(const restore of [false,true]){const r=await app.inject({method:restore?"POST":"PUT",url:restore?"/api/recursive-routes/revisions/rev/restore":"/api/recursive-routes",payload:restore?{serverId:1}:{serverId:1,routes:[]}});assert.equal(r.statusCode,502);assert.equal(r.json().state,state);assert.equal(r.json().confirmedWrites,1);assert.equal(r.json().ok,undefined)}
}
applyError=undefined;lateReadFailure=true
const late=await app.inject({method:"PUT",url:"/api/recursive-routes",payload:{serverId:1,routes:[row]}});assert.equal(late.statusCode,502);assert.equal(late.json().state,"applied");assert.equal(late.json().metadataRecorded,true)
lateReadFailure=false
await app.close()
console.log("recursive routes HTTP: invalid/explicit-empty/zero/copy/error/restore ownership and payload passed without DB/RouterOS")
+28 -18
View File
@@ -1,5 +1,6 @@
import { recursiveApplyBodySchema, recursiveTargetSchema, recursiveInputRowsSchema } from '../services/recursive-route-validation.js'
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog } from "../services/recursive-route-catalog.js"
import { executeRecursiveApply, RecursiveApplyError } from "../services/recursive-apply.js"
import { asc, eq } from "drizzle-orm"
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
@@ -10,7 +11,6 @@ import { parseDbServerId } from "../utils/server-id.js"
import { managedRecursiveComment } from "../managed-markers.js"
import {
mapRosManagedRoutes,
planRecursiveApply,
userRecursiveComment,
} from "../services/config-apply-plan.js"
import {
@@ -123,10 +123,11 @@ function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
}
async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
await db.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId))
await db.transaction(async tx => {
await tx.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId))
if (routes.length === 0) return
const now = new Date().toISOString()
await db.insert(recursiveRoutes).values(
await tx.insert(recursiveRoutes).values(
routes.map((r, i) => ({
serverId,
sortOrder: i,
@@ -144,6 +145,7 @@ async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
updatedAt: now,
})),
)
})
}
async function applyRecursiveToServer(
@@ -152,16 +154,7 @@ async function applyRecursiveToServer(
source: ConfigRevisionSource,
): Promise<{ pushed: number; deleted: number }> {
const client = MikrotikClient.fromServer(server)
const existing = await client.get<RosRoute[]>("/ip/route")
const { deleteIds } = planRecursiveApply(existing)
for (const route of routes) {
await client.post("/ip/route", toRouterPayload(route))
}
for (const id of deleteIds) {
await client.delete(`/ip/route/${encodeURIComponent(id)}`)
}
return executeRecursiveApply(client, routes.map(toRouterPayload), async () => {
await replaceDbRoutes(server.id, routes)
await appendRevisionIfChanged({
serverId: server.id,
@@ -169,11 +162,18 @@ async function applyRecursiveToServer(
source,
payload: canonicalRecursiveRoutes(routes),
})
return { pushed: routes.length, deleted: deleteIds.length }
})
}
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
function recursiveFailureReceipt(error: unknown, result?: { pushed: number; deleted: number }) {
if (error instanceof RecursiveApplyError) return error.receipt
// Native operations and metadata completed; a later cache read can still fail.
if (result) return { state: "applied", confirmedWrites: result.pushed + result.deleted, needsReview: true, metadataRecorded: true }
return undefined
}
export interface RecursiveRouteDependencies {
getServer: (id: number) => Promise<ServerRow | undefined>
readRaw: (server: ServerRow) => Promise<unknown>
@@ -201,6 +201,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
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" })
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
try {
const routes = mergeCachedCountry(parseRecursiveCatalog(await deps.readRaw(server)), await deps.getRoutes(serverId))
await deps.saveObserved(serverId, routes)
@@ -214,6 +215,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
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" })
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
try {
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server)) })
} catch (error) {
@@ -227,6 +229,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = await deps.getServer(serverId)
if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
try {
const client = MikrotikClient.fromServer(server)
@@ -257,6 +260,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = await deps.getServer(serverId)
if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
const cached = await deps.getRoutes(serverId)
try {
@@ -290,14 +294,17 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
const body = parsed.data, serverId = body.serverId
const server = await deps.getServer(serverId)
if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
const routes = body.routes.map(r => ({ ...r, comment: userRecursiveComment(r.comment) }))
const source: ConfigRevisionSource = body.source === "copy" ? "copy" : "apply"
let result: { pushed: number; deleted: number } | undefined
try {
const result = await deps.apply(server, routes, source)
result = await deps.apply(server, routes, source)
const stored = await deps.getRoutes(serverId)
return reply.send({ ok: true, serverId, routes: stored, pushedRoutes: result.pushed })
} catch (err) {
return reply.status(500).send({ error: String(err) })
const receipt = recursiveFailureReceipt(err, result)
return reply.status(receipt ? 502 : 500).send({ error: String(err), serverId, ...receipt })
}
})
@@ -323,15 +330,18 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
if (!parsedRoutes.success) return reply.status(409).send({ error: "Версия содержит некорректные маршруты; восстановление не выполнено" })
const server = await deps.getServer(rev.serverId)
if (!server) return reply.status(404).send({ error: "Server not found" })
if (!server.enabled) return reply.status(409).send({ error: "Устройство отключено: опрос и применение маршрутов недоступны" })
const routes: RecursiveRouteDto[] = parsedRoutes.data.map((row, index) => ({
...row, id: `rev-${index}`, comment: userRecursiveComment(row.comment),
}))
let result: { pushed: number; deleted: number } | undefined
try {
const result = await deps.apply(server, routes, "rollback")
result = await deps.apply(server, routes, "rollback")
const stored = await deps.getRoutes(server.id)
return reply.send({ ok: true, serverId: server.id, routes: stored, pushedRoutes: result.pushed })
} catch (err) {
return reply.status(500).send({ error: String(err) })
const receipt = recursiveFailureReceipt(err, result)
return reply.status(receipt ? 502 : 500).send({ error: String(err), serverId: server.id, ...receipt })
}
})
}
@@ -0,0 +1,26 @@
import assert from "node:assert/strict"
import type { MikrotikClient } from "./mikrotik.js"
import { MikrotikError } from "./mikrotik.js"
import { executeRecursiveApply, RecursiveApplyError } from "./recursive-apply.js"
const owned = { ".id": "*old", static: "true", "dst-address": "198.18.88.0/24", gateway: "192.0.2.1", distance: "1", scope: "30", "target-scope": "10", "routing-table": "main", "check-gateway": "none", disabled: "false", comment: "MikrotikManager:recursive test" }
let rows: unknown = [owned, { ".id": "*foreign", comment: "Keep default route" }], events: string[] = [], puts = 0
let failAt = 0, rejection: Error = new Error("ACK потерян"), badAck = false, failDelete = false, failPersist = false
const client: Pick<MikrotikClient,"get"|"put"|"delete"> = {
get: async <T>() => rows as T,
put: async <T>() => { puts++; events.push("put:"+puts); if(puts===failAt)throw rejection; return (badAck?{}:{".id":"*new"+puts}) as T },
delete: async path => { events.push("delete:"+path); if(failDelete)throw new MikrotikError(403,path,"rejected") },
}
const persist=async()=>{events.push("persist");if(failPersist)throw Error("История недоступна")}
const reset=()=>{events=[];puts=0;failAt=0;badAck=false;failDelete=false;failPersist=false;rows=[owned,{".id":"*foreign",comment:"Keep default route"}]}
const payloads=[{"dst-address":"198.18.88.1/32"},{"dst-address":"198.18.88.2/32"}]
reset();assert.deepEqual(await executeRecursiveApply(client,payloads,persist),{pushed:2,deleted:1});assert.deepEqual(events,["put:1","put:2","delete:/ip/route/*old","persist"])
for(const invalid of [{},[owned,owned],[{...owned,static:undefined}],[{...owned,blackhole:"true"}]]){reset();rows=invalid;await assert.rejects(executeRecursiveApply(client,payloads,persist));assert.deepEqual(events,[])}
async function expectState(state:string,confirmed:number){await assert.rejects(executeRecursiveApply(client,payloads,persist),(e:unknown)=>{assert(e instanceof RecursiveApplyError);assert.equal(e.receipt.state,state);assert.equal(e.receipt.confirmedWrites,confirmed);assert.equal(e.receipt.metadataRecorded,false);return true})}
reset();failAt=1;rejection=new MikrotikError(401,"/ip/route","denied");await expectState("rejected",0);assert.deepEqual(events,["put:1"])
reset();failAt=2;rejection=new MikrotikError(403,"/ip/route","denied");await expectState("partial",1);assert.deepEqual(events,["put:1","put:2"])
reset();failAt=2;rejection=new Error("ACK второго PUT потерян");await expectState("unknown",1);assert.deepEqual(events,["put:1","put:2"])
reset();badAck=true;await expectState("unknown",0);assert.deepEqual(events,["put:1"])
reset();failDelete=true;await expectState("partial",2);assert.deepEqual(events,["put:1","put:2","delete:/ip/route/*old"])
reset();failPersist=true;await expectState("applied",3)
reset();rows=[];assert.deepEqual(await executeRecursiveApply(client,[],persist),{pushed:0,deleted:0});assert.deepEqual(events,["persist"])
console.log("Recursive apply: strict ownership before PUT, foreign retention, partial/unknown/rejected/applied, no retry/delete after rejection")
+46
View File
@@ -0,0 +1,46 @@
import type { MikrotikClient } from "./mikrotik.js"
import { MikrotikError } from "./mikrotik.js"
import { parseRecursiveCatalog } from "./recursive-route-catalog.js"
export class RecursiveApplyError extends Error {
constructor(message: string, public readonly receipt: {
state: "partial" | "unknown" | "rejected" | "applied"
confirmedWrites: number
createdIds: string[]
deletedIds: string[]
needsReview: boolean
metadataRecorded: boolean
}) { super(message); this.name = "RecursiveApplyError" }
}
export async function executeRecursiveApply(
client: Pick<MikrotikClient, "get" | "put" | "delete">,
payloads: Record<string, string>[],
persist: () => Promise<void>,
) {
// The editor replaces every owned row: reject unsupported ownership before any write.
const existing = parseRecursiveCatalog(await client.get<unknown>("/ip/route"))
const createdIds: string[] = [], deletedIds: string[] = []
let nativeComplete = false
try {
for (const payload of payloads) {
const result = await client.put<unknown>("/ip/route", payload)
if (!result || typeof result !== "object" || !(".id" in result) || typeof result[".id"] !== "string" || !result[".id"].trim()) throw new Error("RouterOS не подтвердил ID созданного маршрута")
createdIds.push(result[".id"])
}
for (const route of existing) {
await client.delete("/ip/route/" + encodeURIComponent(route.id))
deletedIds.push(route.id)
}
nativeComplete = true
await persist()
return { pushed: createdIds.length, deleted: deletedIds.length }
} catch (error) {
const rejected = error instanceof MikrotikError && error.statusCode >= 400 && error.statusCode < 500
const confirmedWrites = createdIds.length + deletedIds.length
const state = nativeComplete ? "applied" : rejected ? (confirmedWrites ? "partial" : "rejected") : "unknown"
throw new RecursiveApplyError(`Подтверждено записей RouterOS: ${confirmedWrites}. Обновите каталог перед повторной попыткой. ${String(error)}`, {
state, confirmedWrites, createdIds, deletedIds, needsReview: state !== "rejected", metadataRecorded: false,
})
}
}
@@ -1,14 +1,23 @@
import { z } from "zod"
import { isIP } from "node:net"
const destination = z.string().trim().refine(value => {
const [address, prefix, extra] = value.split("/")
return extra === undefined && isIP(address ?? "") === 4 && (prefix === undefined || /^\d+$/.test(prefix) && Number(prefix) <= 32)
}, "Нужен IPv4 адрес или префикс")
const gateway = z.string().trim().refine(value => {
const [address, iface, extra] = value.split("%")
return extra === undefined && isIP(address ?? "") === 4 && (iface === undefined || Boolean(iface) && !/\s/.test(iface))
}, "Нужен IPv4 шлюз с необязательным интерфейсом")
const serverId = z.union([z.number().int().positive(), z.string().trim().regex(/^\d+$/)])
.transform(Number).refine(value => Number.isSafeInteger(value) && value > 0)
export const recursiveTargetSchema = z.object({ serverId })
export const recursiveInputRowSchema = z.object({
id: z.string().optional().default(""),
dstAddress: z.string().trim().min(1), gateway: z.string().trim().min(1),
dstAddress: destination, gateway,
distance: z.number().int().min(0).max(255),
scope: z.number().int().min(0).max(255).nullable(),
targetScope: z.number().int().min(0).max(255).nullable(),
routingTable: z.string().trim().min(1), checkGateway: z.string(),
routingTable: z.string().trim().min(1), checkGateway: z.enum(["none", "arp", "ping", "bfd"]),
country: z.string(), comment: z.string(), disabled: z.boolean(),
})
export const recursiveInputRowsSchema = z.array(recursiveInputRowSchema)