/** * Collect what each firewall's LIVE ruleset permits to reach the managed zones, * alongside what celilo composes for it — the input to the unowned-trusted- * network audit check. * * The live read is `iptables-save`, not celilo's own `rules.v4`: a rule added * out of band (the `fw-keeper.sh` case) exists in the running table and not in * the file celilo renders, and that difference is exactly the finding. */ import { type Runner, execRunner, runAppCommand } from '@celilo/capabilities'; import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { capabilities, moduleConfigs, systemConfig } from '../db/schema'; import { loadTrustedSubnets } from '../hooks/capability-loader'; import type { FirewallReachState } from './audit/trusted-sources'; import { parseLiveReachRules } from './audit/trusted-sources'; /** The segmented tiers the renderer builds its zone matrix from. */ const ZONE_TIERS = ['dmz', 'app', 'secure'] as const; const IPTABLES_SAVE_TIMEOUT_MS = 15_000; /** Firewall hosts celilo knows about, by the `firewall_ip` in their module config. */ export function listFirewallIps(db: DbClient): string[] { const providers = db .select() .from(capabilities) .where(eq(capabilities.capabilityName, 'firewall')) .all(); const ips: string[] = []; for (const provider of providers) { const row = db .select() .from(moduleConfigs) .where(eq(moduleConfigs.moduleId, provider.moduleId)) .all() .find((c) => c.key === 'firewall_ip'); if (row?.value) ips.push(row.value); } return [...new Set(ips)]; } export function loadZoneSubnets(db: DbClient): string[] { const subnets: string[] = []; for (const zone of ZONE_TIERS) { const row = db .select() .from(systemConfig) .where(eq(systemConfig.key, `network.${zone}.subnet`)) .get(); if (row?.value) subnets.push(row.value); } return subnets; } /** * Read each firewall's live ruleset and pair it with celilo's composed trusted * set. Firewalls whose ruleset cannot be read are reported separately, as * `unreachable` — the audit turns each into an `unmeasured` finding, because * an unreadable box is unknown, not clean (D7). */ export interface FirewallReachCollection { firewalls: FirewallReachState[]; unreachableFirewalls: string[]; } export function collectFirewallReach( db: DbClient, run: Runner = execRunner, ): FirewallReachCollection { const zoneSubnets = loadZoneSubnets(db); if (zoneSubnets.length === 0) return { firewalls: [], unreachableFirewalls: [] }; const firewalls: FirewallReachState[] = []; const unreachableFirewalls: string[] = []; for (const firewallIp of listFirewallIps(db)) { // escape-hatch: reads the running filter table (`iptables-save`), which is // the only place an out-of-band rule appears. Read-only, no capability. const result = runAppCommand({ ipv4_address: firewallIp }, 'iptables-save -t filter', run, { timeoutMs: IPTABLES_SAVE_TIMEOUT_MS, }); if (!result.ok) { unreachableFirewalls.push(firewallIp); continue; } firewalls.push({ firewallIp, live: parseLiveReachRules(result.stdout, zoneSubnets), known: loadTrustedSubnets(db, firewallIp).map((e) => e.subnet), }); } return { firewalls, unreachableFirewalls }; }