fix(ui): сохранять недоступные срезы Firewall

This commit is contained in:
Denozordec committed 2026-09-30 10:46:36 +07:00
1 parent 59440555cd
commit 5e6057842f
11 files changed
+228 -77

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@
"test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts",
"test:ros-file": "tsx src/services/ros-file-contents.test.ts",
"test:backups": "tsx src/services/s3-backup-client.test.ts",
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts",
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts",
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:live-maps && npm run test:config-sync && npm run test:ros-file",
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts"
},
+19 -43
View File
@@ -1,3 +1,4 @@
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"
@@ -150,15 +151,6 @@ export function mapAddressList(
}
}
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"
}
@@ -210,28 +202,17 @@ export function mapFirewallSnapshotList(
}
}
export async function fetchFirewallState(server: ServerRow): Promise<{
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 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 { 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[] = []
@@ -256,6 +237,7 @@ export async function fetchFirewallState(server: ServerRow): Promise<{
addressLists,
liveRules,
liveLists,
failures,
snapshot: canonicalFirewallSnapshot({
rules: liveRules.filter((r) => !r.dynamic),
addressLists: liveLists.filter((e) => !e.dynamic),
@@ -279,28 +261,22 @@ export async function captureFirewallSnapshot(server: ServerRow): Promise<Firewa
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) => {
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[] }
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: perServer.flatMap((r) => r.rules),
addressLists: perServer.flatMap((r) => r.addressLists),
}
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 }
@@ -0,0 +1,37 @@
import assert from "node:assert/strict"
import { pollFirewallChunks } from "./firewall-poll.js"
import { fetchFirewallState, captureFirewallSnapshot } from "./firewall-live.js"
import { MikrotikClient } from "./mikrotik.js"
const client = {
getFirewallRules: async (family: string, table: string) => {
if (family === "ip6" && table === "nat") throw new Error("slice unavailable")
return [{ ".id": "*1", chain: "forward", action: "accept", packets: "1" }]
},
getFirewallAddressList: async () => [],
}
const partial = await pollFirewallChunks(client)
assert.equal(partial.ruleChunks.length, 8)
assert.equal(partial.listChunks.length, 2)
assert.deepEqual(partial.failures, [{ family: "ip6", table: "nat", error: "slice unavailable" }])
assert.equal(partial.ruleChunks.filter(chunk => chunk.raw.length === 1).length, 7)
const empty = await pollFirewallChunks({ getFirewallRules: async () => [], getFirewallAddressList: async () => [] })
assert.deepEqual(empty.failures, [])
assert.ok(empty.ruleChunks.every(chunk => chunk.raw.length === 0))
const failed = await pollFirewallChunks({ getFirewallRules: async () => { throw new Error("failed") }, getFirewallAddressList: async () => { throw new Error("failed") } })
assert.equal(failed.failures.length, 10)
const malformed = await pollFirewallChunks({ getFirewallRules: async () => null as never, getFirewallAddressList: async () => [] })
assert.equal(malformed.failures.length, 8)
assert.ok(malformed.failures.every(failure => failure.error.includes("некорректный список")))
const fromServer = MikrotikClient.fromServer
try {
MikrotikClient.fromServer = () => client as unknown as ReturnType<typeof fromServer>
const server = { id: 1, name: "Router", host: "192.0.2.1" } as Parameters<typeof fetchFirewallState>[0]
await assert.rejects(fetchFirewallState(server), /Не удалось полностью прочитать Firewall/)
await assert.rejects(captureFirewallSnapshot(server), /Не удалось полностью прочитать Firewall/)
const state = await fetchFirewallState(server, true)
assert.equal(state.rules.length, 7)
assert.deepEqual(state.failures, partial.failures)
} finally { MikrotikClient.fromServer = fromServer }
console.log("Firewall polling: mixed/empty/all-failed/malformed and strict snapshot rejection passed")
+26
View File
@@ -0,0 +1,26 @@
import type { FirewallFamily, FirewallTable, RosFirewallFilter, RosFirewallAddressList } from "../types/server.js"
export type FirewallChunkFailure = { family: FirewallFamily; table: FirewallTable | "address-list"; error: string }
export async function pollFirewallChunks(client: {
getFirewallRules: (family: FirewallFamily, table: FirewallTable) => Promise<RosFirewallFilter[]>
getFirewallAddressList: (family: FirewallFamily) => Promise<RosFirewallAddressList[]>
}) {
const failures: FirewallChunkFailure[] = []
async function read<T>(family: FirewallFamily, table: FirewallChunkFailure["table"], fetch: () => Promise<T[]>): Promise<T[]> {
try {
const rows = await fetch()
if (!Array.isArray(rows)) throw new Error("RouterOS вернул некорректный список")
return rows
} catch (e) {
failures.push({ family, table, error: e instanceof Error ? e.message : "Ошибка опроса Firewall" })
return []
}
}
const families: FirewallFamily[] = ["ip", "ip6"]
const tables: FirewallTable[] = ["filter", "nat", "mangle", "raw"]
const [ruleChunks, listChunks] = await Promise.all([
Promise.all(families.flatMap(family => tables.map(async table => ({ family, table, raw: await read(family, table, () => client.getFirewallRules(family, table)) })))),
Promise.all(families.map(async family => ({ family, raw: await read(family, "address-list", () => client.getFirewallAddressList(family)) }))),
])
return { ruleChunks, listChunks, failures }
}