fix(ui): сохранять недоступные срезы Firewall
This commit is contained in:
1 parent
59440555cd
commit
5e6057842f
11 files changed
+228
-77
No files matched your search
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user