feat(config-revisions): implement configuration history for Firewall, GRE, and WireGuard
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m20s
Docker images / frontend-image (push) Successful in 2m51s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 12s

- Added configuration history management for Firewall, GRE, and WireGuard pages, enabling users to view and restore previous configurations.
- Introduced new components for displaying configuration history and integrated them into the respective pages.
- Enhanced API routes to support fetching and restoring configuration revisions, ensuring data consistency across the application.
- Updated state management to handle loading and restoring states effectively, improving user experience during data operations.
- Enhanced tests to cover new functionalities and ensure reliability.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DenozordecandCursor committed 2026-09-11 14:03:00 +07:00
1 parent b4a3c3a925
commit 25b82997b6
22 files changed
+2232 -41

No files matched your search

+73 -1
View File
@@ -2,7 +2,20 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
import { MikrotikClient, MikrotikError, encodeRosId, firewallRestPath } from "../services/mikrotik.js"
import { getEnabledServerById } from "../services/wireguard-live.js"
import { listFirewallAll } from "../services/firewall-live.js"
import {
captureFirewallSnapshot,
fetchFirewallState,
listFirewallAll,
} from "../services/firewall-live.js"
import {
captureAndAppendRevision,
listRevisions,
loadRevisionForRestore,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
import { parseFirewallSnapshot, planFirewallRestore } from "../services/entity-snapshots.js"
import { executeRosOps } from "../services/ros-ops.js"
import { parseDbServerId } from "../utils/server-id.js"
import type { FirewallFamily, FirewallTable } from "../types/server.js"
const FamilySchema = z.enum(["ip", "ip6"])
@@ -120,6 +133,20 @@ async function requireServer(serverId: string) {
return await getEnabledServerById(serverId)
}
async function recordFirewall(
server: NonNullable<Awaited<ReturnType<typeof requireServer>>>,
source: ConfigRevisionSource,
) {
await captureAndAppendRevision({
serverId: server.id,
section: "firewall",
source,
capture: () => captureFirewallSnapshot(server),
})
}
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/firewall/all", async (_req, reply) => {
const data = await listFirewallAll()
@@ -138,6 +165,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)
try {
await client.put(path, ruleToRos(body))
await recordFirewall(server, "apply")
return reply.status(201).send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -156,6 +184,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = `${firewallRestPath(body.family as FirewallFamily, body.table as FirewallTable)}/${encodeRosId(body.rosId)}`
try {
await client.patch(path, ruleToRos(body))
await recordFirewall(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -174,6 +203,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
try {
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
await recordFirewall(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -192,6 +222,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = `${firewallRestPath(body.family, body.table)}/${encodeRosId(body.rosId)}`
try {
await client.delete(path)
await recordFirewall(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -213,6 +244,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
numbers: body.rosId,
...(body.destinationRosId ? { destination: body.destinationRosId } : {}),
})
await recordFirewall(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -230,6 +262,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
await client.put(firewallRestPath(body.family, "address-list"), addressToRos(body))
await recordFirewall(server, "apply")
return reply.status(201).send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -248,6 +281,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try {
await client.patch(path, addressToRos(body))
await recordFirewall(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -266,6 +300,7 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try {
await client.patch(path, { disabled: body.disabled ? "yes" : "no" })
await recordFirewall(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
@@ -284,11 +319,48 @@ const firewallRoutes: FastifyPluginAsyncZod = async (app) => {
const path = `${firewallRestPath(body.family, "address-list")}/${encodeRosId(body.rosId)}`
try {
await client.delete(path)
await recordFirewall(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.get("/firewall/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, "firewall")
return reply.send({ revisions })
})
app.post("/firewall/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined
const loaded = await loadRevisionForRestore({
id,
section: "firewall",
requestedServerId: parseDbServerId(body?.serverId),
})
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
const client = MikrotikClient.fromServer(loaded.server)
try {
const desired = parseFirewallSnapshot(loaded.row.payload)
const state = await fetchFirewallState(loaded.server)
const ops = planFirewallRestore(desired, {
rules: state.liveRules,
addressLists: state.liveLists,
})
await executeRosOps(client, ops)
await recordFirewall(loaded.server, "rollback")
const next = await fetchFirewallState(loaded.server)
return reply.send({ ok: true, rules: next.rules, addressLists: next.addressLists })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
}
export default firewallRoutes
+229
View File
@@ -0,0 +1,229 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { z } from "zod"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import { getEnabledServerById } from "../services/wireguard-live.js"
import {
captureGreSnapshot,
fetchGreState,
formatKeepalive,
listGreTunnels,
parseKeepalive,
} from "../services/gre-live.js"
import {
canonicalGreSnapshot,
parseGreSnapshot,
planGreCreate,
planGreDelete,
planGreRestore,
} from "../services/entity-snapshots.js"
import { executeRosOps } from "../services/ros-ops.js"
import {
captureAndAppendRevision,
listRevisions,
loadRevisionForRestore,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
import { parseDbServerId } from "../utils/server-id.js"
const TunnelWriteSchema = z.object({
serverId: z.string().min(1),
name: z.string().min(1),
rosId: z.string().optional(),
localAddress: z.string().optional(),
remoteAddress: z.string().min(1),
localInnerIp: z.string().optional(),
remoteInnerIp: z.string().optional(),
comment: z.string().optional(),
enabled: z.boolean().optional(),
mtu: z.number().optional(),
keepaliveInterval: z.number().optional(),
keepaliveRetries: z.number().optional(),
dscp: z.union([z.literal("inherit"), z.number(), z.string()]).optional(),
clampTcpMss: z.boolean().optional(),
allowFastPath: z.boolean().optional(),
ipsecSecret: z.string().optional(),
})
const TunnelKeySchema = z.object({
serverId: z.string().min(1),
rosId: z.string().optional(),
name: z.string().optional(),
disabled: z.boolean().optional(),
})
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
function rosErr(e: unknown): string {
if (e instanceof MikrotikError) return e.message
if (e instanceof Error) return e.message
return String(e)
}
async function recordGre(
server: NonNullable<Awaited<ReturnType<typeof getEnabledServerById>>>,
source: ConfigRevisionSource,
) {
await captureAndAppendRevision({
serverId: server.id,
section: "gre",
source,
capture: () => captureGreSnapshot(server),
})
}
function tunnelFromBody(body: z.infer<typeof TunnelWriteSchema> & { keepaliveInterval?: number; keepaliveRetries?: number }) {
const dscp = body.dscp == null
? "inherit"
: typeof body.dscp === "number"
? String(body.dscp)
: body.dscp
const keepalive = body.keepaliveInterval === undefined && body.keepaliveRetries === undefined
? undefined
: formatKeepalive(body.keepaliveInterval ?? 0, body.keepaliveRetries ?? 10)
return canonicalGreSnapshot({
tunnels: [{
name: body.name,
localAddress: body.localAddress ?? "",
remoteAddress: body.remoteAddress,
localInnerIp: body.localInnerIp ?? "",
remoteInnerIp: body.remoteInnerIp ?? "",
comment: body.comment ?? "",
disabled: body.enabled === false,
mtu: body.mtu ?? 1476,
keepalive: keepalive ?? "0",
dscp,
clampTcpMss: body.clampTcpMss,
allowFastPath: body.allowFastPath,
ipsecSecret: body.ipsecSecret ?? "",
}],
}).tunnels[0]!
}
const greRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/gre/tunnels", async (req, reply) => {
const q = req.query as { serverId?: string | number }
const sid = parseDbServerId(q.serverId)
const result = await listGreTunnels({ serverId: sid !== null ? String(sid) : undefined })
return reply.send(result)
})
app.post("/gre/tunnels", async (req, reply) => {
const parsed = TunnelWriteSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
const tunnel = tunnelFromBody(body)
await executeRosOps(client, planGreCreate(tunnel))
await recordGre(server, "apply")
const state = await fetchGreState(server)
const created = state.tunnels.find((t) => t.name === tunnel.name)
return reply.status(201).send(created ?? { ok: true, name: tunnel.name })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.patch("/gre/tunnels", async (req, reply) => {
const parsed = TunnelWriteSchema.partial().required({ serverId: true }).safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
try {
const state = await fetchGreState(server)
const live = state.gre.find((g) =>
(body.rosId && g.rosId === body.rosId) || (body.name && g.name === body.name),
)
if (!live) return reply.status(404).send({ error: "Туннель не найден" })
const merged = tunnelFromBody({
serverId: body.serverId,
name: body.name || live.name,
localAddress: body.localAddress ?? live.localAddress,
remoteAddress: body.remoteAddress || live.remoteAddress,
localInnerIp: body.localInnerIp ?? state.addrs.find((a) => a.interfaceName === live.name)?.address ?? "",
remoteInnerIp: body.remoteInnerIp,
comment: body.comment ?? live.comment,
enabled: body.enabled ?? !live.disabled,
mtu: body.mtu ?? live.mtu,
keepaliveInterval: body.keepaliveInterval ?? parseKeepalive(live.keepalive).interval,
keepaliveRetries: body.keepaliveRetries ?? parseKeepalive(live.keepalive).retries,
dscp: body.dscp ?? live.dscp,
clampTcpMss: body.clampTcpMss ?? live.clampTcpMss,
allowFastPath: body.allowFastPath ?? live.allowFastPath,
ipsecSecret: body.ipsecSecret ?? live.ipsecSecret,
})
const ops = planGreRestore(
{ tunnels: state.snapshot.tunnels.map((t) => t.name === live.name ? merged : t) },
{ gre: state.gre, addrs: state.addrs },
)
await executeRosOps(state.client, ops)
await recordGre(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.delete("/gre/tunnels", async (req, reply) => {
const parsed = TunnelKeySchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
try {
const state = await fetchGreState(server)
const live = state.gre.find((g) =>
(body.rosId && g.rosId === body.rosId) || (body.name && g.name === body.name),
)
if (!live) return reply.status(404).send({ error: "Туннель не найден" })
await executeRosOps(state.client, planGreDelete(live.name, { gre: state.gre, addrs: state.addrs }))
await recordGre(server, "apply")
return reply.send({ ok: true })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
app.get("/gre/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, "gre")
return reply.send({ revisions })
})
app.post("/gre/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined
const loaded = await loadRevisionForRestore({
id,
section: "gre",
requestedServerId: parseDbServerId(body?.serverId),
})
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
try {
const desired = parseGreSnapshot(loaded.row.payload)
const state = await fetchGreState(loaded.server)
const ops = planGreRestore(desired, { gre: state.gre, addrs: state.addrs })
await executeRosOps(state.client, ops)
await recordGre(loaded.server, "rollback")
const next = await fetchGreState(loaded.server)
return reply.send({ ok: true, tunnels: next.tunnels })
} catch (e) {
return reply.status(502).send({ error: `RouterOS: ${rosErr(e)}` })
}
})
}
export default greRoutes
+78
View File
@@ -18,6 +18,8 @@ import {
type WgParsedConfig,
} from "../services/wireguard-config.js"
import {
captureWireguardSnapshot,
fetchWireguardRestoreState,
getEnabledServerById,
listWireGuardInterfaces,
} from "../services/wireguard-live.js"
@@ -27,6 +29,16 @@ import {
putWireguardPeer,
toRosBody,
} from "../services/wireguard-ros.js"
import {
captureAndAppendRevision,
listRevisions,
loadRevisionForRestore,
type ConfigRevisionSource,
} from "../services/config-revisions.js"
import { parseWireguardSnapshot, planWireguardRestore } from "../services/entity-snapshots.js"
import { executeRosOps } from "../services/ros-ops.js"
import { parseDbServerId } from "../utils/server-id.js"
import { z } from "zod"
function serverIdParam(v: string): string {
return decodeURIComponent(v)
@@ -137,6 +149,20 @@ function findIface(
return list.find((i) => i.serverId === serverId && i.name === interfaceName)
}
async function recordWireguard(
server: NonNullable<Awaited<ReturnType<typeof getEnabledServerById>>>,
source: ConfigRevisionSource,
) {
await captureAndAppendRevision({
serverId: server.id,
section: "wireguard",
source,
capture: () => captureWireguardSnapshot(server),
})
}
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/wireguard", async (req, reply) => {
const q = req.query as { serverId?: string; includePrivateKey?: string }
@@ -145,6 +171,11 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
serverId: q.serverId,
includePrivateKey,
})
const sid = parseDbServerId(q.serverId)
if (sid !== null) {
const server = await getEnabledServerById(sid)
if (server) await recordWireguard(server, "observed")
}
return reply.send(result)
})
@@ -181,6 +212,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
includePrivateKey: true,
})
const created = list.interfaces.find((i) => i.name === body.name)
await recordWireguard(server, "apply")
return reply.status(201).send(created ?? { ok: true, name: body.name })
} catch (e) {
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
@@ -210,6 +242,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
}),
)
await recordWireguard(server, "apply")
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
@@ -224,6 +257,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
await client.delete(`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`)
await recordWireguard(server, "apply")
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
@@ -242,6 +276,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
await putWireguardPeer(client, peerToRosBody(body))
await recordWireguard(server, "apply")
return reply.status(201).send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
@@ -277,6 +312,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
}),
)
await recordWireguard(server, "apply")
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
@@ -291,6 +327,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`)
await recordWireguard(server, "apply")
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
@@ -320,6 +357,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const client = MikrotikClient.fromServer(server)
try {
const applied = await applyParsedConfig(client, config)
await recordWireguard(server, "copy")
return reply.send({ dryRun: false, preview, applied })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
@@ -437,6 +475,46 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
content,
})
})
app.get("/wireguard/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, "wireguard")
return reply.send({ revisions })
})
app.post("/wireguard/revisions/:id/restore", {
schema: { params: RevisionIdParamSchema },
}, async (req, reply) => {
const { id } = req.params
const body = req.body as { serverId?: string | number } | undefined
const loaded = await loadRevisionForRestore({
id,
section: "wireguard",
requestedServerId: parseDbServerId(body?.serverId),
})
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
try {
const desired = parseWireguardSnapshot(loaded.row.payload)
const state = await fetchWireguardRestoreState(loaded.server)
const ops = planWireguardRestore(desired, {
ifaces: state.ifaces,
peers: state.peers,
addrs: state.addrs,
})
await executeRosOps(state.client, ops)
await recordWireguard(loaded.server, "rollback")
const list = await listWireGuardInterfaces({
serverId: String(loaded.server.id),
includePrivateKey: true,
})
return reply.send({ ok: true, interfaces: list.interfaces })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
}
export default wireguardRoutes