Files
MikrotikManager/backend/src/services/firewall-live.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

307 lines
8.0 KiB
TypeScript

import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import {
MikrotikClient,
firewallRestPath,
} from "./mikrotik.js"
import {
captureAndAppendRevision,
} from "./config-revisions.js"
import type {
FirewallFamily,
FirewallTable,
RosFirewallAddressList,
RosFirewallFilter,
} from "../types/server.js"
import {
canonicalFirewallSnapshot,
type FirewallLiveList,
type FirewallLiveRule,
type FirewallSnapshot,
} from "./entity-snapshots.js"
type ServerRow = typeof servers.$inferSelect
export interface FirewallRuleDto {
id: string
rosId: string
serverId: string
serverName: string
family: FirewallFamily
table: FirewallTable
chain: string
action: string
proto: string
src: string
dst: string
port: string
iface: string
comment: string
enabled: boolean
hits: number
log: boolean
logPrefix: string
tlsHost?: string
layer7Proto?: string
}
export interface FirewallAddressListDto {
id: string
rosId: string
serverId: string
serverName: string
family: FirewallFamily
list: string
address: string
comment: string
disabled: boolean
timeout?: string
}
const TABLES: FirewallTable[] = ["filter", "nat", "mangle", "raw"]
const FAMILIES: FirewallFamily[] = ["ip", "ip6"]
function dash(v: string | undefined): string {
const s = v?.trim() ?? ""
return s.length > 0 ? s : "—"
}
function rosDisabled(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
function parseHits(raw: RosFirewallFilter): number {
const n = Number.parseInt(raw.packets ?? "0", 10)
return Number.isFinite(n) ? n : 0
}
export function ruleUiId(
serverId: string | number,
family: FirewallFamily,
table: FirewallTable,
rosId: string,
): string {
return `${serverId}:${family}:${table}:${rosId}`
}
export function addressUiId(
serverId: string | number,
family: FirewallFamily,
rosId: string,
): string {
return `${serverId}:${family}:address-list:${rosId}`
}
export function mapFirewallRule(
server: ServerRow,
family: FirewallFamily,
table: FirewallTable,
raw: RosFirewallFilter,
idx: number,
): FirewallRuleDto {
const rosId = raw[".id"] || `*${idx}`
const src = raw["src-address"] || raw["src-address-list"]
const dst = raw["dst-address"] || raw["dst-address-list"]
const port = raw["dst-port"] || raw["src-port"]
const iface = raw["in-interface"] || raw["out-interface"]
return {
id: ruleUiId(server.id, family, table, rosId),
rosId,
serverId: String(server.id),
serverName: server.name || server.host,
family,
table,
chain: raw.chain || "",
action: raw.action || "",
proto: raw.protocol || "all",
src: dash(src),
dst: dash(dst),
port: dash(port),
iface: dash(iface),
comment: raw.comment ?? "",
enabled: !rosDisabled(raw.disabled),
hits: parseHits(raw),
log: raw.log === "true" || raw.log === "yes",
logPrefix: raw["log-prefix"] ?? "",
tlsHost: raw["tls-host"],
layer7Proto: raw["layer7-protocol"],
}
}
export function mapAddressList(
server: ServerRow,
family: FirewallFamily,
raw: RosFirewallAddressList,
idx: number,
): FirewallAddressListDto {
const rosId = raw[".id"] || `*${idx}`
return {
id: addressUiId(server.id, family, rosId),
rosId,
serverId: String(server.id),
serverName: server.name || server.host,
family,
list: raw.list || "",
address: raw.address || "",
comment: raw.comment ?? "",
disabled: rosDisabled(raw.disabled),
timeout: raw.timeout,
}
}
async function safeGet<T>(fn: () => Promise<T[]>, fallback: T[] = []): Promise<T[]> {
try {
const rows = await fn()
return Array.isArray(rows) ? rows : fallback
} catch {
return fallback
}
}
function rosYes(v: string | undefined): boolean {
return v === "true" || v === "yes"
}
export function mapFirewallSnapshotRule(
family: FirewallFamily,
table: FirewallTable,
raw: RosFirewallFilter,
): FirewallLiveRule {
return {
rosId: raw[".id"] || "",
dynamic: rosYes(raw.dynamic),
family,
table,
chain: raw.chain || "",
action: raw.action || "",
protocol: raw.protocol || "",
srcAddress: raw["src-address"] ?? "",
dstAddress: raw["dst-address"] ?? "",
srcAddressList: raw["src-address-list"] ?? "",
dstAddressList: raw["dst-address-list"] ?? "",
srcPort: raw["src-port"] ?? "",
dstPort: raw["dst-port"] ?? "",
inInterface: raw["in-interface"] ?? "",
outInterface: raw["out-interface"] ?? "",
connectionState: raw["connection-state"] ?? "",
comment: raw.comment ?? "",
disabled: rosDisabled(raw.disabled),
log: rosYes(raw.log),
logPrefix: raw["log-prefix"] ?? "",
tlsHost: raw["tls-host"] ?? "",
layer7Proto: raw["layer7-protocol"] ?? "",
}
}
export function mapFirewallSnapshotList(
family: FirewallFamily,
raw: RosFirewallAddressList,
): FirewallLiveList {
return {
rosId: raw[".id"] || "",
dynamic: rosYes(raw.dynamic),
family,
list: raw.list || "",
address: raw.address || "",
comment: raw.comment ?? "",
disabled: rosDisabled(raw.disabled),
timeout: raw.timeout ?? "",
}
}
export async function fetchFirewallState(server: ServerRow): Promise<{
rules: FirewallRuleDto[]
addressLists: FirewallAddressListDto[]
liveRules: FirewallLiveRule[]
liveLists: FirewallLiveList[]
snapshot: FirewallSnapshot
}> {
const client = MikrotikClient.fromServer(server)
const ruleJobs = FAMILIES.flatMap((family) =>
TABLES.map(async (table) => {
const raw = await safeGet(() => client.getFirewallRules(family, table))
return { family, table, raw }
}),
)
const listJobs = FAMILIES.map(async (family) => {
const raw = await safeGet(() => client.getFirewallAddressList(family))
return { family, raw }
})
const [ruleChunks, listChunks] = await Promise.all([
Promise.all(ruleJobs),
Promise.all(listJobs),
])
const rules: FirewallRuleDto[] = []
const liveRules: FirewallLiveRule[] = []
for (const chunk of ruleChunks) {
chunk.raw.forEach((row, idx) => {
rules.push(mapFirewallRule(server, chunk.family, chunk.table, row, idx))
liveRules.push(mapFirewallSnapshotRule(chunk.family, chunk.table, row))
})
}
const addressLists: FirewallAddressListDto[] = []
const liveLists: FirewallLiveList[] = []
for (const chunk of listChunks) {
chunk.raw.forEach((row, idx) => {
addressLists.push(mapAddressList(server, chunk.family, row, idx))
liveLists.push(mapFirewallSnapshotList(chunk.family, row))
})
}
return {
rules,
addressLists,
liveRules,
liveLists,
snapshot: canonicalFirewallSnapshot({
rules: liveRules.filter((r) => !r.dynamic),
addressLists: liveLists.filter((e) => !e.dynamic),
}),
}
}
export async function fetchServerFirewall(server: ServerRow): Promise<{
rules: FirewallRuleDto[]
addressLists: FirewallAddressListDto[]
}> {
const state = await fetchFirewallState(server)
return { rules: state.rules, addressLists: state.addressLists }
}
export async function captureFirewallSnapshot(server: ServerRow): Promise<FirewallSnapshot> {
const state = await fetchFirewallState(server)
return state.snapshot
}
export async function listFirewallAll(): Promise<{
rules: FirewallRuleDto[]
addressLists: FirewallAddressListDto[]
}> {
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
const perServer = await Promise.all(
allServers.map(async (server) => {
try {
const state = await fetchFirewallState(server)
await captureAndAppendRevision({
serverId: server.id,
section: "firewall",
source: "observed",
capture: async () => state.snapshot,
})
return { rules: state.rules, addressLists: state.addressLists }
} catch {
return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[] }
}
}),
)
return {
rules: perServer.flatMap((r) => r.rules),
addressLists: perServer.flatMap((r) => r.addressLists),
}
}
export { firewallRestPath, FAMILIES, TABLES }