/** * Firewall interface classification, reported where an operator already looks. * * The condition that produced `fw-keeper.sh` was not that celilo lacked * information — it was that celilo said nothing. An interface it cannot * attribute is an unowned network segment on a firewall, and one that is never * surfaced is discovered as an outage rather than as a finding. * * What this reports, per firewall: * * - every interface celilo cannot attribute, by name AND address; * - a carrier-grade NAT interface, which celilo refuses to converge on; * - an ambiguous external edge — more than one public leg, none designated; * - a default route leaving through neither `internal` nor `external`. * * The last three are BLOCKING, matching what the converge does with them. An * audit that called them drift while the converge refused would be telling the * operator something different from what the system does. * * Pure, like every other checker here: it consumes a pre-computed view so it is * unit-testable without SSH or a live firewall. * * openspec: firewall-interface-classification §10 (design D1, D2, D11) */ import { type InterfaceAddress, type ZoneDeclaration, classifyInterfaces, defaultRouteFinding, externalEdge, isPubliclyRoutable, subnetContains, } from '@celilo/capabilities'; import type { DriftFinding } from './types'; /** RFC 6598. celilo refuses to converge on one — see design D2 (amended). */ const CARRIER_GRADE_NAT = '100.64.0.0/10'; export interface FirewallInterfaceView { /** * User-facing hostname, and the key the finding is subjected on. * * NOT the machine UUID: suppression resolves a machine's ancestor key from * its hostname, so a finding subjected on a UUID produces an alert key * suppression can never match (#596). Users never see UUIDs either. */ hostname: string; /** Every address the firewall holds, as read from the box. */ interfaces: InterfaceAddress[]; /** The zones this firewall declares, with the subnets that define them. */ zones: ZoneDeclaration[]; /** The interface carrying the default route, when celilo knows it. */ defaultRouteInterface?: string | null; /** The operator's designated edge, from the firewall's `zone.external.ip`. */ designatedExternalIp?: string | null; } /** * Findings for one firewall's interfaces. * * Ordered most-severe first so the report reads top-down: the reasons a * converge would refuse, then the interfaces that are merely unaccounted for. */ export function auditFirewallInterfaces(view: FirewallInterfaceView): DriftFinding[] { const findings: DriftFinding[] = []; const classifications = classifyInterfaces(view.interfaces, view.zones); // ── Blocking: a carrier address celilo cannot reason about ──────────────── const carrier = view.interfaces.filter((i) => subnetContains(CARRIER_GRADE_NAT, i.ip)); for (const iface of carrier) { findings.push({ category: 'interface_classification', severity: 'blocked', code: 'carrier_grade_nat_interface', subject: view.hostname, message: `${view.hostname}: ${iface.name} holds a carrier-grade NAT address (${iface.ip})`, details: "RFC 6598 space belongs to the carrier and is shared with other subscribers, so it cannot tell celilo the fleet's real external address. celilo does not know what such an interface is for, whether to NAT on it, or what reach it should have, so it refuses to converge rather than guessing. Only ISP equipment should hold one.", remediation: 'Remove the carrier-grade NAT interface from this firewall, or place the ISP boundary on separate equipment (celilo drives an upstream router through the firewall capability).', actionable: false, }); } // ── Blocking: two public legs and no designation ────────────────────────── const edge = externalEdge(classifications, view.designatedExternalIp); if (edge.kind === 'ambiguous') { const named = edge.candidates.map((c) => `${c.name} (${c.ip})`).join(', '); findings.push({ category: 'interface_classification', severity: 'blocked', code: 'ambiguous_external_edge', subject: view.hostname, message: `${view.hostname}: ${edge.candidates.length} publicly routable interfaces and none designated — ${named}`, details: 'celilo will not pick an external edge by interface order. Before this check the first non-private address won silently, so a box with two public addresses had its edge chosen by enumeration with no warning and no way to override.', remediation: `celilo module config set zone.external.ip c.ip) .join(', ')}>`, actionable: false, }); } // ── Blocking: egress leaving through a segment celilo cannot describe ───── if (view.defaultRouteInterface !== undefined) { const routeFinding = defaultRouteFinding(classifications, view.defaultRouteInterface); if (routeFinding) { findings.push({ category: 'interface_classification', severity: 'blocked', code: 'default_route_off_edge', subject: view.hostname, message: `${view.hostname}: ${routeFinding.message}`, details: 'celilo supports two shapes: a firewall that owns its WAN egresses through its external leg, and a downstream firewall egresses via an upstream router on the internal segment. Egress anywhere else leaves through a segment celilo cannot describe.', remediation: 'Move the default route onto the external or internal leg, or declare a zone for the interface that carries it.', actionable: false, }); } } // ── Drift: interfaces nobody accounted for ──────────────────────────────── const alien = classifications.filter((c) => c.role === 'alien'); if (alien.length > 0) { const named = alien.map((c) => `${c.name} (${c.ip})`).join(', '); findings.push({ category: 'interface_classification', severity: 'drift', code: 'alien_interfaces', subject: view.hostname, message: `${view.hostname}: ${alien.length} interface(s) celilo cannot attribute — ${named}`, details: 'An interface in no declared zone is an unowned network segment on a firewall. celilo refuses to converge a firewall it is onboarding until every interface is accounted for; on one it has already brought to a known-good state, a NEWLY appeared interface like this is disabled.', remediation: `celilo system config set network..subnet # then add the zone to the firewall's declared zones`, actionable: false, }); } return findings; } /** Every firewall's findings, flattened. */ export interface InterfaceClassificationAuditDeps { views: FirewallInterfaceView[]; /** * Firewalls celilo knows about but whose interface state could not be read * over SSH. Each is an `unmeasured` finding: an interface survey that * cannot see a firewall has measured nothing about it (D7). */ unreachableFirewalls: string[]; } export function auditInterfaceClassification( deps: InterfaceClassificationAuditDeps, ): DriftFinding[] { const unreachable = deps.unreachableFirewalls.map((firewallIp): DriftFinding => { return { category: 'interface_classification', severity: 'unmeasured', code: 'interface_classification_unmeasured', subject: firewallIp, message: `${firewallIp}: interfaces could not be read, so classification is unknown`, remediation: `Check SSH reachability of ${firewallIp}, then re-audit. This finding records that the interfaces were not read, not that they are classified.`, actionable: true, }; }); return [...unreachable, ...deps.views.flatMap(auditFirewallInterfaces)]; } /** * The classification itself, for the report body rather than as a finding. * * §10.2 requires a fully declared firewall to produce NO findings, so the * per-interface roles cannot be findings — they are what an operator reads to * confirm the picture is what they intended. */ export function describeClassification(view: FirewallInterfaceView): string[] { return classifyInterfaces(view.interfaces, view.zones).map((c) => { if (c.role === 'zone') return `${c.name}: ${c.ip} → zone:${c.zone}`; if (c.role === 'external') { return `${c.name}: ${c.ip} → external (the WAN edge)`; } const why = isPubliclyRoutable(c.ip) ? 'publicly routable but unclaimed' : 'no declared subnet contains it'; return `${c.name}: ${c.ip} → alien (${why})`; }); }