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 <[email protected]>
367 lines
14 KiB
TypeScript
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
|