Files
MikrotikManager/backend/src/routes/firewall.ts
T
DenozordecandCursor 25b82997b6
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
feat(config-revisions): implement configuration history for Firewall, GRE, and WireGuard
- 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 <[email protected]>
2026-09-11 14:03:00 +07:00

367 lines
14 KiB
TypeScript

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 {
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"])
const TableSchema = z.enum(["filter", "nat", "mangle", "raw"])
const RuleKeySchema = z.object({
serverId: z.string().min(1),
family: FamilySchema,
table: TableSchema,
rosId: z.string().min(1),
})
const RuleWriteSchema = z.object({
serverId: z.string().min(1),
family: FamilySchema,
table: TableSchema,
rosId: z.string().min(1).optional(),
chain: z.string().min(1),
action: z.string().min(1),
protocol: z.string().optional(),
srcAddress: z.string().optional(),
dstAddress: z.string().optional(),
srcAddressList: z.string().optional(),
dstAddressList: z.string().optional(),
srcPort: z.string().optional(),
dstPort: z.string().optional(),
inInterface: z.string().optional(),
outInterface: z.string().optional(),
connectionState: z.string().optional(),
comment: z.string().optional(),
disabled: z.boolean().optional(),
log: z.boolean().optional(),
logPrefix: z.string().optional(),
tlsHost: z.string().optional(),
layer7Proto: z.string().optional(),
})
const RulePatchSchema = RuleKeySchema.extend({
disabled: z.boolean(),
})
const RuleMoveSchema = RuleKeySchema.extend({
destinationRosId: z.string().min(1).optional(),
})
const AddressKeySchema = z.object({
serverId: z.string().min(1),
family: FamilySchema,
rosId: z.string().min(1),
})
const AddressWriteSchema = z.object({
serverId: z.string().min(1),
family: FamilySchema,
rosId: z.string().min(1).optional(),
list: z.string().min(1),
address: z.string().min(1),
comment: z.string().optional(),
timeout: z.string().optional(),
disabled: z.boolean().optional(),
})
const AddressPatchSchema = AddressKeySchema.extend({
disabled: z.boolean(),
})
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(obj)) {
if (v !== undefined && v !== "") out[k] = v
}
return out
}
function ruleToRos(d: z.infer<typeof RuleWriteSchema>): Record<string, string> {
return toRosBody({
chain: d.chain,
action: d.action,
protocol: d.protocol && d.protocol !== "all" ? d.protocol : undefined,
"src-address": d.srcAddress,
"dst-address": d.dstAddress,
"src-address-list": d.srcAddressList,
"dst-address-list": d.dstAddressList,
"src-port": d.srcPort,
"dst-port": d.dstPort,
"in-interface": d.inInterface,
"out-interface": d.outInterface,
"connection-state": d.connectionState,
comment: d.comment,
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
log: d.log === true ? "yes" : d.log === false ? "no" : undefined,
"log-prefix": d.logPrefix,
"tls-host": d.tlsHost,
"layer7-protocol": d.layer7Proto,
})
}
function addressToRos(d: z.infer<typeof AddressWriteSchema>): Record<string, string> {
return toRosBody({
list: d.list,
address: d.address,
comment: d.comment,
timeout: d.timeout,
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
})
}
function rosErr(e: unknown): string {
if (e instanceof MikrotikError) return e.message
if (e instanceof Error) return e.message
return String(e)
}
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()
return reply.send(data)
})
app.post("/firewall/rules", async (req, reply) => {
const parsed = RuleWriteSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
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)}` })
}
})
app.put("/firewall/rules", async (req, reply) => {
const parsed = RuleWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
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)}` })
}
})
app.patch("/firewall/rules", async (req, reply) => {
const parsed = RulePatchSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
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)}` })
}
})
app.delete("/firewall/rules", async (req, reply) => {
const parsed = RuleKeySchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
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)}` })
}
})
app.post("/firewall/rules/move", async (req, reply) => {
const parsed = RuleMoveSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const path = `${firewallRestPath(body.family, body.table)}/move`
try {
await client.post(path, {
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)}` })
}
})
app.post("/firewall/address-lists", async (req, reply) => {
const parsed = AddressWriteSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
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)}` })
}
})
app.put("/firewall/address-lists", async (req, reply) => {
const parsed = AddressWriteSchema.extend({ rosId: z.string().min(1) }).safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
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)}` })
}
})
app.patch("/firewall/address-lists", async (req, reply) => {
const parsed = AddressPatchSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
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)}` })
}
})
app.delete("/firewall/address-lists", async (req, reply) => {
const parsed = AddressKeySchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = await requireServer(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
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