324 lines
17 KiB
TypeScript
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()
|