fix(routes): читать рекурсивные маршруты с незаданными свойствами
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 5m2s
Docker images / frontend-image (push) Successful in 2m46s
Docker images / updater-image (push) Successful in 42s
Docker images / backend-image (push) Successful in 1m54s
Docker images / publish-release (push) Successful in 15s
Docker images / notify-webhook (push) Successful in 2s

This commit is contained in:
Denozordec committed 2026-10-16 00:28:45 +07:00
1 parent eb24a08981
commit e6a2e4009d
9 files changed
+188 -9

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@
"test:traffic-flow": "tsx src/routes/traffic-flow-query.test.ts && tsx src/services/traffic-flow-month.test.ts && tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-ipv6-pipeline.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/geoip-download.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/pg-pool.test.ts && 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/db/scheduler-retention-index.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 && tsx src/services/recursive-apply.test.ts",
"test:recursive-routes": "tsx src/routes/recursive-routes.test.ts && tsx src/services/recursive-route-catalog.test.ts && tsx src/services/recursive-effective-properties.test.ts && tsx src/services/recursive-apply.test.ts",
"test:config-sync": "tsx src/utils/server-id.test.ts && tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts && tsx src/services/gre-native-identity.test.ts && tsx src/services/gre-ipv6.test.ts && tsx src/routes/gre-ipv6-validation.test.ts && tsx src/services/network-disabled-server.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts && tsx src/services/mikrotik-response.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",
@@ -9,6 +9,7 @@ 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
let propertyFailure = false, propertyReads = 0
const applied: Array<{ rows: typeof row[]; source: string }> = []
const app = Fastify()
app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler)
@@ -16,6 +17,7 @@ await app.register(createRecursiveRoutes({
getServer: async id => { lookup++; return id === 1 ? server : undefined },
getRoutes: async () => { if (lateReadFailure) throw Error("Сбой чтения после применения"); return stored },
readRaw: async () => { rawReads++; if (rawFailure) throw new Error("Тест опроса"); return rawPayload },
readProperty: async (_server, _id, property) => { propertyReads++; if (propertyFailure) throw Error("Свойство недоступно"); return property === "disabled" ? { ret: "false" } : [] },
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 })
@@ -85,6 +87,14 @@ 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=[{...observed,disabled:undefined,"check-gateway":undefined}]
const recovered=await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})
assert.equal(recovered.statusCode,200);assert.equal(recovered.json().routes[0].disabled,false);assert.equal(recovered.json().routes[0].checkGateway,"none");assert.equal(propertyReads,2)
propertyFailure=true
const beforePropertyFailure=savedSnapshots
const failedProperty=await app.inject({method:"GET",url:"/api/recursive-routes/catalog?serverId=1"})
assert.equal(failedProperty.statusCode,503);assert.equal(failedProperty.json().routes,undefined);assert.equal(savedSnapshots,beforePropertyFailure)
propertyFailure=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) {
+15 -5
View File
@@ -1,5 +1,5 @@
import { recursiveApplyBodySchema, recursiveTargetSchema, recursiveInputRowsSchema } from '../services/recursive-route-validation.js'
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog } from "../services/recursive-route-catalog.js"
import { parseRecursiveCatalog, parseRecursiveGatewayCatalog, readRecursiveEffectiveProperties, type RecursiveObservedProperty } from "../services/recursive-route-catalog.js"
import { executeRecursiveApply, RecursiveApplyError } from "../services/recursive-apply.js"
import { asc, eq } from "drizzle-orm"
import { z } from "zod"
@@ -176,7 +176,8 @@ function recursiveFailureReceipt(error: unknown, result?: { pushed: number; dele
export interface RecursiveRouteDependencies {
getServer: (id: number) => Promise<ServerRow | undefined>
readRaw: (server: ServerRow) => Promise<unknown>
readRaw: (server: ServerRow, purpose?: "routes" | "gateways") => Promise<unknown>
readProperty: (server: ServerRow, id: string, property: RecursiveObservedProperty) => Promise<unknown>
saveObserved: (serverId: number, routes: RecursiveRouteDto[]) => Promise<void>
getRoutes: typeof mapDbRoutes
apply: typeof applyRecursiveToServer
@@ -187,7 +188,15 @@ 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"),
readRaw: (server, purpose) => {
const client = MikrotikClient.fromServer(server)
// Every ownership marker is a comment. Keep all commented rows, including
// unsupported ones that must block an unsafe replacement of the managed set.
return purpose === "gateways"
? client.get<unknown>("/ip/route?.proplist=.id,gateway,active")
: client.post<unknown>("/ip/route/print", { ".query": ["comment"] })
},
readProperty: (server, id, property) => MikrotikClient.fromServer(server).post("/ip/route/get", { ".id": id, "value-name": property }),
saveObserved: async (serverId, routes) => {
await replaceDbRoutes(serverId, routes)
await appendRevisionIfChanged({ serverId, section: "recursive-routes", source: "observed", payload: canonicalRecursiveRoutes(routes) })
@@ -203,7 +212,8 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
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))
const observed = await readRecursiveEffectiveProperties(await deps.readRaw(server), (id, property) => deps.readProperty(server, id, property))
const routes = mergeCachedCountry(parseRecursiveCatalog(observed), await deps.getRoutes(serverId))
await deps.saveObserved(serverId, routes)
return reply.send({ serverId, routes, live: true, stale: false })
} catch (error) {
@@ -217,7 +227,7 @@ export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependenc
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)) })
return reply.send({ serverId, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server, "gateways")) })
} catch (error) {
return reply.status(503).send({ error: error instanceof Error ? error.message : "Не удалось прочитать шлюзы" })
}
@@ -3,6 +3,27 @@ import assert from "node:assert/strict"
import { createServer } from "node:http"
import { MikrotikClient } from "./mikrotik.js"
test("RouterOS print preserves native query arrays and property selection", async () => {
let received: unknown, target = "", method = ""
const server = createServer(async (req, res) => {
target = req.url ?? ""; method = req.method ?? ""
let body = ""; for await (const chunk of req) body += chunk.toString()
received = JSON.parse(body)
res.setHeader("content-type", "application/json"); res.end(JSON.stringify([{ ".id": "*1" }]))
})
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
const address = server.address(); assert(address && typeof address !== "string")
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
try {
const result = await client.post("/ip/route/print", { ".query": ["comment"], ".proplist": [".id", "gateway"] })
assert.deepEqual(received, { ".query": ["comment"], ".proplist": [".id", "gateway"] })
assert.equal(target, "/rest/ip/route/print"); assert.equal(method, "POST")
assert.deepEqual(result, [{ ".id": "*1" }])
} finally {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
})
test("RouterOS shared REST pool bounds logins across client instances and closes idle sockets", async () => {
let active=0,peak=0,requests=0
const connections=new Set<import("node:net").Socket>()
+2 -2
View File
@@ -104,7 +104,7 @@ function rosRequest(
function rosPost(
params: MikrotikConnectParams,
path: string,
body: Record<string, string>,
body: Record<string, string | string[]>,
timeoutMs: number,
externalSignal?: AbortSignal,
): Promise<unknown> {
@@ -595,7 +595,7 @@ export class MikrotikClient {
return rosRequest(this.params, path, timeoutMs) as Promise<T>
}
async post<T>(path: string, body: Record<string, string>, timeoutMs = 15_000, signal?: AbortSignal): Promise<T> {
async post<T>(path: string, body: Record<string, string | string[]>, timeoutMs = 15_000, signal?: AbortSignal): Promise<T> {
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
}
@@ -0,0 +1,44 @@
import assert from "node:assert/strict"
import { readRecursiveEffectiveProperties, parseRecursiveCatalog } from "./recursive-route-catalog.js"
const row = { ".id": "*1", static: "true", "dst-address": "10.9.9.2/32", gateway: "192.0.2.1%gre-test", distance: "1", scope: "30", "target-scope": "10", "routing-table": "main", comment: "MikrotikManager:recursive Тест" }
const calls: string[] = []
const result = await readRecursiveEffectiveProperties([row], async (id, property) => {
calls.push(id + ":" + property)
return property === "disabled" ? { ret: "false" } : []
})
assert.equal(parseRecursiveCatalog(result)[0].disabled, false)
assert.equal(parseRecursiveCatalog(result)[0].checkGateway, "none")
assert.deepEqual(calls.sort(), ["*1:check-gateway", "*1:disabled"])
assert.equal("disabled" in row, false)
let reads = 0
const complete = { ...row, disabled: "true", "check-gateway": "ping" }
assert.deepEqual(await readRecursiveEffectiveProperties([complete, { ".id": "*other", comment: "user-owned" }], async () => { reads++; return null }), [complete, { ".id": "*other", comment: "user-owned" }])
assert.equal(reads, 0)
for (const payload of [null, {}, [null], [{ ...row, ".id": "" }], [row, row]]) {
await assert.rejects(readRecursiveEffectiveProperties(payload, async () => { reads++; return null }))
assert.equal(reads, 0)
}
for (const property of ["disabled", "check-gateway"] as const) {
for (const response of [null, {}, { ret: false }, { ret: "" }, { ret: "unknown" }, [{}], ...(property === "disabled" ? [[]] : [])]) {
const input = { ...complete }; delete (input as Record<string, unknown>)[property]
await assert.rejects(readRecursiveEffectiveProperties([input], async () => response), /Не подтверждён/)
}
}
await assert.rejects(readRecursiveEffectiveProperties([row], async () => { throw Error("RouterOS недоступен") }), /недоступен/)
const explicit = await readRecursiveEffectiveProperties([row], async (_id, property) => ({ ret: property === "disabled" ? "true" : "bfd" }))
assert.equal(parseRecursiveCatalog(explicit)[0].disabled, true)
assert.equal(parseRecursiveCatalog(explicit)[0].checkGateway, "bfd")
// Explicit invalid values are not replaced with defaults or re-read.
const invalid = { ...complete, disabled: "unknown" }
const untouched = await readRecursiveEffectiveProperties([invalid], async () => { throw Error("unexpected read") })
assert.throws(() => parseRecursiveCatalog(untouched))
let active = 0, maximum = 0
await readRecursiveEffectiveProperties(Array.from({ length: 12 }, (_, i) => ({ ...row, ".id": "*" + i })), async (_id, property) => {
maximum = Math.max(maximum, ++active)
await new Promise(resolve => setTimeout(resolve, 1)); active--
return { ret: property === "disabled" ? "false" : "arp" }
})
assert.equal(maximum, 2)
console.log("Recursive effective properties: omitted flags/unset check, strict envelopes, ownership, no mutation, errors and bounded reads passed")
@@ -13,6 +13,44 @@ function rawRows(payload: unknown): Array<Record<string, unknown>> {
}
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 type RecursiveObservedProperty = "disabled" | "check-gateway"
/** RouterOS print can omit unset properties. Ask get for the effective value,
* rather than interpreting an incomplete snapshot as enabled/no-check. */
export async function readRecursiveEffectiveProperties(
payload: unknown,
readProperty: (id: string, property: RecursiveObservedProperty) => Promise<unknown>,
): Promise<Array<Record<string, unknown>>> {
const rows = rawRows(payload).map(row => ({ ...row }))
const tasks: Array<{ row: Record<string, unknown>; property: RecursiveObservedProperty }> = []
for (const row of rows) {
if (typeof row.comment !== "string" || !isOwnedRecursiveComment(row.comment)) continue
for (const property of ["disabled", "check-gateway"] as const) {
if (row[property] === undefined) tasks.push({ row, property })
}
}
let cursor = 0, failed = false
await Promise.all(Array.from({ length: Math.min(2, tasks.length) }, async () => {
while (!failed && cursor < tasks.length) {
const { row, property } = tasks[cursor++]!
try {
const response = await readProperty(String(row[".id"]), property)
// An empty successful /get reply means the optional check is unset.
// Do not treat null, malformed envelopes or failed reads as defaults.
const unset = property === "check-gateway" && Array.isArray(response) && response.length === 0
const value = response && typeof response === "object" && !Array.isArray(response) && "ret" in response ? response.ret : undefined
if (property === "disabled") {
if (typeof value !== "string" || observedBoolean(value) === null) throw new Error("Не подтверждён disabled маршрута " + row[".id"])
row.disabled = observedBoolean(value) ? "true" : "false"
} else {
if (!unset && (typeof value !== "string" || !["none", "arp", "ping", "bfd"].includes(value))) throw new Error("Не подтверждён check-gateway маршрута " + row[".id"])
row[property] = unset ? "none" : value
}
} catch (error) { failed = true; throw error }
}
}))
return rows
}
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"])