/** * Unowned-trusted-network check. * * A converge renders the firewall's whole ruleset from the registry and applies * it atomically, so a rule nobody registered is a rule nobody owns — and it is * removed. That is correct behaviour, but it is silent, and silence is what * produced `/tmp/fw-keeper.sh`: a shell script re-adding the admin VPN's rules * every second because the VPN had no module to register on its behalf, and * nothing anywhere said so. * * This check reports reach granted on the live firewall to a network celilo does * not recognise, so the next unowned thing surfaces as an actionable gap instead * of as an unexplained outage. * * Pure and dependency-injected: the CLI adapter reads the live ruleset off each * firewall and hands the parsed result in. */ import type { DriftFinding } from './types'; /** How a live FORWARD rule grants reach into a managed zone. */ export type ReachOrigin = { kind: 'subnet'; value: string } | { kind: 'interface'; value: string }; export interface LiveReachRule { origin: ReachOrigin; /** The managed zone subnet the rule permits reaching. */ destSubnet: string; } export interface FirewallReachState { firewallIp: string; /** Reach rules parsed from the firewall's LIVE ruleset (`iptables-save`). */ live: LiveReachRule[]; /** The subnets celilo composes for this firewall (derived + registered + override). */ known: string[]; } export interface TrustedSourcesAuditDeps { firewalls: FirewallReachState[]; /** * Firewalls celilo knows about but whose live ruleset could not be read * (`iptables-save` failed over SSH). Each is an `unmeasured` finding, not * silence: a check that cannot see a firewall has not measured that * firewall, and silence renders as READY (D7). */ unreachableFirewalls: string[]; } /** * Extract reach-granting FORWARD rules from an `iptables-save` dump: any ACCEPT * into one of the managed zone subnets, keyed by what grants it. * * Both shapes matter. celilo renders `-s -d `; a hand-added rule * (or a keeper script) is as likely to be `-i -d `, which celilo * cannot model at all — trust in celilo is by subnet, never by interface. */ export function parseLiveReachRules(rulesetText: string, zoneSubnets: string[]): LiveReachRule[] { const zones = new Set(zoneSubnets); const rules: LiveReachRule[] = []; for (const line of rulesetText.split('\n')) { const trimmed = line.trim(); if (!trimmed.startsWith('-A FORWARD')) continue; if (!/-j\s+ACCEPT\b/.test(trimmed)) continue; const dest = trimmed.match(/-d\s+(\S+)/)?.[1]; if (!dest || !zones.has(dest)) continue; const source = trimmed.match(/-s\s+(\S+)/)?.[1]; if (source) { rules.push({ origin: { kind: 'subnet', value: source }, destSubnet: dest }); continue; } const iface = trimmed.match(/-i\s+(\S+)/)?.[1]; if (iface) { rules.push({ origin: { kind: 'interface', value: iface }, destSubnet: dest }); } } return rules; } function originKey(origin: ReachOrigin): string { return `${origin.kind}:${origin.value}`; } export async function auditTrustedSources(deps: TrustedSourcesAuditDeps): Promise { const findings: DriftFinding[] = []; // Unreachable firewalls first, so the report leads with what could not be // measured at all before anything it did measure. for (const firewallIp of deps.unreachableFirewalls) { findings.push({ category: 'trusted_sources', severity: 'unmeasured', code: 'trusted_sources_unmeasured', message: `${firewallIp}: live ruleset could not be read, so trusted-source drift is unknown`, remediation: `Check SSH reachability of ${firewallIp}, then re-audit. This finding records that the ruleset was not read, not that it is clean.`, actionable: true, subject: firewallIp, }); } for (const firewall of deps.firewalls) { const known = new Set(firewall.known); const reported = new Set(); for (const rule of firewall.live) { // A subnet celilo composes is owned by definition — that rule IS the // rendered output. if (rule.origin.kind === 'subnet' && known.has(rule.origin.value)) continue; if (reported.has(originKey(rule.origin))) continue; reported.add(originKey(rule.origin)); const byInterface = rule.origin.kind === 'interface'; findings.push({ category: 'trusted_sources', severity: 'drift', code: 'unowned_trusted_network', message: byInterface ? `${firewall.firewallIp}: interface ${rule.origin.value} reaches managed zones, but celilo models no such trust` : `${firewall.firewallIp}: ${rule.origin.value} reaches managed zones, but celilo does not recognise that network`, details: [ 'The live ruleset permits this reach; celilo does not render it.', 'The next converge rebuilds the ruleset from the registry and will', 'remove it, with no error and no explanation at the point of failure.', ...(byInterface ? [ '', 'celilo expresses trust by SUBNET, never by interface — an', 'interface-scoped rule cannot be registered as-is. Find the', 'network behind that interface and register THAT.', ] : []), ].join('\n'), remediation: byInterface ? 'Deploy a module that owns this network and registers its client subnet as a trusted source (the wireguard module does this for the admin VPN), or record it with `celilo system config set firewall.trusted_subnets `' : `Deploy a module that registers ${rule.origin.value} as a trusted source, or record it with \`celilo system config set firewall.trusted_subnets ${rule.origin.value}\``, actionable: false, subject: firewall.firewallIp, }); } } return findings; }