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
362 lines
15 KiB
TypeScript
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()
|