Files
MikrotikManager/backend/src/routes/recursive-routes.ts
T
Denozordec e6a2e4009d
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
fix(routes): читать рекурсивные маршруты с незаданными свойствами
2026-10-16 00:28:45 +07:00

362 lines
15 KiB
TypeScript

import { recursiveApplyBodySchema, recursiveTargetSchema, recursiveInputRowsSchema } from '../services/recursive-route-validation.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"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { db } from "../db/index.js"
import { recursiveRoutes, servers } from "../db/schema.js"
import { MikrotikClient } from "../services/mikrotik.js"
import { parseDbServerId } from "../utils/server-id.js"
import { managedRecursiveComment } from "../managed-markers.js"
import {
mapRosManagedRoutes,
userRecursiveComment,
} from "../services/config-apply-plan.js"
import {
appendRevisionIfChanged,
canonicalRecursiveRoutes,
getRevisionById,
listRevisions,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
type ServerRow = typeof servers.$inferSelect
interface RosRoute {
".id"?: string
"dst-address"?: string
gateway?: string
distance?: string
scope?: string
"target-scope"?: string
"routing-table"?: string
"check-gateway"?: string
comment?: string
disabled?: string
static?: string
dynamic?: string
blackhole?: string
unreachable?: string
prohibit?: string
active?: string
}
interface GatewayOptionDto {
id: string
name: string
ip: string
status: "up" | "down"
}
interface RecursiveRouteDto {
id: string
dstAddress: string
gateway: string
distance: number
scope: number | null
targetScope: number | null
routingTable: string
checkGateway: string
country: string
comment: string
disabled: boolean
}
function splitGateway(raw: string): { ip: string; name: string } | null {
const v = raw.trim()
if (!v) return null
const [ip, name] = v.split("%")
if (!ip || !/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) return null
return { ip, name: name || ip }
}
async function mapDbRoutes(serverId: number): Promise<RecursiveRouteDto[]> {
const rows = await db
.select()
.from(recursiveRoutes)
.where(eq(recursiveRoutes.serverId, serverId))
.orderBy(asc(recursiveRoutes.sortOrder))
return rows.map((r) => ({
id: String(r.id),
dstAddress: r.dstAddress,
gateway: r.gateway,
distance: r.distance,
scope: r.scope ?? null,
targetScope: r.targetScope ?? null,
routingTable: r.routingTable,
checkGateway: r.checkGateway,
country: r.country ?? "",
comment: r.comment,
disabled: r.disabled,
}))
}
function mergeCachedCountry(
live: RecursiveRouteDto[],
cached: RecursiveRouteDto[],
): RecursiveRouteDto[] {
return live.map((row) => {
if (row.country) return row
const match = cached.find((c) =>
c.dstAddress === row.dstAddress &&
c.gateway === row.gateway &&
c.distance === row.distance,
)
return match?.country ? { ...row, country: match.country } : row
})
}
function toRouterPayload(route: RecursiveRouteDto): Record<string, string> {
return {
"dst-address": route.dstAddress,
gateway: route.gateway,
distance: String(route.distance),
...(route.scope != null ? { scope: String(route.scope) } : {}),
...(route.targetScope != null ? { "target-scope": String(route.targetScope) } : {}),
...(route.routingTable ? { "routing-table": route.routingTable } : {}),
...(route.checkGateway ? { "check-gateway": route.checkGateway } : {}),
comment: managedRecursiveComment(route.comment),
disabled: route.disabled ? "true" : "false",
}
}
async function replaceDbRoutes(serverId: number, routes: RecursiveRouteDto[]) {
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 tx.insert(recursiveRoutes).values(
routes.map((r, i) => ({
serverId,
sortOrder: i,
dstAddress: r.dstAddress,
gateway: r.gateway,
distance: r.distance,
scope: r.scope,
targetScope: r.targetScope,
routingTable: r.routingTable || "main",
checkGateway: r.checkGateway ?? "",
country: r.country ?? "",
comment: userRecursiveComment(r.comment),
disabled: r.disabled,
createdAt: now,
updatedAt: now,
})),
)
})
}
async function applyRecursiveToServer(
server: ServerRow,
routes: RecursiveRouteDto[],
source: ConfigRevisionSource,
): Promise<{ pushed: number; deleted: number }> {
const client = MikrotikClient.fromServer(server)
return executeRecursiveApply(client, routes.map(toRouterPayload), async () => {
await replaceDbRoutes(server.id, routes)
await appendRevisionIfChanged({
serverId: server.id,
section: "recursive-routes",
source,
payload: canonicalRecursiveRoutes(routes),
})
})
}
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, 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
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,
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) })
},
...overrides,
}
return async (app) => {
app.get("/recursive-routes/catalog", async (req, reply) => {
const parsed = recursiveTargetSchema.safeParse(req.query)
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 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) {
return reply.status(503).send({ error: error instanceof Error ? error.message : "Не удалось прочитать каталог маршрутов" })
}
})
app.get("/recursive-routes/gateways/catalog", async (req, reply) => {
const parsed = recursiveTargetSchema.safeParse(req.query)
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, "gateways")) })
} catch (error) {
return reply.status(503).send({ error: error instanceof Error ? error.message : "Не удалось прочитать шлюзы" })
}
})
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 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)
const rosRoutes = await client.get<RosRoute[]>("/ip/route")
const map = new Map<string, GatewayOptionDto>()
for (const r of rosRoutes) {
const g = splitGateway(r.gateway ?? "")
if (!g) continue
const id = `${g.ip}%${g.name}`
if (!map.has(id)) {
map.set(id, {
id,
name: g.name,
ip: g.ip,
status: r.active === "true" ? "up" : "down",
})
}
}
return reply.send({ serverId, gateways: [...map.values()] })
} catch (err) {
return reply.status(500).send({ error: String(err) })
}
})
app.get("/recursive-routes", 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 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 {
const client = MikrotikClient.fromServer(server)
const rosRoutes = await client.get<RosRoute[]>("/ip/route")
const live = mergeCachedCountry(mapRosManagedRoutes(rosRoutes), cached)
await replaceDbRoutes(serverId, live)
await appendRevisionIfChanged({
serverId,
section: "recursive-routes",
source: "observed",
payload: canonicalRecursiveRoutes(live),
})
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")
return reply.send({
serverId,
routes: cached,
live: false,
stale: true,
error: String(err),
})
}
})
app.put("/recursive-routes", async (req, reply) => {
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" })
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 {
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) {
const receipt = recursiveFailureReceipt(err, result)
return reply.status(receipt ? 502 : 500).send({ error: String(err), serverId, ...receipt })
}
})
app.get("/recursive-routes/revisions", 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 revisions = await listRevisions(serverId, "recursive-routes")
return reply.send({ revisions })
})
app.post("/recursive-routes/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
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" })
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" })
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 {
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) {
const receipt = recursiveFailureReceipt(err, result)
return reply.status(receipt ? 502 : 500).send({ error: String(err), serverId: server.id, ...receipt })
}
})
}
}
export default createRecursiveRoutes()