283 lines
8.1 KiB
TypeScript
283 lines
8.1 KiB
TypeScript
import { pollFirewallChunks, type FirewallChunkFailure } from "./firewall-poll.js"
|
|
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,
|
|
}
|
|
}
|
|
|
|
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, allowPartial = false): Promise<{
|
|
rules: FirewallRuleDto[]
|
|
addressLists: FirewallAddressListDto[]
|
|
liveRules: FirewallLiveRule[]
|
|
liveLists: FirewallLiveList[]
|
|
snapshot: FirewallSnapshot
|
|
failures: FirewallChunkFailure[]
|
|
}> {
|
|
const client = MikrotikClient.fromServer(server)
|
|
const { ruleChunks, listChunks, failures } = await pollFirewallChunks(client)
|
|
if (!allowPartial && failures.length > 0) throw new Error("Не удалось полностью прочитать Firewall: " + failures.map(f => f.family + "/" + f.table + ": " + f.error).join("; "))
|
|
|
|
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,
|
|
failures,
|
|
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[]
|
|
failures: Array<Partial<FirewallChunkFailure> & { serverId: string; serverName: string; error: string }>
|
|
}> {
|
|
const allServers = await db.select().from(servers).where(eq(servers.enabled, true))
|
|
const perServer = await Promise.all(allServers.map(async server => {
|
|
const context = { serverId: String(server.id), serverName: server.name || server.host }
|
|
try {
|
|
const state = await fetchFirewallState(server, true)
|
|
if (state.failures.length === 0) {
|
|
await captureAndAppendRevision({ serverId: server.id, section: "firewall", source: "observed", capture: async () => state.snapshot })
|
|
}
|
|
return { rules: state.rules, addressLists: state.addressLists, failures: state.failures.map(f => ({ ...context, ...f })) }
|
|
} catch (e) {
|
|
return { rules: [] as FirewallRuleDto[], addressLists: [] as FirewallAddressListDto[], failures: [{ ...context, error: e instanceof Error ? e.message : "Ошибка опроса Firewall" }] }
|
|
}
|
|
}))
|
|
return { rules: perServer.flatMap(r => r.rules), addressLists: perServer.flatMap(r => r.addressLists), failures: perServer.flatMap(r => r.failures) }
|
|
}
|
|
|
|
export { firewallRestPath, FAMILIES, TABLES }
|