import { ipv6ToInt } from "../../services/traffic-flow-ip.js" import { isConfirmedRoutingFibTable } from "../../services/routing-table-fib.js" import { routeGatewayContext } from "./routing-community-context-model.js" type Row = Record export function confirmedEcmpTable(rows: readonly Row[], name: string) { const matches = rows.filter(row => row.name === name) if (matches.length !== 1) return undefined const table = matches[0], id = table[".id"] // The /routing/table catalogue can return an empty FIB value for a non-FIB table. if (typeof id !== "string" || !id.trim() || table.fib === "" || !isConfirmedRoutingFibTable(table)) return undefined return { id } } export type PreflightObservation = { rows: Row[]; error: string | null } export const preflightPaths = (family: "ipv4" | "ipv6") => { const base = family === "ipv4" ? "ip" : "ipv6" return { routes: `/${base}/route`, settings: `/${base}/settings`, filter: `/${base}/firewall/filter`, nat: `/${base}/firewall/nat`, mangle: `/${base}/firewall/mangle`, raw: `/${base}/firewall/raw`, rules: "/routing/rule", tables: "/routing/table", vrfs: "/ip/vrf", connectionTracking: "/ip/firewall/connection/tracking" } } export type PreflightSource = keyof ReturnType export function preflightObservation(raw: unknown, singleton = false): PreflightObservation { const rows = singleton && !Array.isArray(raw) && raw && typeof raw === "object" ? [raw] : raw if (!Array.isArray(rows) || singleton && rows.length !== 1 || rows.some(row => !row || typeof row !== "object" || Array.isArray(row))) return { rows: [], error: "Некорректный ответ устройства" } if (!singleton) { const ids = rows.map(row => row[".id"]) if (ids.some(id => typeof id !== "string" || !id.trim()) || new Set(ids).size !== ids.length) return { rows: [], error: "Неоднозначные ID наблюдаемого каталога" } } return { rows, error: null } } const text = (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : null const flag = (value: unknown) => value === true || value === "true" || value === "yes" ? true : value === false || value === "false" || value === "no" ? false : null export function ecmpAdjacency(value: unknown, interfaces?: ReadonlySet) { if (typeof value !== "string" || !value || /[\r\n,]/.test(value)) return null try { const hop = routeGatewayContext(value) const address = hop.family === 6 ? ipv6ToInt(hop.address) : hop.address // fe80::/10 needs a confirmed link to identify the adjacency. if (address === null || (typeof address === "bigint" && address >> 118n === 0x3fan && hop.interface === null)) return null return { address, interface: hop.interface } } catch { return interfaces?.has(value) ? { address: null, interface: value } : null } } export function confirmedEcmpInterfaces(rows: readonly Row[]) { const names = new Map() const ambiguous = new Set() for (const row of rows) { if (typeof row.name !== "string" || !row.name.trim() || typeof row[".id"] !== "string" || !row[".id"].trim()) continue if (names.has(row.name)) ambiguous.add(row.name) names.set(row.name, row[".id"]) } for (const name of ambiguous) names.delete(name) return names } export function confirmedEcmpInterfaceNames(rows: readonly Row[]) { return new Set(confirmedEcmpInterfaces(rows).keys()) } export function duplicateEcmpAdjacency(hops: ReturnType[]) { return hops.some((hop, index) => hop && hops.slice(0, index).some(previous => previous && previous.address === hop.address && (previous.interface === hop.interface || previous.interface === null || hop.interface === null))) } /** Configuration evidence only. Rule presence does not prove packet matching, * physical independence, NAT affinity, return path or traffic distribution. */ export function analyzeEcmpPreflight(family: "ipv4" | "ipv6", routeIds: string[], observed: Record, inventory?: PreflightObservation) { const findings: { code: string; level: "review" | "unknown"; resource: string; ids: string[]; objectCount: number; idsTruncated: boolean; message: string }[] = [] const add = (code: string, level: "review" | "unknown", resource: string, ids: string[], message: string) => findings.push({ code, level, resource, ids: ids.slice(0, 100), objectCount: ids.length, idsTruncated: ids.length > 100, message }) const sources = inventory ? { ...observed, interfaces: inventory } : observed for (const [resource, observation] of Object.entries(sources)) if (observation.error) add("OBSERVATION_UNAVAILABLE", "unknown", resource, [], observation.error) const candidates = routeIds.map(id => observed.routes.rows.find(row => row[".id"] === id)) const selected = candidates.filter((row): row is Row => !!row) if (selected.length !== routeIds.length) add("ROUTE_NOT_OBSERVED", "unknown", "routes", routeIds.filter((_id, index) => !candidates[index]), "Не все выбранные маршруты подтверждены") const routeSummary = selected.map(row => ({ id: String(row[".id"]), prefix: text(row["dst-address"]), table: text(row["routing-table"]), gateway: text(row.gateway), immediateGateway: text(row["immediate-gw"]), active: flag(row.active), disabled: flag(row.disabled), distance: text(row.distance) })) if (routeSummary.some(row => !row.prefix || !row.table || !row.gateway || !row.immediateGateway || row.disabled === null)) add("ROUTE_CONTEXT_UNKNOWN", "unknown", "routes", routeSummary.map(row => row.id), "Не подтверждены prefix/table/gateway/disabled; отсутствие свойства не считается значением по умолчанию") if (routeSummary.some(row => row.disabled === true)) add("DISABLED_ROUTE", "review", "routes", routeSummary.filter(row => row.disabled === true).map(row => row.id), "В наборе есть отключённые маршруты") if (new Set(routeSummary.map(row => row.prefix).filter(Boolean)).size > 1 || new Set(routeSummary.map(row => row.table).filter(Boolean)).size > 1) add("DIFFERENT_ROUTE_CONTEXTS", "review", "routes", routeSummary.map(row => row.id), "Prefix или routing table выбранных маршрутов различаются") if (observed.tables.error === null) { const unconfirmed = new Set() for (const table of new Set(routeSummary.map(row => row.table))) { if (table !== null && !confirmedEcmpTable(observed.tables.rows, table)) unconfirmed.add(table) } const affected = routeSummary.filter(row => row.table !== null && unconfirmed.has(row.table)) if (affected.length) add("FIB_TABLE_UNCONFIRMED", "unknown", "routes", affected.map(row => row.id), "Для выбранных маршрутов не подтверждена однозначная активная таблица FIB") } const knownGateways = routeSummary.map(row => row.gateway).filter(Boolean) if (new Set(knownGateways).size !== knownGateways.length) add("DUPLICATE_GATEWAY", "review", "routes", routeSummary.map(row => row.id), "Next-hop выбранных маршрутов повторяется") const names = inventory?.error === null ? confirmedEcmpInterfaceNames(inventory.rows) : undefined const hops = selected.map(row => ecmpAdjacency(row["immediate-gw"], names)) if (duplicateEcmpAdjacency(hops)) add("DUPLICATE_IMMEDIATE_GATEWAY", "review", "routes", routeSummary.map(row => row.id), "Выбранные маршруты разрешаются в один и тот же next-hop") const interfaces = hops.map(hop => hop?.interface ?? null) if (interfaces.some(iface => iface === null)) add("EGRESS_INTERFACE_UNKNOWN", "unknown", "routes", routeSummary.map(row => row.id), "Не подтверждён интерфейс immediate gateway") else if (new Set(interfaces).size < interfaces.length) add("SHARED_EGRESS_INTERFACE", "review", "routes", routeSummary.map(row => row.id), "Несколько next-hop используют один интерфейс; независимость путей не подтверждена") const maybeEnabled = (resource: PreflightSource) => { const rows = observed[resource].rows.filter(row => flag(row.disabled) !== true) const unknown = rows.filter(row => flag(row.disabled) === null) if (unknown.length) add("RULE_ENABLEMENT_UNKNOWN", "unknown", resource, unknown.map(row => String(row[".id"])), "Для части объектов не подтверждён disabled; они остаются в проверяемом наборе") return rows } const fastTrack = maybeEnabled("filter").filter(row => row.action === "fasttrack-connection") const nat = maybeEnabled("nat"), mangle = maybeEnabled("mangle") const raw = maybeEnabled("raw").filter(row => row.action === "notrack" || row.action === "jump"), rules = maybeEnabled("rules") if (fastTrack.length) add("FASTTRACK_REVIEW", "review", "filter", fastTrack.map(row => String(row[".id"])), "Обнаружен FastTrack: проверить исключение трафика вне main и влияние на наблюдаемость") if (nat.length) add("NAT_AFFINITY_REVIEW", "review", "nat", nat.map(row => String(row[".id"])), "Проверить NAT, привязку соединений к выходу и обратный путь; совпадение пакетов не вычислялось") if (mangle.length || rules.length) add("POLICY_ROUTING_REVIEW", "review", "routing", [...mangle, ...rules].map(row => String(row[".id"])), "Обнаружены маркировки/переходы или routing rules; проверить фактическую таблицу и асимметрию") if (raw.length) add("NOTRACK_REVIEW", "review", "raw", raw.map(row => String(row[".id"])), "Проверить notrack и переходы raw относительно NAT/stateful firewall") const settings = observed.settings.rows[0] ?? {}, hashKey = family === "ipv4" ? "ipv4-multipath-hash-policy" : "multipath-hash-policy" const hashValue = text(settings[hashKey]), hashPolicy = hashValue && ["l3", "l4", "l3-inner"].includes(hashValue) ? hashValue : null if (!hashPolicy) add("HASH_POLICY_UNKNOWN", "unknown", "settings", [], "Устройство не подтвердило hash policy") const rpfValue = text(settings["rp-filter"]), rpf = family === "ipv4" && rpfValue && ["no", "loose", "strict"].includes(rpfValue) ? rpfValue : null if (family === "ipv4" && !rpf) add("RPF_UNKNOWN", "unknown", "settings", [], "Режим IPv4 RPF не подтверждён") if (rpf === "strict") add("STRICT_RPF_REVIEW", "review", "settings", [], "Strict RPF требует проверки обратного пути и совместимости с таблицами/асимметрией") const trackingRaw = observed.connectionTracking.rows[0]?.enabled, trackingValue = typeof trackingRaw === "boolean" ? String(trackingRaw) : text(trackingRaw) const connectionTracking = trackingValue && ["auto", "yes", "no", "true", "false"].includes(trackingValue) ? trackingValue : null if (!connectionTracking) add("CONNECTION_TRACKING_UNKNOWN", "unknown", "connectionTracking", [], "Режим connection tracking не подтверждён") else if (connectionTracking === "no" || connectionTracking === "false") add("CONNECTION_TRACKING_DISABLED", "review", "connectionTracking", [], "Connection tracking отключён; проверить зависимости NAT/stateful firewall") return { family, decision: "review-required", configurationObserved: !findings.some(item => item.level === "unknown"), networkValidation: "not-performed", independentPaths: "not-proven", routeSummary, hashPolicy, rpf: { supported: family === "ipv4", mode: rpf }, connectionTracking, sources: Object.fromEntries(Object.entries(sources).map(([key, value]) => [key, { observed: value.error === null, count: value.rows.length }])), findings } }