320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
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,
|
|
planRecursiveApply,
|
|
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.delete(recursiveRoutes).where(eq(recursiveRoutes.serverId, serverId))
|
|
if (routes.length === 0) return
|
|
const now = new Date().toISOString()
|
|
await db.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)
|
|
const existing = await client.get<RosRoute[]>("/ip/route")
|
|
const { deleteIds } = planRecursiveApply(existing)
|
|
|
|
for (const route of routes) {
|
|
await client.post("/ip/route", toRouterPayload(route))
|
|
}
|
|
for (const id of deleteIds) {
|
|
await client.delete(`/ip/route/${encodeURIComponent(id)}`)
|
|
}
|
|
|
|
await replaceDbRoutes(server.id, routes)
|
|
await appendRevisionIfChanged({
|
|
serverId: server.id,
|
|
section: "recursive-routes",
|
|
source,
|
|
payload: canonicalRecursiveRoutes(routes),
|
|
})
|
|
return { pushed: routes.length, deleted: deleteIds.length }
|
|
}
|
|
|
|
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
|
|
|
|
const recursiveRoutesPlugin: FastifyPluginAsyncZod = 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]
|
|
if (!server) return reply.status(404).send({ error: "Server not found" })
|
|
|
|
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: ServerRow | undefined = (await db
|
|
.select().from(servers)
|
|
.where(eq(servers.id, serverId))
|
|
.limit(1))[0]
|
|
if (!server) return reply.status(404).send({ error: "Server not found" })
|
|
|
|
const cached = await mapDbRoutes(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 mapDbRoutes(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 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]
|
|
if (!server) return reply.status(404).send({ error: "Server not found" })
|
|
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)
|
|
return reply.send({ ok: true, serverId, routes: stored, pushedRoutes: result.pushed })
|
|
} catch (err) {
|
|
return reply.status(500).send({ error: String(err) })
|
|
}
|
|
})
|
|
|
|
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 body = req.body as { serverId?: string | number } | undefined
|
|
const rev = await getRevisionById(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 (!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),
|
|
}
|
|
})
|
|
|
|
try {
|
|
const result = await applyRecursiveToServer(server, routes, "rollback")
|
|
const stored = await mapDbRoutes(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) })
|
|
}
|
|
})
|
|
}
|
|
|
|
export default recursiveRoutesPlugin
|