/** * celilo discovering the network of the box it is installed on. * * This used to live in `modules/celilo-mgmt/scripts/discovery.ts`, which parsed * `ip route` and then shelled `celilo system apply-config network.internal.…`. * That made the management module the author of a network definition — and * networks are celilo's * (openspec/changes/networks-are-declared-not-written/specs/network-declaration/spec.md). * * The distinction is worth stating, because it is the reason this moved rather * than being exempted. A module writing `network..subnet` from its own * config is CHOOSING a range: an authority it should not have. celilo-mgmt was * not doing that — it was reading the kernel's routing table and reporting what * it found. The value was never the module's opinion. But the mechanism was * identical to the one being closed, and an exemption for "this caller is * trustworthy" is not enforceable: nothing stopped any other module from making * the same call. Moving the code makes the module's authority disappear instead * of being promised away. * * So the discovery is celilo's, the write is celilo's, and celilo-mgmt asks for * it by name. */ import { execFileSync } from 'node:child_process'; import { eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { type NetworkZone, systemConfig } from '../db/schema'; import { CONTROL_PLANE_MODULE_ID, asZone, getModuleSystems } from './deployed-systems'; export interface DiscoveredNetwork { subnet: string; gateway: string; } /** * The connected subnet + gateway of the interface carrying the default route. * * Pure, so it can be tested against real `ip route` output without a host. * Returns null when either the default route or its connected (kernel/link) * route is absent — celilo says it could not discover, rather than guessing. */ export function parseInternalNetwork(ipRouteOutput: string): DiscoveredNetwork | null { const lines = ipRouteOutput.split('\n').map((l) => l.trim()); const defaultLine = lines.find((l) => l.startsWith('default ')); const match = defaultLine?.match(/^default via (\S+) dev (\S+)/); if (!match) return null; const gateway = match[1]; const dev = match[2]; const cidr = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}$/; const subnetLine = lines.find( (l) => l.includes(`dev ${dev}`) && l.includes('proto kernel') && cidr.test(l.split(/\s+/)[0]), ); if (!subnetLine) return null; return { subnet: subnetLine.split(/\s+/)[0], gateway }; } /** * WHICH zone the discovered network is, which depends on the topology: * * - Single-network deployment (the common case): the box sits on the internal * LAN, so what it discovered IS `internal`. First install records it there. * - Segmented deployment: the box sits on a dedicated control-plane network. * `internal` is then someone else's subnet — the semi-trusted LAN — and the * discovered value belongs under the control plane's own zone (`secure-mgmt` * in the default topology, celilo#1356). * * On FIRST INSTALL the zone is decided the same way the rest of celilo decides * it: from where the control plane actually landed. `discoverAndRecordNetwork` * reads `celilo-mgmt`'s deployed-system row and passes that zone in as * `opts.controlPlaneZone`; with no row recorded yet the fallback stays * `internal`, the pre-#1356 behaviour. * * Issue #300 spotted the second case as "discovery returns the WRONG subnet" and * mitigated it by DISCARDING the value whenever `internal` was already set. That * kept `internal` correct and left celilo blind to its own network, which is what * breaks control-plane firewall trust and split-horizon DNS: the resolver has no * view for an unrecognized source, so it answers NOERROR with zero records and * the name falls through to public DNS. Recorded as `secure-mgmt` instead. * * Never clobbers an already-set value in either zone: discovery is a first-install * fallback, not an override. */ export function discoveredNetworkKeys( host: DiscoveredNetwork | null, internalAlreadySet: boolean, opts: { internalSubnet?: string; secureMgmtAlreadySet?: boolean; /** Zone the control plane itself occupies; first install records there, not `internal`. */ controlPlaneZone?: NetworkZone; } = {}, ): Record { if (!host) return {}; if (!internalAlreadySet) { const targetZone = opts.controlPlaneZone ?? 'internal'; if (targetZone !== 'internal' && opts.secureMgmtAlreadySet) return {}; return { [`network.${targetZone}.subnet`]: host.subnet, [`network.${targetZone}.gateway`]: host.gateway, }; } const onInternal = opts.internalSubnet === undefined || opts.internalSubnet === host.subnet; if (onInternal || opts.secureMgmtAlreadySet) return {}; return { 'network.secure-mgmt.subnet': host.subnet, 'network.secure-mgmt.gateway': host.gateway, }; } /** Read the host's routing table. Injectable so the command is testable. */ export type RouteReader = () => string | null; const readRoutes: RouteReader = () => { try { return execFileSync('ip', ['route'], { encoding: 'utf-8' }); } catch { return null; } }; export interface NetworkDiscoveryResult { /** Keys written, ` = `, for the operator to read back. */ applied: string[]; /** Set when nothing could be discovered, with the reason. */ skipped?: string; } function readValue(db: DbClient, key: string): string | undefined { return db.select().from(systemConfig).where(eq(systemConfig.key, key)).get()?.value ?? undefined; } /** * The zone the control plane actually landed in, from its deployed-system row * (same source `loadControlPlaneSubnet` reads). Undefined when celilo-mgmt has * no system recorded yet, so the caller falls back to `internal`. */ function controlPlaneZone(db: DbClient): NetworkZone | undefined { return asZone(getModuleSystems(CONTROL_PLANE_MODULE_ID, db)[0]?.zone) ?? undefined; } /** * Discover the network this box sits on and record it. Idempotent, and never * overwrites a value that is already set. */ export function discoverAndRecordNetwork( db: DbClient, readRoutesImpl: RouteReader = readRoutes, ): NetworkDiscoveryResult { const routes = readRoutesImpl(); if (routes === null) { return { applied: [], skipped: 'could not read the routing table (`ip route` failed)' }; } const host = parseInternalNetwork(routes); if (!host) { return { applied: [], skipped: 'no default route with a connected subnet was found in `ip route`', }; } const internalSubnet = readValue(db, 'network.internal.subnet'); const keys = discoveredNetworkKeys(host, internalSubnet !== undefined, { internalSubnet, secureMgmtAlreadySet: readValue(db, 'network.secure-mgmt.subnet') !== undefined, controlPlaneZone: controlPlaneZone(db), }); const applied: string[] = []; for (const [key, value] of Object.entries(keys)) { db.insert(systemConfig) .values({ key, value }) .onConflictDoUpdate({ target: systemConfig.key, set: { value } }) .run(); applied.push(`${key} = ${value}`); } if (applied.length === 0) { return { applied, skipped: `this box is on ${host.subnet}, which celilo already accounts for — nothing to record`, }; } return { applied }; }