135 lines
11 KiB
TypeScript
135 lines
11 KiB
TypeScript
import { z } from "zod"
|
|
import { isIP } from "node:net"
|
|
import { ipv4ToInt, ipv6ToInt } from "./traffic-flow-ip.js"
|
|
import type { ServerRow } from "../modules/servers/repository/servers-repository.js"
|
|
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
|
|
import { MikrotikClient, firewallRestPath, encodeRosId } from "./mikrotik.js"
|
|
import { observedBoolean, observedDurationMs } from "./ospf-observed-values.js"
|
|
import { fingerprint } from "../modules/mcp/security.js"
|
|
import { AddressWriteSchema, addressToRos } from "./firewall-address-input.js"
|
|
import { withApplicationMutationLock, withConfigMutationLock } from "./config-mutation-lock.js"
|
|
import { captureAndAppendRevision } from "./config-revisions.js"
|
|
import { fetchFirewallState } from "./firewall-live.js"
|
|
|
|
const key = { family: z.enum(["ip", "ip6"]) }, nativeId = z.string().regex(/^\*[A-Za-z0-9]+$/).max(100)
|
|
export const firewallAddressFieldsSchema = AddressWriteSchema.omit({ serverId: true, family: true, rosId: true }).strict()
|
|
export const firewallAddressMutationSchema = z.discriminatedUnion("operation", [
|
|
z.object({ ...key, operation: z.literal("create"), fields: firewallAddressFieldsSchema }).strict(),
|
|
z.object({ ...key, operation: z.literal("update"), rosId: nativeId, fields: firewallAddressFieldsSchema }).strict(),
|
|
z.object({ ...key, operation: z.literal("toggle"), rosId: nativeId, disabled: z.boolean() }).strict(),
|
|
z.object({ ...key, operation: z.literal("delete"), rosId: nativeId }).strict(),
|
|
])
|
|
export type FirewallAddressMutation = z.infer<typeof firewallAddressMutationSchema>
|
|
export type AddressExpiry = { earliest: number; latest: number }
|
|
export type AddressState = { stable: { serverConfiguration: string; rows: Record<string, string>[] }; timers: Record<string, AddressExpiry> }
|
|
const problem = (message: string) => Object.assign(new Error(message), { statusCode: 409 })
|
|
const same = (a: unknown, b: unknown) => fingerprint(a) === fingerprint(b)
|
|
const bodyFor = (input: FirewallAddressMutation): Record<string, string> => input.operation === "delete" ? {} : input.operation === "toggle" ? { disabled: input.disabled ? "yes" : "no" } : addressToRos({ ...input.fields, serverId: "", family: input.family })
|
|
|
|
/** Compare host, subnet and IPv4 range identities without DNS lookup or mapped-v6 coercion. */
|
|
export function firewallAddressIdentity(raw: string) {
|
|
const range = raw.split("-")
|
|
if (range.length === 2 && range.every(ip => isIP(ip) === 4)) return `4:${ipv4ToInt(range[0])}:${ipv4ToInt(range[1])}`
|
|
const [ip, prefix, extra] = raw.split("/"), family = isIP(ip)
|
|
if (!family || extra !== undefined || prefix !== undefined && !/^\d+$/.test(prefix)) return raw.toLowerCase()
|
|
const bits = family === 4 ? 32 : 128, size = prefix === undefined ? bits : Number(prefix)
|
|
if (size < 0 || size > bits) return raw
|
|
const value = family === 4 ? BigInt(ipv4ToInt(ip)!) : ipv6ToInt(ip)!
|
|
const shift = BigInt(bits - size), start = value >> shift << shift
|
|
return `${family}:${start}:${start + (1n << shift) - 1n}`
|
|
}
|
|
function lifetime(value: string | undefined, dynamic?: string) {
|
|
if (!value || ["none-static", "none-dynamic"].includes(value)) return value || (observedBoolean(dynamic) === false ? "none-static" : "unknown")
|
|
const ms = observedDurationMs(value)
|
|
if (ms === null) throw problem("Не подтверждён timeout address-list")
|
|
return ms > 0 ? "finite" : observedBoolean(dynamic) === false ? "none-static" : "none-dynamic"
|
|
}
|
|
function normalized(row: Record<string, string>) {
|
|
return Object.fromEntries(Object.entries(row).map(([key, value]) => [key, ["disabled", "dynamic", "invalid"].includes(key) ? observedBoolean(value) : key === "address" && typeof value === "string" ? firewallAddressIdentity(value) : value]))
|
|
}
|
|
export async function readFirewallAddressState(server: ServerRow, input: FirewallAddressMutation): Promise<AddressState> {
|
|
if (!server.enabled) throw problem("Сервер отключён")
|
|
const client = MikrotikClient.fromServer(server), path = firewallRestPath(input.family, "address-list"), start = Date.now()
|
|
const raw = await client.get<unknown>(path), end = Date.now()
|
|
if (!Array.isArray(raw) || raw.length > 1000 || raw.some(row => !row || typeof row !== "object" || Array.isArray(row) || Object.values(row).some(value => typeof value !== "string") || [".id", "list", "address"].some(key => typeof row[key] !== "string" || !row[key].trim()))) throw problem("Недостоверный каталог address-list")
|
|
const rows = raw as Record<string, string>[]
|
|
if (new Set(rows.map(row => row[".id"])).size !== rows.length) throw problem("Повторяющиеся ID address-list")
|
|
const targets = rows.filter(row => input.operation === "create" ? row.list === input.fields.list && firewallAddressIdentity(row.address) === firewallAddressIdentity(input.fields.address) : row[".id"] === input.rosId)
|
|
if (targets.length > 50) throw problem("Слишком много совпадающих записей address-list")
|
|
for (const row of targets) for (const field of ["dynamic", "disabled"]) if (row[field] === undefined) {
|
|
const value = await client.post<{ ret?: string }>(`${path}/get`, { ".id": row[".id"], "value-name": field })
|
|
if (typeof value?.ret === "string") row[field] = value.ret
|
|
}
|
|
const timers: AddressState["timers"] = {}
|
|
const stable = rows.map(row => {
|
|
const mode = lifetime(row.timeout, row.dynamic), remaining = mode === "finite" ? observedDurationMs(row.timeout)! : 0
|
|
if (mode === "finite") {
|
|
if (!Number.isSafeInteger(end + remaining + 1000)) throw problem("Срок address-list выходит за допустимый диапазон")
|
|
timers[row[".id"]] = { earliest: start + remaining - 1000, latest: end + remaining + 1000 }
|
|
}
|
|
return { ...row, timeout: mode }
|
|
})
|
|
return { stable: { serverConfiguration: fingerprint(server), rows: stable }, timers }
|
|
}
|
|
export function assertFirewallAddressTarget(input: FirewallAddressMutation, state: AddressState) {
|
|
if (input.operation === "create") return
|
|
const row = state.stable.rows.find(row => row[".id"] === input.rosId)
|
|
if (!row || observedBoolean(row.dynamic) === null) throw problem("Не подтверждена идентичность/происхождение address-list")
|
|
}
|
|
export function expiryMatches(expected: AddressExpiry | undefined, actual: AddressExpiry | undefined) {
|
|
return !expected ? !actual : !!actual && expected.earliest <= actual.latest && actual.earliest <= expected.latest
|
|
}
|
|
export function verifyFirewallAddressMutation(input: FirewallAddressMutation, before: AddressState, next: AddressState, commandWindow: AddressExpiry) {
|
|
if (before.stable.serverConfiguration !== next.stable.serverConfiguration) return false
|
|
const body = bodyFor(input), patch = { ...body }
|
|
if (patch.timeout) patch.timeout = lifetime(patch.timeout, patch.timeout === "none-static" ? "false" : "true")
|
|
let targetId: string
|
|
if (input.operation === "create") {
|
|
const added = next.stable.rows.filter(row => !before.stable.rows.some(previous => previous[".id"] === row[".id"]))
|
|
if (added.length !== 1 || !same(next.stable.rows.filter(row => row !== added[0]), before.stable.rows) || !Object.entries(patch).every(([key, value]) => same(normalized({ [key]: added[0][key] })[key], normalized({ [key]: value })[key]))) return false
|
|
targetId = added[0][".id"]
|
|
} else {
|
|
targetId = input.rosId
|
|
const expected = before.stable.rows.filter(row => input.operation !== "delete" || row[".id"] !== targetId).map(row => row[".id"] === targetId ? { ...row, ...patch, ...(patch.timeout ? { dynamic: patch.timeout === "none-static" ? "false" : "true" } : {}) } : row)
|
|
if (!same(next.stable.rows.map(normalized), expected.map(normalized))) return false
|
|
}
|
|
if (input.operation === "delete") return true
|
|
if (patch.timeout === "finite") {
|
|
const duration = observedDurationMs(body.timeout)!
|
|
return expiryMatches({ earliest: commandWindow.earliest + duration - 1000, latest: commandWindow.latest + duration + 1000 }, next.timers[targetId])
|
|
}
|
|
if (patch.timeout) return !next.timers[targetId]
|
|
return input.operation === "create" ? !next.timers[targetId] : expiryMatches(before.timers[targetId], next.timers[targetId])
|
|
}
|
|
export async function executeFirewallAddressMutation(input: FirewallAddressMutation, expectedFingerprint: string | undefined, expectedExpiry: AddressExpiry | undefined, deps: {
|
|
read: () => Promise<AddressState>; command: (body: Record<string, string>) => Promise<unknown>; recordRevision: () => Promise<boolean>;
|
|
}) {
|
|
const before = await deps.read()
|
|
if (expectedFingerprint && fingerprint(before.stable) !== expectedFingerprint) throw problem("Address-list изменился после подготовки")
|
|
assertFirewallAddressTarget(input, before)
|
|
const body = bodyFor(input)
|
|
if (body.timeout) lifetime(body.timeout, body.timeout === "none-static" ? "false" : "true")
|
|
if (expectedFingerprint && input.operation !== "create" && !expiryMatches(expectedExpiry, before.timers[input.rosId])) throw problem("Срок address-list изменился после подтверждения")
|
|
const start = Date.now(); await deps.command(body); const end = Date.now()
|
|
const next = await deps.read(), verified = verifyFirewallAddressMutation(input, before, next, { earliest: start, latest: end })
|
|
const affectedId = input.operation === "create" ? next.stable.rows.find(row => !before.stable.rows.some(previous => previous[".id"] === row[".id"]))?.[".id"] : input.rosId
|
|
return { acknowledged: true, verified, afterFingerprint: fingerprint(next.stable), affectedId, afterExpiry: affectedId ? next.timers[affectedId] : undefined, revisionRecorded: verified ? await deps.recordRevision() : false }
|
|
}
|
|
export async function writeFirewallAddress(server: ServerRow, raw: FirewallAddressMutation, expectedFingerprint?: string, expectedExpiry?: AddressExpiry) {
|
|
const input = firewallAddressMutationSchema.parse(raw)
|
|
return withApplicationMutationLock(() => withConfigMutationLock([server.id], async () => {
|
|
const fresh = await getServerRowById(server.id)
|
|
if (!fresh?.enabled || !same(fresh, server)) throw problem("Источник address-list изменился")
|
|
const client = MikrotikClient.fromServer(fresh), path = firewallRestPath(input.family, "address-list")
|
|
return executeFirewallAddressMutation(input, expectedFingerprint, expectedExpiry, {
|
|
read: () => readFirewallAddressState(fresh, input),
|
|
command: body => input.operation === "create" ? client.put(path, body) : input.operation === "delete" ? client.delete(`${path}/${encodeRosId(input.rosId)}`) : client.patch(`${path}/${encodeRosId(input.rosId)}`, body),
|
|
recordRevision: async () => (await captureAndAppendRevision({ serverId: fresh.id, section: "firewall", source: "apply", capture: async () => {
|
|
const state = await fetchFirewallState(fresh)
|
|
if (!state.snapshotFlagsVerified) throw problem("Flags ревизии Firewall не подтверждены")
|
|
return state.snapshot
|
|
} })) !== null,
|
|
})
|
|
}))
|
|
}
|