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
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:
1 parent
eb24a08981
commit
e6a2e4009d
9 files changed
+188
-9
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in new issue
Block a user