/** * `celilo firewall interface list []` * * The interface classification, on demand, with no side effects. * * A converge already refuses or isolates on what it finds — but only when it * runs, and only in the middle of a deploy's output. An operator about to * onboard a firewall, or wondering why one refused, needs to be able to ASK. * The condition that produced `fw-keeper.sh` was not that celilo lacked the * information; it was that celilo never said it. * * No side effects: it reads the LIVE interface table the same way the next * converge will — classification is recomputed, never read from a stored copy * (design D4: a stored classification goes stale) — plus the declarations, and * classifies in memory. The only remote operations are the read-only interface * detectors `machine add` uses. When the box cannot be reached it says so, and * falls back to the interface snapshot recorded at machine add, labelled as * what it is. * * The stored-read-only version of this command was the whole of the CLI half * of #1287: the snapshot is taken at machine add, so an interface removed after * it was recorded kept reading ALIEN forever, and an operator who did exactly * what the converge refusal told them to do was told their fix had not worked. */ import { classifyInterfaces, isPubliclyRoutable } from '@celilo/capabilities'; import { getDb } from '../../db/client'; import { listFirewallIps, readDeclaredNetworks } from '../../hooks/capability-loader'; import { detectNetworkInterfaces, detectNetworkInterfacesLocal, } from '../../services/machine-detector'; import { listMachines } from '../../services/machine-pool'; import { LOCAL_MACHINE_IP, deleteTemporarySshKey, writeTemporarySshKey, } from '../../services/ssh-key-manager'; import type { Machine } from '../../types/infrastructure'; import { celiloIntro } from '../prompts'; import type { CommandResult } from '../types'; /** * Every network a subnet is declared for — THE same reader the converge uses. * * This command's whole value is telling an operator what the next converge will * do, so reading declarations a second way is not a duplication smell, it is a * correctness bug: this file walked `NETWORK_ZONES` and so could not see * `network.control-plane-vpn.subnet`. It would have reported `wg0` as ALIEN — * "this will be isolated" — about an interface the converge attributes and * leaves alone. The operator's most likely response to that reading is to go * and remove their own admin VPN. */ async function declaredZones(): Promise> { return readDeclaredNetworks(getDb()); } /** What the classifier consumes, whatever the source. */ interface NamedInterface { name: string; ip: string; } /** * The interface table as the box holds it RIGHT NOW, via the same read-only * detectors `machine add` runs. The converge reads the box live too, so this * is the input its classification will see. */ async function readLiveInterfaces(machine: Machine): Promise { const toNamed = (list: Array<{ name: string; ipAddress: string }>): NamedInterface[] => list.map(({ name, ipAddress }) => ({ name, ip: ipAddress })); // The management box registers itself as 127.0.0.1 with no stored SSH key // and is reached locally, the same way machine add reaches it. if (machine.ipAddress === LOCAL_MACHINE_IP) { const { interfaces } = await detectNetworkInterfacesLocal(); return toNamed(interfaces); } const keyPath = await writeTemporarySshKey(machine.id); try { const { interfaces } = await detectNetworkInterfaces( machine.ipAddress, machine.sshUser, keyPath, ); return toNamed(interfaces); } finally { deleteTemporarySshKey(machine.id); } } /** One line per interface, explaining the role rather than just naming it. */ function describe(role: string, zone: string | undefined, ip: string): string { if (role === 'zone') return `zone:${zone}`; if (role === 'external') return 'external — the WAN edge'; return isPubliclyRoutable(ip) ? 'ALIEN — publicly routable but no declared zone claims it' : 'ALIEN — no declared subnet contains it'; } export async function handleFirewallInterfaceList( args: string[], _flags: Record = {}, ): Promise { celiloIntro('Firewall interfaces'); const wanted = args[0]; const machines = await listMachines(); // A firewall is a machine a firewall provider MANAGES. `role === 'router'` is // kept as a second way in, but it cannot be the only one: the role is decided // by `machine add` from the zones declared at that moment, and the normal // order is to add the machine and THEN deploy iptables, whose `on_install` // writes the zone subnets. So a working firewall is recorded as a plain host, // and this command — whose entire purpose is to report on firewalls — answered // "No firewalls in the machine pool" on a fleet that had one. // // Named explicitly, the hostname wins, so an operator can inspect any box. const firewallIps = new Set(await listFirewallIps(getDb())); const targets = wanted ? machines.filter((m) => m.hostname === wanted) : machines.filter((m) => firewallIps.has(m.ipAddress) || m.role === 'router'); if (targets.length === 0) { return { success: false, error: wanted ? `No machine named "${wanted}". Run \`celilo machine list\` to see the pool.` : 'No firewalls in the machine pool. celilo looks for a machine managed by a firewall provider, or one it classified as a router.', }; } const zones = await declaredZones(); if (zones.length === 0) { console.log( 'No zone subnets are declared, so every interface will read as unaccounted for.\n' + 'Declare them with: celilo system config set network..subnet \n', ); } let alienTotal = 0; for (const machine of targets) { console.log(`\n${machine.hostname} (${machine.ipAddress})`); let interfaces: NamedInterface[]; try { interfaces = await readLiveInterfaces(machine); } catch (error) { // An unreachable box is not a classification result. Name the fallback // for what it is, so a stale ALIEN is never mistaken for a current one. const reason = error instanceof Error ? error.message : String(error); console.log( ' could not read the box live — classifying the interface snapshot recorded at machine add, which may be stale:', ); console.log(` ${reason}`); interfaces = (machine.interfaces ?? []).map((i) => ({ name: i.name, ip: i.ipAddress, })); } if (interfaces.length === 0) { console.log(' no interfaces recorded — re-run `celilo machine add` to detect them'); continue; } for (const c of classifyInterfaces(interfaces, zones)) { if (c.role === 'alien') alienTotal += 1; console.log(` ${c.name.padEnd(8)} ${c.ip.padEnd(16)} ${describe(c.role, c.zone, c.ip)}`); } } console.log(''); if (alienTotal > 0) { // Say what will HAPPEN, not merely what was found — the answer differs by // whether celilo has a baseline for the box, and that is the thing an // operator most needs to know before the next converge. const consequence = [ 'On a firewall celilo has not yet converged cleanly, the next converge will REFUSE and change nothing.', 'On one with a recorded baseline, an interface that appeared since will be isolated.', 'Resolve either by declaring a zone: celilo system config set network..subnet ', ].join('\n'); console.log(`${alienTotal} interface(s) celilo cannot attribute.\n${consequence}`); } return { success: true, message: alienTotal === 0 ? 'every interface accounted for' : `${alienTotal} unaccounted`, }; }