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

This commit is contained in:
Denozordec committed 2026-09-30 21:15:23 +07:00
1 parent f5fc9282e2
commit 52493debc2
6 files changed
+146 -56

No files matched your search

+2 -1
View File
@@ -19,11 +19,12 @@
"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: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",
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync && npm run test:ros-file",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
},
"dependencies": {
@@ -0,0 +1,70 @@
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"
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 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,
apply: async (device, rows, source) => {
assert.equal(device.id, 1); writes++; applied.push({ rows: rows as typeof row[], source })
if (fail) throw new Error("Тест частичного применения")
stored = rows as typeof row[]; return { pushed: rows.length, deleted: 0 }
},
getRevision: async () => {
revisions++
return { id: "rev", serverId: revisionServer, section, payload } as NonNullable<Awaited<ReturnType<RecursiveRouteDependencies["getRevision"]>>>
},
}), { prefix: "/api" })
const invalid = [
{}, { serverId: 1 }, { serverId: 1, routes: null }, { serverId: 1, routes: {} },
{ serverId: null, routes: [] }, { serverId: "srv1", routes: [] }, { serverId: "9007199254740993", routes: [] },
{ serverId: 1, routes: [{}] }, { serverId: 1, routes: [{ ...row, disabled: "false" }] },
{ 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" },
]
for (const body of invalid) {
const result = await app.inject({ method: "PUT", url: "/api/recursive-routes", payload: body })
assert.equal(result.statusCode, 400, JSON.stringify(body))
assert.equal(lookup, 0); assert.equal(writes, 0)
}
const empty = await app.inject({ method: "PUT", url: "/api/recursive-routes", payload: { serverId: "1", routes: [] } })
assert.equal(empty.statusCode, 200); assert.deepEqual(empty.json(), { ok: true, serverId: 1, routes: [], pushedRoutes: 0 })
const known = await app.inject({ method: "PUT", url: "/api/recursive-routes", payload: { serverId: 1, routes: [row], source: "copy" } })
assert.equal(known.statusCode, 200); assert.equal(known.json().routes[0].distance, 0); assert.equal(known.json().routes[0].scope, 0)
assert.equal(applied.at(-1)?.source, "copy")
fail = true
const failed = await app.inject({ method: "PUT", url: "/api/recursive-routes", payload: { serverId: 1, routes: [row] } })
assert.equal(failed.statusCode, 500); assert.equal(failed.json().ok, undefined); fail = false
for (const body of [{}, { serverId: 0 }, { serverId: "srv1" }]) {
const before = writes, reads = revisions
const result = await app.inject({ method: "POST", url: "/api/recursive-routes/revisions/rev/restore", payload: body })
assert.equal(result.statusCode, 400); assert.equal(writes, before); assert.equal(revisions, reads)
}
revisionServer = 2
const before = writes
assert.equal((await app.inject({ method: "POST", url: "/api/recursive-routes/revisions/rev/restore", payload: { serverId: 1 } })).statusCode, 400)
assert.equal(writes, before); revisionServer = 1
section = "filters"
assert.equal((await app.inject({ method: "POST", url: "/api/recursive-routes/revisions/rev/restore", payload: { serverId: 1 } })).statusCode, 400)
assert.equal(writes, before); section = "recursive-routes"
for (payload of [null, {}, [{}], [{ ...row, disabled: "false" }], [{ ...row, distance: null }]]) {
const result = await app.inject({ method: "POST", url: "/api/recursive-routes/revisions/rev/restore", payload: { serverId: 1 } })
assert.equal(result.statusCode, 409); assert.equal(writes, before)
}
payload = [{ ...row, id: undefined }]
const restored = await app.inject({ method: "POST", url: "/api/recursive-routes/revisions/rev/restore", payload: { serverId: 1 } })
assert.equal(restored.statusCode, 200); assert.equal(restored.json().serverId, 1); assert.equal(applied.at(-1)?.source, "rollback")
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 })
await app.close()
console.log("recursive routes HTTP: invalid/explicit-empty/zero/copy/error/restore ownership and payload passed without DB/RouterOS")
+43 -54
View File
@@ -1,3 +1,4 @@
import { recursiveApplyBodySchema, recursiveTargetSchema, recursiveInputRowsSchema } from '../services/recursive-route-validation.js'
import { asc, eq } from "drizzle-orm"
import { z } from "zod"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
@@ -172,12 +173,26 @@ async function applyRecursiveToServer(
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
export interface RecursiveRouteDependencies {
getServer: (id: number) => Promise<ServerRow | undefined>
getRoutes: typeof mapDbRoutes
apply: typeof applyRecursiveToServer
getRevision: typeof getRevisionById
}
export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependencies> = {}): FastifyPluginAsyncZod {
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,
...overrides,
}
return async (app) => {
app.get("/recursive-routes/gateways", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
const server = await deps.getServer(serverId)
if (!server) return reply.status(404).send({ error: "Server not found" })
try {
@@ -207,13 +222,10 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
const q = req.query as { serverId?: string | number }
const serverId = parseDbServerId(q.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server: ServerRow | undefined = (await db
.select().from(servers)
.where(eq(servers.id, serverId))
.limit(1))[0]
const server = await deps.getServer(serverId)
if (!server) return reply.status(404).send({ error: "Server not found" })
const cached = await mapDbRoutes(serverId)
const cached = await deps.getRoutes(serverId)
try {
const client = MikrotikClient.fromServer(server)
const rosRoutes = await client.get<RosRoute[]>("/ip/route")
@@ -225,7 +237,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
source: "observed",
payload: canonicalRecursiveRoutes(live),
})
const stored = await mapDbRoutes(serverId)
const stored = await deps.getRoutes(serverId)
return reply.send({ serverId, routes: stored, live: true, stale: false })
} catch (err) {
app.log.warn({ serverId, err: String(err) }, "recursive live GET failed, serving cache")
@@ -240,23 +252,16 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
})
app.put("/recursive-routes", async (req, reply) => {
const body = req.body as {
serverId?: string | number
routes?: RecursiveRouteDto[]
source?: ConfigRevisionSource
}
const serverId = parseDbServerId(body.serverId)
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0]
const parsed = recursiveApplyBodySchema.safeParse(req.body)
if (!parsed.success) return reply.status(400).send({ error: "Некорректный запрос маршрутов: нужны serverId и полный список routes" })
const body = parsed.data, serverId = body.serverId
const server = await deps.getServer(serverId)
if (!server) return reply.status(404).send({ error: "Server not found" })
const routes = (body.routes ?? []).map((r) => ({
...r,
comment: userRecursiveComment(r.comment),
}))
const routes = body.routes.map(r => ({ ...r, comment: userRecursiveComment(r.comment) }))
const source: ConfigRevisionSource = body.source === "copy" ? "copy" : "apply"
try {
const result = await applyRecursiveToServer(server, routes, source)
const stored = await mapDbRoutes(serverId)
const 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) })
@@ -275,40 +280,22 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined
const rev = await getRevisionById(id)
const parsedTarget = recursiveTargetSchema.safeParse(req.body)
if (!parsedTarget.success) return reply.status(400).send({ error: "Для восстановления нужен serverId" })
const rev = await deps.getRevision(id)
if (!rev) return reply.status(404).send({ error: "Revision not found" })
if (rev.section !== "recursive-routes") {
return reply.status(400).send({ error: "Revision section mismatch" })
}
const requested = parseDbServerId(body?.serverId)
if (requested !== null && requested !== rev.serverId) {
return reply.status(400).send({ error: "Revision belongs to another server" })
}
const server = (await db.select().from(servers).where(eq(servers.id, rev.serverId)).limit(1))[0]
if (rev.section !== "recursive-routes") return reply.status(400).send({ error: "Revision section mismatch" })
if (parsedTarget.data.serverId !== rev.serverId) return reply.status(400).send({ error: "Revision belongs to another server" })
const parsedRoutes = recursiveInputRowsSchema.safeParse(rev.payload)
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" })
const raw = Array.isArray(rev.payload) ? rev.payload : []
const routes: RecursiveRouteDto[] = raw.map((item, idx) => {
const r = item as Partial<RecursiveRouteDto>
return {
id: `rev-${idx}`,
dstAddress: r.dstAddress ?? "",
gateway: r.gateway ?? "",
distance: r.distance ?? 1,
scope: r.scope ?? null,
targetScope: r.targetScope ?? null,
routingTable: r.routingTable || "main",
checkGateway: r.checkGateway ?? "",
country: r.country ?? "",
comment: userRecursiveComment(r.comment),
disabled: Boolean(r.disabled),
}
})
const routes: RecursiveRouteDto[] = parsedRoutes.data.map((row, index) => ({
...row, id: `rev-${index}`, comment: userRecursiveComment(row.comment),
}))
try {
const result = await applyRecursiveToServer(server, routes, "rollback")
const stored = await mapDbRoutes(server.id)
const 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) })
@@ -316,4 +303,6 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
})
}
export default recursiveRoutesPlugin
}
export default createRecursiveRoutes()
@@ -0,0 +1,18 @@
import { z } from "zod"
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),
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(),
country: z.string(), comment: z.string(), disabled: z.boolean(),
})
export const recursiveInputRowsSchema = z.array(recursiveInputRowSchema)
.refine(rows => { const ids = rows.map(row => row.id).filter(Boolean); return new Set(ids).size === ids.length })
export const recursiveApplyBodySchema = recursiveTargetSchema.extend({
routes: recursiveInputRowsSchema, source: z.enum(["apply", "copy"]).optional(),
})
+9 -1
View File
@@ -26,11 +26,19 @@
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.24; операции рекурсивных маршрутов, далее backend validation |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.25; операции/валидация рекурсивных маршрутов, далее URL/source |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.25 — backend-валидация рекурсивных операций (2026-09-30)
- PUT требует явные serverId/routes и валидные параметры каждого маршрута; отсутствие routes не превращается в удаление всех managed-маршрутов. Пустой массив остаётся допустимым намеренным действием. Нули distance/scope/targetScope сохраняются.
- Restore требует точного serverId, совпадения раздела/устройства и валидного payload. Null/object/неполный snapshot не подменяется пустым списком или default параметрами. Canonical версии без id совместимы; id формируется отдельно.
- Фабрика зависимостей позволяет через HTTP injection проверить отказ до поиска устройства и сетевого apply. Реальные БД/RouterOS не вызываются.
- Проверки: backend HTTP regression (15 malformed PUT, explicit empty/zero/copy/error, restore ownership/payload), auth smoke/permissions, config-sync, build и lint прошли. Алгоритм add/delete, managed markers и source apply/copy/rollback прежние.
- Далее 10.26: URL устройства/поиска/группы и source/history при незавершённой операции; затем strict observed parser и визуальная приёмка. Последняя квота: 38% окна, 37% недели использовано. Локальный коммит без push.
## Блок 10.24 — подтверждённые операции рекурсивных маршрутов (2026-09-30)
- PUT и restore проверяют ok, serverId, полный список и pushedRoutes; backend добавляет serverId к чтению, шлюзам и receipts. Старые поля сохранены. Новый frontend требует совместимый обновлённый backend; старый ответ безопасно блокирует запись.
+4
View File
@@ -148,3 +148,7 @@ Route subtype и BFD multihop неизвестны без наблюдения.
## 10.24. Операции рекурсивных маршрутов
Strict receipts/serverId, общая блокировка save/delete/restore, форма и последний снимок при ошибке; разные routing tables и скрытые поиском endpoints сохранены. 35 E2E, TS/lint, обе сборки и config-sync прошли; 11 общих history повторно прошли после разделения доступности read/write. Далее backend validation, URL/source и observed metadata/layout. Legacy parser пока не мигрирован; сетевые правила применения прежние.
## 10.25. Валидация PUT/restore
Явный список и точный serverId обязательны; повреждённая версия не превращается в empty/default конфигурацию. HTTP injection проверяет отказ до сети; auth/config-sync/build/lint прошли. Intentional empty и canonical snapshot без id совместимы; алгоритм применения прежний. Далее URL/source, observed parser и layout.