Files
MikrotikManager/backend/src/routes/recursive-routes.ts
T

324 lines
17 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 { storeRecursiveRoutes } from "../services/recursive-route-store.js"
import { planRecursiveIdentities } from "../services/recursive-route-identity.js"
import { withApplicationMutationLock, withConfigMutationLock } from "../services/config-mutation-lock.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 { 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 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
}
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,
}))
}
export { mapDbRoutes as readCachedRecursiveRoutes }
function mergeCachedCountry(
live: RecursiveRouteDto[],
cached: RecursiveRouteDto[],
): RecursiveRouteDto[] {
return live.map((row) => {
if (row.country) return row
const matches = cached.filter((c) =>
c.dstAddress === row.dstAddress &&
c.gateway === row.gateway &&
c.distance === row.distance && c.routingTable === row.routingTable,
)
const match = matches.length === 1 ? matches[0] : undefined
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[]) {
return storeRecursiveRoutes(serverId, routes, "observed")
}
export async function applyRecursiveToServer(
server: ServerRow,
routes: RecursiveRouteDto[],
source: ConfigRevisionSource,
validateExisting?: (rows: ReturnType<typeof parseRecursiveCatalog>) => void | Promise<void>,
): Promise<{ pushed: number; deleted: number }> {
return withApplicationMutationLock(() => withConfigMutationLock([server.id], async () => {
await storeRecursiveRoutes(server.id, routes, source, true)
const client = MikrotikClient.fromServer(server)
return executeRecursiveApply(client, routes.map(toRouterPayload), async () => {
await storeRecursiveRoutes(server.id, routes, source)
await appendRevisionIfChanged({
serverId: server.id,
section: "recursive-routes",
source,
payload: canonicalRecursiveRoutes(routes),
})
}, async () => {
const observed = await readRecursiveNativeRowsForServer(server)
await validateExisting?.(parseRecursiveCatalog(observed))
return observed
}, () => readRecursiveNativeRowsForServer(server))
}))
}
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
}
const recursiveDefaults: 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,inactive")
: 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) => {
const stored = await replaceDbRoutes(serverId, routes)
await appendRevisionIfChanged({ serverId, section: "recursive-routes", source: "observed", payload: canonicalRecursiveRoutes(stored) })
},
}
/** Strict observation shared by both transports. REST can persist metadata;
* observers opt out. An unavailable source always fails this catalog read. */
export async function readRecursiveCatalogForServer(server: ServerRow, deps: Pick<RecursiveRouteDependencies, "readRaw" | "readProperty" | "getRoutes" | "saveObserved"> = recursiveDefaults, options: { persistObserved?: boolean } = {}) {
const read = async () => {
if (!server.enabled) throw new Error("Устройство отключено: каталог маршрутов недоступен")
const observed = await readRecursiveEffectiveProperties(await deps.readRaw(server), (id, property) => deps.readProperty(server, id, property))
const routes = mergeCachedCountry(parseRecursiveCatalog(observed), await deps.getRoutes(server.id))
if (options.persistObserved !== false) await deps.saveObserved(server.id, routes)
return { serverId: server.id, routes, live: true, stale: false }
}
return deps === recursiveDefaults && options.persistObserved !== false
? withApplicationMutationLock(() => withConfigMutationLock([server.id], read)) : read()
}
export async function readRecursiveNativeRowsForServer(server: ServerRow) {
if (!server.enabled) throw new Error("Устройство отключено: опрос маршрутов недоступен")
return readRecursiveEffectiveProperties(await recursiveDefaults.readRaw(server), (id, property) => recursiveDefaults.readProperty(server, id, property))
}
export async function readRecursiveGatewayCatalogForServer(server: ServerRow, deps: Pick<RecursiveRouteDependencies, "readRaw"> = recursiveDefaults) {
if (!server.enabled) throw new Error("Устройство отключено: каталог шлюзов недоступен")
return { serverId: server.id, gateways: parseRecursiveGatewayCatalog(await deps.readRaw(server, "gateways")) }
}
export async function readRecursiveGatewaysForServer(server: ServerRow, deps: Pick<RecursiveRouteDependencies, "readRaw"> = recursiveDefaults) {
const result = await readRecursiveGatewayCatalogForServer(server, deps)
return { ...result, gateways: result.gateways.map(row => ({ ...row, status: row.status === "active" ? "up" : row.status === "inactive" ? "down" : "unknown" })) }
}
/** Legacy application read, including its explicit stale fallback. MCP observers
* share the source/parser while leaving cache and revision history untouched. */
export async function readRecursiveRoutesForServer(server: ServerRow, deps: Pick<RecursiveRouteDependencies, "readRaw" | "readProperty" | "getRoutes" | "saveObserved"> = recursiveDefaults, options: { persistObserved?: boolean } = {}) {
if (!server.enabled) throw new Error("Устройство отключено: опрос маршрутов недоступен")
const read = async () => {
const cached = await deps.getRoutes(server.id)
try {
const result = await readRecursiveCatalogForServer(server, deps, options)
const routes = options.persistObserved === false
? planRecursiveIdentities(cached, result.routes, new Set(), "observed").rows.map((row, index) => ({ ...row, id: row.id || result.routes[index].id }))
: await deps.getRoutes(server.id)
return { ...result, routes }
} catch {
return { serverId: server.id, routes: cached, live: false, stale: true, error: "Не удалось подтвердить живой каталог; возвращена сохранённая конфигурация" }
}
}
return deps === recursiveDefaults && options.persistObserved !== false
? withApplicationMutationLock(() => withConfigMutationLock([server.id], read)) : read()
}
export function createRecursiveRoutes(overrides: Partial<RecursiveRouteDependencies> = {}): FastifyPluginAsyncZod {
const deps: RecursiveRouteDependencies = Object.keys(overrides).length ? { ...recursiveDefaults, ...overrides } : recursiveDefaults
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 {
return reply.send(await readRecursiveCatalogForServer(server, deps))
} 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(await readRecursiveGatewayCatalogForServer(server, deps))
} 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 {
return reply.send(await readRecursiveGatewaysForServer(server, deps))
} 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: "Устройство отключено: опрос и применение маршрутов недоступны" })
return reply.send(await readRecursiveRoutesForServer(server, deps))
})
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()