123 lines
12 KiB
TypeScript
123 lines
12 KiB
TypeScript
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<string, unknown>
|
|
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<typeof preflightPaths>
|
|
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<string>) {
|
|
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<string, string>()
|
|
const ambiguous = new Set<string>()
|
|
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<typeof ecmpAdjacency>[]) {
|
|
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<PreflightSource, PreflightObservation>, 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<string>()
|
|
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 }
|
|
}
|