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

@@ -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)