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]>
307 lines
8.0 KiB
TypeScript
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 }
|