/** * Capability Function Loader * * Loads capability function modules from provider modules and builds * callable interfaces for injection into hook contexts. * * When a module declares `requires.capabilities: [{ name: dns_registrar }]`, * this loader finds the provider module (namecheap), loads its function module * (scripts/register-host.ts), and builds the interface using the provider's * config and secrets. */ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { createPublicWeb, isCompiledCapabilityFactory, orderFirewallChain, wrapWithLogging, } from '@celilo/capabilities'; import type { DnsInternalCapability, DnsRegistrarCapability, HookLogger, PortForwardStore, ProviderConvergeView, RouteOps, RouteReadView, TrustedSourceStore, } from '@celilo/capabilities'; import { and, eq } from 'drizzle-orm'; import { selectCapabilityProvider } from '../capabilities/lookup'; import { WELL_KNOWN_CAPABILITIES } from '../capabilities/well-known'; import type { DbClient } from '../db/client'; import { NETWORK_ZONES, capabilities, modules, secrets, systemConfig, webRoutes, } from '../db/schema'; import { resolveModuleStateWebRoot, resolveModuleWebRoot } from '../module/web-root'; import { decryptSecret } from '../secrets/encryption'; import { getOrCreateMasterKey } from '../secrets/master-key'; import { buildControlPlaneApi } from '../services/api-principal-enrolment'; import { recordCapabilityBinding, withBindingRecord } from '../services/capability-bindings'; import { emitWebRoutesChangedAndWait } from '../services/celilo-events'; import { CONTROL_PLANE_MODULE_ID, getModuleSystems } from '../services/deployed-systems'; import { withDnsInternalLedger } from '../services/dns-internal-records'; import { withDnsRegistrationLedger } from '../services/dns-registrations'; import { buildPortForwardStore } from '../services/port-forwards'; import { TRUSTED_SUBNETS_CONFIG_KEY, type TrustedSubnetEntry, buildTrustedSourceStore, composeTrustedSubnets, listTrustedSourcesFor, parseOperatorTrustedSubnets, } from '../services/trusted-sources'; import { resolveComputedFields } from '../variables/computed/evaluate'; import { containsComputedMarker } from '../variables/computed/marker'; import { buildProviderLookup } from '../variables/computed/provider-lookup'; import type { BufferedStoreOp } from './hook-protocol'; import { type HookStores, createHookStores } from './hook-store'; import { type StoreTransport, buildStoreView } from './hook-store-proxy'; import { loadHookConfigMap } from './load-hook-config'; /** * Mapping of capability names to their function module entry points. * * After HOOK_API_V2 Phase 8 (D8), the canonical pattern is to default-export * a `defineCapabilityFunction(...)` from the script. The loader detects the * brand via `isCompiledCapabilityFactory` and invokes the factory with a * `{ config, secrets, logger }` context. * * `legacyFactoryName` is the named export checked when the default export is * missing OR when it isn't a branded compiled factory. iptables's * `createFirewall` is intentionally on the legacy path because its * upstream-chain wiring takes a second factory argument (`upstreamFirewall`) * that doesn't fit the `defineCapabilityFunction` shape. */ /** * Make the public_web provider re-render and converge, and insist it happened. * * Both triggers that change what the provider must serve come through here: a * route registered or withdrawn, and a static site published. They used to be * two different mechanisms — an event for routes, a direct core-side content * converge for publishes — which is how a publish could converge content * against a config the provider had not re-rendered. One path now * (providers-converge-declared-state, design D6). * * Authoritative (ISS-0081): a change the provider never reconciled is a * FAILURE, not a warning. These used to be logger.warn while the call returned * success anyway, so a consumer could report "ready" while caddy never learned * the hostname — no site block, no cert, TLS internal_error for clients. */ async function requireProviderReconcile(consumingModuleId: string, what: string): Promise { const reconcile = await emitWebRoutesChangedAndWait(consumingModuleId); if (reconcile.noDispatcher) { throw new Error( `public_web ${what} for ${consumingModuleId} was persisted but NOT delivered to the provider (caddy): no event dispatcher is running, so caddy never reconciled and the hostname has no site block or cert. Run this through \`celilo module deploy\` (which runs the dispatcher) rather than a bare hook.`, ); } if (reconcile.timedOut) { throw new Error( `public_web reconcile for ${consumingModuleId} (${what}) did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)) — caddy did not confirm the change is live.`, ); } if (reconcile.failed > 0) { throw new Error( `public_web reconcile for ${consumingModuleId} (${what}): ${reconcile.failed} delivery(ies) failed — caddy could not apply the change, so the hostname is not served as declared.`, ); } // ISS-0087: a change WAS persisted and the dispatcher IS alive, yet ZERO // providers reconciled it (no subscriber consumed routes_changed). That // would otherwise report success for something nobody applied. `events === 0` // means nothing changed (benign); `events > 0 && succeeded === 0` means it // changed and nobody served it — a failure. if (reconcile.events > 0 && reconcile.succeeded === 0) { throw new Error( `public_web ${what} for ${consumingModuleId} changed (${reconcile.events} event(s)) but NO provider reconciled it — caddy has no reconcile_routes subscription on the bus, so the change is persisted yet never served. Ensure a public_web provider (caddy) is deployed and subscribed.`, ); } } export const CAPABILITY_MODULE_MAP: Record = { dns_registrar: { script: 'scripts/register-host.ts', legacyFactoryName: 'default', }, firewall: { script: 'scripts/firewall-functions.ts', legacyFactoryName: 'createFirewall', }, // public_web: handled via framework implementation (createPublicWeb from @celilo/capabilities) idp: { script: 'scripts/idp-functions.ts', legacyFactoryName: 'createIdp', }, source_forge: { script: 'scripts/source-forge-functions.ts', legacyFactoryName: 'createForgejoSourceForge', }, registry_publish: { script: 'scripts/registry-publish-functions.ts', legacyFactoryName: 'default', }, dhcp_server: { script: 'scripts/dhcp-server-functions.ts', legacyFactoryName: 'default', }, dns_internal: { script: 'scripts/dns-internal-functions.ts', legacyFactoryName: 'default', }, notification: { script: 'scripts/notification.ts', legacyFactoryName: 'default', }, external_web: { script: 'scripts/publish-functions.ts', legacyFactoryName: 'default', }, control_plane_vpn: { script: 'scripts/control-plane-vpn-functions.ts', legacyFactoryName: 'default', }, // MODULE-provided, unlike its sibling public_web above, which the framework // implements (createPublicWeb) against celilo's `web_routes` table. The // asymmetry is deliberate: a private route stored in `web_routes` would be // picked up by the PUBLIC caddy, which derives its served hostnames from // every row of that table — so the provider keeps its own routes and celilo // core holds no private-ingress business at all (celilo#846). private_web: { script: 'scripts/private-web-functions.ts', legacyFactoryName: 'default', }, }; /** * Load capability function interfaces for a consuming module * * Finds all required capabilities, loads their function modules from * provider modules, and returns a map of capability name → callable interface. * * The consuming module's ID is captured into the public_web factory so the * Phase 5 API surface (no per-call moduleId) knows whose routes to * register/unregister. * * The hook's logger is captured by the framework's auto-logging wrapper * (HOOK_API_V2 Phase 6 / D6) so every capability method call produces * `→ .` / `✓` / `✗` markers without per-call boilerplate * inside the hook script. * * @param consumingModuleId - The consuming module's ID * @param db - Database connection * @param logger - Hook logger captured by the auto-logging wrapper * @returns Map of capability name to function interface */ /** * The firewall's internal NAT IP — split-horizon DNS records point here so * internal-zone clients reach a service via the iptables DNAT rules rather than * Caddy's unreachable DMZ container IP. Returns undefined when no firewall * provider advertises a `nat_ip`. Shared by the live public_web registration * and the deploy-time web-route DNS backfill (ISS-0029) so both write the same * value. */ export async function resolveFirewallNatIp(db: DbClient): Promise { const firewallProviders = db .select() .from(capabilities) .where(eq(capabilities.capabilityName, 'firewall')) .all(); for (const fp of firewallProviders) { const fpConfig = await loadModuleConfig(fp.moduleId, db); if (typeof fpConfig.nat_ip === 'string' && fpConfig.nat_ip) { return fpConfig.nat_ip; } } return undefined; } /** * The addresses of every machine a `firewall` capability provider manages. * * This is the authoritative answer to "which machines are firewalls", and it has * to be: a machine's stored `role` is decided by `machine add`, from the zones * declared AT THAT MOMENT. The normal order is `machine add` and THEN deploy * iptables, whose `on_install` writes the zone subnets — so the firewall is * recorded as a plain host and stays that way. A firewall provider naming an IP * is a statement, not an inference from a snapshot. */ export async function listFirewallIps(db: DbClient): Promise { const providers = db .select() .from(capabilities) .where(eq(capabilities.capabilityName, 'firewall')) .all(); const ips: string[] = []; for (const provider of providers) { const config = await loadModuleConfig(provider.moduleId, db); if (typeof config.firewall_ip === 'string' && config.firewall_ip) { ips.push(config.firewall_ip); } } return ips; } /** * Caddy's zone-routable IP — its own DMZ ingress address (`target_ip`, the same * value public_web exposes as `dmz_ip`). This is the in-zone split-horizon * answer (ISS-0156): clients INSIDE the segmented zones reach caddy here, since * they can't route to the firewall natIp. Returns undefined when no public_web * provider advertises a `target_ip`. Shared by the live public_web registration * and the deploy-time backfill so both write the same `zoneRoutableValue`. */ export async function resolveCaddyZoneIp(db: DbClient): Promise { const webProviders = db .select() .from(capabilities) .where(eq(capabilities.capabilityName, 'public_web')) .all(); for (const wp of webProviders) { const cfg = await loadModuleConfig(wp.moduleId, db); const ip = String(cfg.target_ip ?? '').split('/')[0]; if (ip) return ip; } return undefined; } /** * Does this module's manifest declare `capabilityName`, under `requires` or * `optional`? * * Reads the manifest stored at import rather than the one on disk, so the * answer is the one the operator's `module import` actually validated. */ function consumerDeclares(db: DbClient, moduleId: string, capabilityName: string): boolean { const row = db.select().from(modules).where(eq(modules.id, moduleId)).get(); const manifest = row?.manifestData; if (!manifest) return false; type Declarations = { capabilities?: { name?: string }[] } | undefined; const declared = [ ...((manifest.requires as Declarations)?.capabilities ?? []), ...((manifest.optional as Declarations)?.capabilities ?? []), ]; return declared.some((cap) => cap?.name === capabilityName); } export async function loadCapabilityFunctions( consumingModuleId: string, db: DbClient, logger: HookLogger, ): Promise> { const result: Record = {}; // Which provider each injected capability came from, so the binding recorded // at the return names the provider the consumer actually reached. Provider // self-views (`firewall_registry`, `web_routes`) are deliberately absent — // a module reading its own registry has not bound to anything. const providerByCapability = new Map(); const masterKey = await getOrCreateMasterKey(); // Load all available capability functions (not just required ones). // This allows modules to optionally consume capabilities at runtime // (e.g., caddy uses dns_registrar if available, falls back if not). const verbose = process.env.CELILO_DEBUG === '1'; // The broker-side stores, resolved from the PROVIDER on the first store // call this consumer load makes, and shared across every capability it // builds. Nothing pays for the manifest read until a factory actually // writes or reads through the accessor. See the storeTransport construction // at the factory-invocation site below. let storesPromise: Promise | undefined; const debugLog = verbose ? (msg: string) => process.stderr.write(`[capability-loader] ${msg}\n`) : (_msg: string) => {}; debugLog( `Loading capabilities. Registry has: public_web (framework), ${Object.keys(CAPABILITY_MODULE_MAP).join(', ')}`, ); // Load all non-public_web capabilities first so dns_internal (and others) // are available when we construct the framework-owned public_web capability. // public_web optionally threads dns_internal through for split-horizon DNS. for (const [capName, moduleInfo] of Object.entries(CAPABILITY_MODULE_MAP)) { // Check if this capability is registered (a provider module is deployed) // For zone-scoped capabilities (like firewall), there may be multiple providers const allProviders = db .select() .from(capabilities) .where(eq(capabilities.capabilityName, capName)) .all(); if (allProviders.length === 0) { debugLog(` ${capName}: not registered in DB, skipping`); continue; } // For firewall: build the chain by loading upstream providers first. // buildFirewallChain handles the per-layer wrap with auto-logging // internally, so we don't need to wrap the result here. if (capName === 'firewall' && allProviders.length > 1) { const { chain, self } = await buildFirewallChain( allProviders, moduleInfo, masterKey, db, logger, debugLog, consumingModuleId, ); if (chain) { result[capName] = chain; // The consumer talks to the OUTERMOST layer, which is not necessarily // the edge provider. Every layer is `stampProvider`'d on the way out, // so the one it was handed names itself. const chainProvider = (chain as { providerModuleId?: string }).providerModuleId; if (chainProvider) providerByCapability.set(capName, chainProvider); } // The chain hands a CONSUMER the innermost layer, which is not this // provider's own layer when it sits further out. `on_consumer_removed` // must converge THIS firewall, so it gets its own layer by name. if (self) { result.firewall_registry = self; debugLog(`firewall_registry: provider view injected for ${consumingModuleId}`); } continue; } // A well-known capability's required zone is also its runtime selection // context. Prefer a provider that explicitly serves that zone, then fall // back to a zone-agnostic provider. Without this, multiple DHCP providers // (for example an upstream router plus an internal LAN server) are selected // by database insertion order and a consumer can reconfigure the wrong // network boundary. const selectionZone = WELL_KNOWN_CAPABILITIES[capName]?.required_zone; const capability = selectCapabilityProvider(allProviders, selectionZone); if (!capability) { debugLog( `${capName}: no provider serves required zone ${selectionZone ?? '(unspecified)'}, skipping`, ); continue; } debugLog(`${capName}: found provider module ${capability.moduleId}`); const providerModule = db .select() .from(modules) .where(eq(modules.id, capability.moduleId)) .get(); if (!providerModule) { debugLog(` ${capName}: provider module record not found, skipping`); continue; } // Check that the function module exists const modulePath = join(providerModule.sourcePath, moduleInfo.script); if (!existsSync(modulePath)) { debugLog(` ${capName}: module not found at ${modulePath}`); continue; } debugLog(`${capName}: module found at ${modulePath}`); // Load provider's config and secrets to initialize the capability const providerConfig = await loadModuleConfig(capability.moduleId, db); const providerSecrets = await loadModuleSecrets(capability.moduleId, masterKey, db); // dns_registrar and dns_internal interfaces get a registration-ledger // wrapper: every successful registerHost / registerRecord is recorded so // celilo has an offline record of what it asked DNS to serve. The // external ledger feeds the provider's refresh_registrations hook // (DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B2); the internal ledger feeds // the doctor's natIp drift check (CELILO_DOCTOR_FLEET_DRIFT.md Phase 4). // The loader is the one layer that knows both provider and consumer. const ledgerCtx = { db, providerModuleId: capability.moduleId, consumerModuleId: consumingModuleId, }; const withLedger = (iface: unknown): unknown => { if (capName === 'dns_registrar') { return withDnsRegistrationLedger(iface as DnsRegistrarCapability, ledgerCtx); } if (capName === 'dns_internal') { return withDnsInternalLedger(iface as DnsInternalCapability, ledgerCtx); } return iface; }; try { // Dynamically import the capability module. Try the default export // first (the HOOK_API_V2 Phase 8 pattern), fall back to the legacy // named export (for factories not yet migrated). const mod = await import(modulePath); const exported = typeof mod.default === 'function' ? mod.default : mod[moduleInfo.legacyFactoryName]; if (typeof exported !== 'function') { debugLog(`${capName}: module does not export default or ${moduleInfo.legacyFactoryName}`); continue; } // Phase 8 path: branded defineCapabilityFunction factory. Call with // the canonical { config, secrets, logger } context. The factory // applies wrapWithLogging internally so we don't double-wrap. // // Both maps arrive as the hook-owned-state view (buildStoreView): the // plain record the factory reads, PLUS get/set/delete/transaction that // reach the broker-side stores (createHookStores), validated against // the PROVIDER module's own manifest. The transport is in-process — a // capability factory runs here in celilo's process, not in the jail — // but the view is the same object a jailed hook sees, so a provider's // self-config write goes through the same validated door regardless of // which process it runs in (hook-owned-state tasks 0.7 and 5.5 shape). if (isCompiledCapabilityFactory(exported)) { const storeTransport: StoreTransport = (store, method, args) => { storesPromise ??= createHookStores(db, capability.moduleId); return storesPromise.then((backend: HookStores): Promise => { const one = backend[store]; switch (method) { case 'transaction': return one.applyTransaction(args[0] as readonly BufferedStoreOp[]); case 'set': return one.set(args[0] as string, args[1] as string); case 'delete': return one.delete(args[0] as string); default: return one.get(args[0] as string); } }); }; const capabilityInterface = exported({ config: buildStoreView('config', providerConfig, storeTransport), secrets: buildStoreView('secrets', providerSecrets, storeTransport), systems: getModuleSystems(capability.moduleId, db), logger, // WHO IS CALLING, so a provider can scope per-consumer state to them. // `createPublicWeb` has always had this; compiled factories did not, // which made a module-provided capability structurally unable to // offer `unregisterRoutes()`-style methods. consumerModuleId: consumingModuleId, // WHERE THE CALLER'S BYTES ARE, for the same reason and by the same // seam. `private_web` and `external_web` publish static sites too, and // without this they could not find the caller's web root now that // `sourceDir` has left the request (D10 amendment). consumerWebRoot: resolveModuleWebRoot(consumingModuleId, db), // WHERE THE CALLER'S GENERATED SITE FILES ARE (celilo#1265), by the // same seam. A jailed hook cannot write into its own web root, so a // module-provided web provider needs the state overlay to honor it. consumerStateWebRoot: resolveModuleStateWebRoot(consumingModuleId, db), }); // Stamp here too, not only on the legacy path: a consumer that cannot // get what it needs must be able to name WHICH provider could not give // it. Missing this made the wireguard module report "Firewall provider // 'unknown' does not support trusted-source registration" against // greenwave — true, useless, and a violation of the contract's // requirement to name the provider. result[capName] = withLedger(stampProvider(capabilityInterface, capability.moduleId)); providerByCapability.set(capName, capability.moduleId); debugLog(`${capName}: loaded via defineCapabilityFunction`); continue; } // Legacy path: factory is a plain function. Build the interface // via the per-capability adapter and apply wrapWithLogging at the // loader site so the consumer still gets auto-logging. const capabilityInterface = buildCapabilityInterface( capName, exported, providerConfig, providerSecrets, // Single firewall provider (no upstream chain) still needs the injected // port-forward store — the chain path isn't taken when there's one provider. capName === 'firewall' ? buildPortForwardStore(db, consumingModuleId) : undefined, capName === 'firewall' ? loadFirewallZones(db) : undefined, // Bound to the CONSUMING module so a trusted-source registration is // attributable — reach into every tier must never be anonymous. capName === 'firewall' ? buildTrustedSourceStore(db, consumingModuleId) : undefined, capability.moduleId, ); if (capabilityInterface) { result[capName] = withLedger( wrapWithLogging(capabilityInterface as object, logger, capName), ); providerByCapability.set(capName, capability.moduleId); debugLog(`${capName}: loaded via legacy factory`); // Sole firewall provider running its OWN hook: the interface just built // IS its layer, so hand it back under the provider-view name too. if (capName === 'firewall' && consumingModuleId === capability.moduleId) { result.firewall_registry = result[capName]; debugLog(`firewall_registry: provider view injected for ${consumingModuleId}`); } } } catch (error) { debugLog( `${capName}: FAILED to load: ${error instanceof Error ? error.message : String(error)}`, ); } } // Handle public_web via framework implementation (no dynamic import needed). // Loaded after the capability loop so dns_internal is already available to // thread through for split-horizon DNS record registration. const publicWebProviders = db .select() .from(capabilities) .where(eq(capabilities.capabilityName, 'public_web')) .all(); if (publicWebProviders.length > 0) { const provider = publicWebProviders[0]; debugLog(`public_web: found provider module ${provider.moduleId}`); const providerConfig = await loadModuleConfig(provider.moduleId, db); const providerSecrets = await loadModuleSecrets(provider.moduleId, masterKey, db); const routeOps = buildRouteOps(db); // Find the firewall NAT IP so split-horizon records point to the iptables // internal interface rather than Caddy's unreachable DMZ container IP. const firewallNatIp = await resolveFirewallNatIp(db); if (firewallNatIp) { debugLog(`public_web: using firewall natIp ${firewallNatIp} for internal DNS`); } // Caddy's configured hostnames — public_web rejects routes for any // hostname not in this list, throwing a structured error that runs // caddy's `managed_hostname` ensure interview. Empty list means // caddy isn't configured yet — also a structured error path // (caller will see it on the first register_route). const caddyHostnames: string[] = []; if (Array.isArray(providerConfig.hostnames)) { for (const h of providerConfig.hostnames) { if (typeof h === 'string' && h) caddyHostnames.push(h); } } // Domains managed by some dns_registrar (primary + additional). // Each entry in caddy's hostnames must also be in this list — else // DNS won't resolve and TLS will eventually break. The registrar's // module id drives the second ensure interview when a hostname // isn't covered. const dnsRegistrarProviders = db .select() .from(capabilities) .where(eq(capabilities.capabilityName, 'dns_registrar')) .all(); const dnsManagedDomains: string[] = []; let dnsRegistrarModuleId: string | undefined; for (const drp of dnsRegistrarProviders) { // First registrar wins — multi-registrar setups can't be auto-extended // since we'd have to pick which one to add the new domain to. if (!dnsRegistrarModuleId) dnsRegistrarModuleId = drp.moduleId; // Canonical source: the registrar's DECLARED `domain_list` computed // field, resolved LIVE in the provider's context — the exact value // `$capability:dns_registrar.domain_list` resolves to and the same set // the registrar's own DDNS validation iterates (for namecheap, // keys(secret.ddns_passwords)). The manifest calls this "never // persisted, never stale"; deriving domains any other way drifts. In // particular a leftover `config.domains` row (from an older registrar // manifest — module config survives version bumps) would otherwise be // preferred by the heuristic below over the live secret keys and // silently exclude a just-added domain, dead-ending new-domain // onboarding forever (ce-iku). const declaredDomains = await resolveRegistrarDomainList(drp.data, drp.moduleId, db); if (declaredDomains) { dnsManagedDomains.push(...declaredDomains); continue; } // Fallback for registrars that DON'T declare a `domain_list` computed // field (older/archived static-config providers): read the config // shape directly. const drConfig = await loadModuleConfig(drp.moduleId, db); // dns_registrar 4.0.0+ exposes a single `domains` array (the // primary_domain/additional_domains split was collapsed — see // apps/celilo/designs/DNS_REGISTRAR_MULTI_DOMAIN.md). Older // registrars that haven't been migrated still use the split shape; // we keep that fallback so a stale config can still be read. let foundAny = false; if (Array.isArray(drConfig.domains)) { for (const d of drConfig.domains) { if (typeof d === 'string' && d) { dnsManagedDomains.push(d); foundAny = true; } } } else { if (typeof drConfig.primary_domain === 'string' && drConfig.primary_domain) { dnsManagedDomains.push(drConfig.primary_domain); foundAny = true; } if (Array.isArray(drConfig.additional_domains)) { for (const d of drConfig.additional_domains) { if (typeof d === 'string' && d) { dnsManagedDomains.push(d); foundAny = true; } } } } // namecheap 3.1.1+ derives its managed-domain list from // `Object.keys(secret.ddns_passwords)` (a JSON-encoded // domain→password map) and no longer exposes a `domains` config. // Mirror that derivation here so the cross-module flow knows // which zones the registrar covers. See // apps/celilo/designs/STRING_LIST_AND_DERIVED_KEYS.md. if (!foundAny) { const drSecrets = await loadModuleSecrets(drp.moduleId, masterKey, db); const raw = drSecrets.ddns_passwords; if (typeof raw === 'string' && raw) { try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { for (const k of Object.keys(parsed)) { if (k) dnsManagedDomains.push(k); } } } catch { // Bad JSON → empty list; consumer will throw // MissingProviderInputError naming this registrar. } } } } try { // The name comes off the row rather than being restated here: core // naming a capability is what `no-module-business-in-core` Scan B counts, // and this is bookkeeping, not a branch. providerByCapability.set(provider.capabilityName, provider.moduleId); result.public_web = createPublicWeb({ moduleId: consumingModuleId, // Resolved here, not in the capability: on a converge there is no // consumer process to ask (D10 amendment). webRoot: resolveModuleWebRoot(consumingModuleId, db), stateWebRoot: resolveModuleStateWebRoot(consumingModuleId, db), logger, config: providerConfig, secrets: providerSecrets, routeOps, dnsInternal: result.dns_internal as DnsInternalCapability | undefined, firewallNatIp, dnsRegistrar: result.dns_registrar as DnsRegistrarCapability | undefined, hostnames: caddyHostnames, caddyModuleId: provider.moduleId, dnsManagedDomains, dnsRegistrarModuleId, // The bytes move through the provider's Ansible converge, not a // hand-built ssh tar pipe (design D10). What changed in // providers-converge-declared-state is WHO decides what to converge: // core used to plan the whole fleet's release set here and throw when // any one module had no built site (celilo#1383). Now the publish // makes the PROVIDER re-render from the declared rows — the same path // a route change takes — so content and config converge together and a // broken consumer costs only its own site. // // The field keeps its name: it is passed into `createPublicWeb` inside // the CONSUMER's bundled copy of @celilo/capabilities, and a module // running an older bundle calls `deps.convergeStaticContent()` by that // name. Renaming it here would leave that call undefined on every // module that has not reinstalled. // // Awaited, so a publish returns only once the host matches: a publish // that reports ready while the host never received the bytes is the // exact "served but silently unreachable" anti-pattern. convergeStaticContent: async () => { await requireProviderReconcile(consumingModuleId, 'static publish'); }, // ISS-0035: register_route/unregister_routes emit this coarse signal // instead of SSHing caddy; the caddy provider's reconcile_routes // subscription re-renders the Caddyfile from web_routes. We await the // reconcile so register_route returns only once the route is live — // the consuming module's health_check runs right after and would // otherwise race the async reconcile. onRoutesChanged: async () => { await requireProviderReconcile(consumingModuleId, 'route change'); }, }); debugLog(`public_web: loaded via framework implementation for ${consumingModuleId}`); } catch (error) { debugLog( `public_web: FAILED to load: ${error instanceof Error ? error.message : String(error)}`, ); } // ISS-0035: when the module running THIS hook is the public_web PROVIDER // itself (caddy running its own hook, not a consumer), give it a read-only // view of the route registry so it can reconcile its config from web_routes // — symmetric with how consumers get the public_web capability. Consumers // never see this; only the provider does. if (consumingModuleId === provider.moduleId) { // Async because the consumer is a hook, which now runs in its own // process. `routeOps` itself stays synchronous: it is bun:sqlite and it // is called in-process by `createPublicWeb`. const routeView: RouteReadView = { getAllRoutes: async () => routeOps.getAllRoutes(), getRoutes: async (m: string) => routeOps.getRoutes(m), }; result.web_routes = routeView; debugLog(`web_routes: read-only route view injected for provider ${consumingModuleId}`); // The provider renders its own config; celilo makes the host match by // running that provider's own role. The lookup is deliberately per // module: core used to plan the whole fleet's sites and throw when one // module had no built site, which stopped every other site converging // (celilo#1383). See openspec/changes/providers-converge-declared-state. const providerModuleId = provider.moduleId; const providerConverge: ProviderConvergeView = { resolveConsumerSite: async (moduleId: string) => { const { resolveModuleStateWebRoot, resolveModuleWebRoot } = await import( '../module/web-root' ); // Pause preserves state (module-pause spec), so a paused module's // site is not re-converged. Leaving it out of the provider's site // list is exactly that: the role converges only the slugs it is // given and prunes nothing else, so the release already on the box // stays, and the module's routes still render into the config. // // This is where d04a2bb7's paused filter went when core's fleet-wide // plan was deleted. It moved rather than vanished, and it moved to // the per-module lookup, which is the only place that can answer for // one module without walking the rest. const { isModulePaused } = await import('../services/module-pause'); if (isModulePaused(db, moduleId)) { return { unavailable: `module '${moduleId}' is paused — its site is left as it is` }; } const sourceDir = resolveModuleWebRoot(moduleId, db); if (!sourceDir) { return { unavailable: `module '${moduleId}' is not installed` }; } if (!existsSync(sourceDir)) { return { unavailable: `no built site at ${sourceDir} — a module that serves a static site ships it at /site/dist`, }; } const stateWebRoot = resolveModuleStateWebRoot(moduleId, db); const overlayDir = stateWebRoot && existsSync(stateWebRoot) ? { overlayDir: stateWebRoot } : {}; return { sourceDir, ...overlayDir }; }, converge: async (artifacts) => { const { convergeProviderConfig, resolveStaticContentRetention } = await import( '../services/provider-converge' ); const result = await convergeProviderConfig(db, providerModuleId, { ...artifacts, // Operator config, resolved here rather than in the provider: the // module never carries the number (design D10 of // capability-owned-tables). retention: resolveStaticContentRetention(db, providerModuleId), }); return { success: result.success, ...(result.error ? { error: result.error } : {}), unresolved: result.unresolved, }; }, }; result.provider_converge = providerConverge; debugLog(`provider_converge: injected for provider ${consumingModuleId}`); } } else { debugLog('public_web: not registered in DB, skipping'); } // Framework-granted, so unlike everything above there is no provider row to // look up and no script to import — celilo IS the management server whose // principals these are (web-ui-console D7b). // // Gated on the DECLARATION, which the rest of this function deliberately is // not: the loop injects every capability it can build "not just required // ones", so a module that never asked still gets `idp` and `firewall`. That is // fine for a capability whose worst outcome is an unused OIDC client. This one // mints an SSH principal into celilo's own control plane, so the `requires` // line is the authorization and a module that did not write one does not get // the object at all. if (consumerDeclares(db, consumingModuleId, 'control_plane_api')) { // Wrapped like every other capability, so an enrolment that fails mid-deploy // leaves a `✗ control_plane_api.enrol_principal` in the hook log rather than // only a thrown error further up. The `defineCapabilityFunction` path wraps // itself; a framework-built table has to be wrapped here. result.control_plane_api = wrapWithLogging( buildControlPlaneApi(consumingModuleId), logger, 'control_plane_api', ); debugLog(`control_plane_api: framework-granted, injected for ${consumingModuleId}`); } // celilo#1072: the CALL is the binding, not the resolution. Everything above // is injected whether or not the consumer declared it — the loop's own // comment says "not just required ones" — so recording what was resolved // would name every provider on the fleet. A method invocation is the only // event that separates the optional capability a module uses from the ones it // merely declares. for (const [capName, iface] of Object.entries(result)) { const providerModuleId = providerByCapability.get(capName); // A module that provides and consumes the same capability is not bound to // itself, and a provider reading its own registry is not a consumer. if (!providerModuleId || providerModuleId === consumingModuleId) continue; if (!iface || typeof iface !== 'object') continue; result[capName] = withBindingRecord(iface as object, () => { try { recordCapabilityBinding(db, consumingModuleId, capName, providerModuleId); } catch (error) { // Bookkeeping must never fail the call it is observing. Loud, not silent. logger.warn( `Could not record the ${capName} binding ${consumingModuleId} → ${providerModuleId}: ${ error instanceof Error ? error.message : String(error) }`, ); } }); } return result; } /** * Build a capability interface from a *legacy* (un-branded) factory. * * After HOOK_API_V2 Phase 8 (D8) the only legacy factory left is * iptables's `createFirewall(config, upstream)` — its second argument * (upstream firewall) doesn't fit the `defineCapabilityFunction` * `{ config, secrets, logger }` shape, so it stays on the legacy path. * * The dns_registrar / idp / greenwave-firewall paths are gone — those * factories are now branded `defineCapabilityFunction` outputs that the * caller invokes directly with the new context. */ /** * Stamp the provider module id onto a capability interface, so a consumer that * cannot get what it needs can name WHICH provider could not supply it (see * `requireTrustedSources`). Non-function properties survive `wrapWithLogging`. */ function stampProvider(iface: unknown, providerModuleId?: string): unknown { if (!iface || typeof iface !== 'object' || !providerModuleId) return iface; return Object.assign(iface, { providerModuleId }); } function buildCapabilityInterface( capabilityName: string, factory: (...args: unknown[]) => unknown, config: Record, _secrets: Record, store?: PortForwardStore, zones?: FirewallZones, trustedSourceStore?: TrustedSourceStore, providerModuleId?: string, ): unknown { if (capabilityName === 'firewall') { // iptables firewall factory: NAT config + the injected port-forward store // (createFirewall(config, store, upstream?, logger?)). Upstream is injected // by buildFirewallChain when this module is downstream of another provider. // The firewall is only built through buildFirewallChain, which always passes // a store — guard so a mis-route surfaces loudly rather than as store=undefined. if (config.firewall_ip && config.nat_ip) { if (!store) { throw new Error('firewall capability requires an injected port-forward store'); } const iface = factory( { firewallIp: config.firewall_ip as string, natIp: config.nat_ip as string, zoneTiers: zones?.zoneTiers ?? [], trustedSubnets: zones?.trustedSubnets ?? [], controlPlaneSubnet: zones?.controlPlaneSubnet, frontedSubnets: zones?.frontedSubnets ?? [], // The recorded baseline (D12), from this firewall's own module config. // Absent means the box has never converged cleanly, so an interface // celilo cannot attribute refuses rather than being disabled. The // downstream-chain site below forwards this too; the single-provider // firewall (an iptables box with the WAN held by a non-celilo ISP // router) is built HERE, and this is the site that dropped it — the // baseline was recorded but never read back, so every converge stayed // in onboarding mode and refused instead of isolating (ce-qzxm). interfaceBaseline: parseInterfaceBaseline(config.interface_baseline), // The learned zone-to-leg map, same round trip and the same site that // dropped it. The module writes it at the end of a clean converge and // reads it back to keep a managed /32 on its declared zone leg. interfaceZoneMap: config.interface_zone_map as string | undefined, // Operator settings, forwarded verbatim. `parseStoredConfigValue` // preserves each manifest-declared type, so the boolean arrives as a // boolean and needs no coercion here. defaultRouteZone: config.default_route_zone as string | undefined, isolateTransitNetwork: config.isolate_transit_network as boolean | undefined, }, store, undefined, // no upstream — the chain path handles that undefined, // logger is applied by wrapWithLogging at the loader site trustedSourceStore, // LIVE — see FirewallZones.declaredNetworks for why this stays live // even though nothing writes a network mid-run any more. zones ? { list: zones.declaredNetworks } : undefined, ); return stampProvider(iface, providerModuleId); } return null; } // No other capabilities should hit this path after Phase 8. The // generic fallback exists only as a defensive landing for anything // unexpected the loader passes through. return factory({ config, secrets: _secrets }); } /** * Load all config values for a module. * * Thin wrapper around `loadHookConfigMap` — kept private here so * existing callers in this file don't need an import-path churn. The * shared helper handles the `target_ip` / `ip.primary` machine-IP * fallback for machine deploys (IPAM writes those keys to * `module_configs` for container deploys, but machine deploys skip * that write). */ async function loadModuleConfig(moduleId: string, db: DbClient): Promise> { return loadHookConfigMap(moduleId, db); } /** * Load and decrypt all secrets for a module */ async function loadModuleSecrets( moduleId: string, masterKey: Buffer, db: DbClient, ): Promise> { const secretRecords = db.select().from(secrets).where(eq(secrets.moduleId, moduleId)).all(); const result: Record = {}; for (const s of secretRecords) { result[s.name] = decryptSecret( { encryptedValue: s.encryptedValue, iv: s.iv, authTag: s.authTag }, masterKey, ); } return result; } /** * Resolve a dns_registrar provider's DECLARED `domain_list` computed field * (e.g. namecheap's `keys(secret.ddns_passwords)`) in the PROVIDER's context — * the canonical, always-live set of zones it manages. Returns the string list, * or null when the provider declares no such computed field so the caller * falls back to reading the config shape directly. * * This is the same value `$capability:dns_registrar.domain_list` resolves to * and the same set the registrar's own DDNS validation iterates, so * public_web's hostname check can never drift from what the registrar actually * manages (ce-iku). */ async function resolveRegistrarDomainList( rawData: unknown, moduleId: string, db: DbClient, ): Promise { try { const data = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; if (!containsComputedMarker(data)) return null; const lookup = await buildProviderLookup(moduleId, db); const resolved = resolveComputedFields(data, lookup) as Record; const list = resolved.domain_list; if (!Array.isArray(list)) return null; return list.filter((d): d is string => typeof d === 'string' && d.length > 0); } catch { // Provider context couldn't resolve (e.g. undecryptable secret) — let the // caller fall back to the config-shape heuristic. return null; } } /** * Build a firewall capability chain from multiple providers. * * Finds the provider with has_external (leaf), then wires upstream providers * to delegate through it. Returns the firewall interface for the most * downstream provider (the one closest to the requesting module). * * After HOOK_API_V2 Phase 8 the leaf is typically a branded * defineCapabilityFunction (greenwave) that we invoke with the new * `{ config, secrets, logger }` context. The downstream (iptables) stays * on the legacy `createFirewall(config, upstream)` path because its * upstream-injection signature doesn't fit the new shape. */ /** Most-exposed → most-protected SEGMENTED tiers for the firewall default-DROP matrix. */ const ZONE_TIER_ORDER = ['dmz', 'app', 'secure'] as const; interface FirewallZones { /** Ordered segmented tiers (dmz→app→secure adjacency) for the data-plane matrix. */ zoneTiers: Array<{ name: string; subnet: string }>; /** Subnets that reach EVERY tier — the composed set (see loadTrustedSubnets). */ trustedSubnets: string[]; /** celilo's control-plane network, as a DESTINATION for trusted sources. */ controlPlaneSubnet?: string; /** * Every zone with a declared subnet, WITH its name — the input to interface * classification. Excludes `external`, which is a residual rather than a * subnet (design D1, amended). */ /** * A LIVE read, not a snapshot — and it STAYS live, deliberately, now that the * race it was introduced for is gone. * * It was introduced because `wireguard` declared its VPN subnet and brought * `wg0` up inside one hook run, so a converge later in that run had to see a * value written after the capability was built. Modules no longer write * networks at all (openspec/changes/networks-are-declared-not-written), and * `ensureRequiredNetworks` defines every required one BEFORE the deploy loads * any capability. So a snapshot would be correct today. * * It is kept live because being correct today is not the property worth having * here. What a stale read costs is specific and severe: an interface celilo * cannot attribute is ALIEN, and on a firewall with a recorded baseline an * alien interface is disabled — which is how a converge came to shut down the * admin VPN, the operator's way back into a remote box. A live read costs one * query per converge. That is a trade worth making permanently, not one to * re-evaluate whenever the write paths happen to look clean. * * And they are not entirely clean: `celilo system discover-network` writes * `network.internal.*` from inside `celilo-mgmt`'s install hook. No firewall * capability exists during that deploy, so it cannot bite — today. Liveness is * what makes that sentence not need to stay true. */ declaredNetworks: () => Array<{ zone: string; subnet: string }>; /** * Every declared zone subnet — read from * `network..subnet` across all of `NETWORK_ZONES`, not just the three * data-plane tiers. * * A downstream firewall must translate for every network behind it, and the * tiers are only the ones that happen to form the dmz→app→secure chain. * `secure-mgmt` and the control-plane VPN are equally behind it and equally * unroutable untranslated. Reading the canonical zone list rather than the tier * list means declaring a zone's subnet is sufficient to get it translated — no * module registration required. * * The egress network is NOT excluded here: the firewall derives that from its * own routing table, so nothing on this side has to guess which zone it is. */ frontedSubnets: string[]; } /** The module that IS celilo's control plane; its network is what we trust. */ function readZoneSubnet(db: DbClient, zone: string): string | undefined { const row = db .select() .from(systemConfig) .where(eq(systemConfig.key, `network.${zone}.subnet`)) .get(); return row?.value ?? undefined; } /** * The subnet celilo's control plane occupies: the zone `celilo-mgmt` is actually * deployed in — NOT a hardcoded zone. * * This used to read `network.internal.subnet` outright, on the assumption that the * management server lives on the internal LAN. When it doesn't, celilo's own * control plane is trusted by nothing: default-DROP then blocks the SSH it uses to * run hooks/converges/Ansible on every deployed box, and the same unrecognized * network is absent from the resolver's split-horizon views (so internal names * resolve publicly and can't be hairpinned). * * `celilo-mgmt` may legitimately run in `internal` OR in `secure-mgmt`; deriving * from where it landed supports both without hardcoding either. */ export function loadControlPlaneSubnet(db: DbClient): string | undefined { for (const system of getModuleSystems(CONTROL_PLANE_MODULE_ID, db)) { const subnet = system.zone ? readZoneSubnet(db, system.zone) : undefined; if (subnet) return subnet; } return undefined; } /** * The composed trusted-subnet set for one firewall, with each subnet's ORIGIN — * celilo's derived control plane, the sources modules registered, and any * explicit operator override. * * This used to be the derived control-plane subnet and nothing else, with no * contribution point: infrastructure whose need was zone-wide REACH rather than * port exposure had no way into the registry, so a converge — correctly * rebuilding the ruleset from what it knew — removed rules nothing had claimed. * * With nothing registered and no override the result is the derived subnet * alone, so a fleet without such infrastructure renders exactly as before. * * `firewallIp` includes that firewall's REGISTRATIONS — the full composed view, * for REPORTING (which networks hold zone-wide reach, and who claimed each). * Omit it for the render input: there the provider unions the live registry at * converge time, and folding a snapshot in here as well would give one value two * sources of truth, one of which cannot shrink. */ export function loadTrustedSubnets(db: DbClient, firewallIp?: string): TrustedSubnetEntry[] { // Fall back to the internal subnet when celilo-mgmt's location can't be // determined (e.g. installs predating celilo-mgmt-as-a-module). Losing control- // plane trust outright would brick celilo's own fleet management, so absent // information preserves today's behaviour; the gap is REPORTED separately. const controlPlaneSubnet = loadControlPlaneSubnet(db) ?? readZoneSubnet(db, 'internal'); // Registrations are keyed by firewall. With no firewall named, contribute // NONE rather than unioning every firewall's registrations — trust registered // against one firewall is not trust granted by another. const registered = firewallIp ? listTrustedSourcesFor(db, firewallIp) : []; const overrideRow = db .select() .from(systemConfig) .where(eq(systemConfig.key, TRUSTED_SUBNETS_CONFIG_KEY)) .get(); return composeTrustedSubnets({ controlPlaneSubnet, registered, operatorOverride: parseOperatorTrustedSubnets(overrideRow?.value ?? undefined), }); } /** * Read the firewall zone matrix inputs from system config (network..subnet): * the segmented tiers [dmz, app, secure] and the trusted subnets that reach all * of them. Zones with no configured subnet are omitted — their traffic stays * denied (fail-closed). */ /** * Every network celilo can attribute an interface to: one entry per * `network..subnet` in system config. * * Read from the config rather than from `NETWORK_ZONES`, because celilo holds * networks that are not placement zones — `network.control-plane-vpn.subnet`, * which `wireguard` requires and reads. The firewall classifies `wg0` "by the * same subnet containment it uses for every other leg", and reading only * `NETWORK_ZONES` left that network invisible: the converge could not attribute * `wg0` and refused — and on a firewall with a baseline it would have ISOLATED * it, shutting down the admin VPN. * * `external` is deliberately absent even when something has set * `network.external.subnet`: it is the RESIDUAL, decided by `isPubliclyRoutable` * and never by containment (design D1, amended). Matching an interface to * `external` by subnet would reintroduce the overload this change removes. */ export function readDeclaredNetworks(db: DbClient): Array<{ zone: string; subnet: string }> { return db .select() .from(systemConfig) .all() .flatMap((row) => { const zone = /^network\.(.+)\.subnet$/.exec(row.key)?.[1]; return zone && zone !== 'external' && row.value ? [{ zone, subnet: row.value }] : []; }); } function loadFirewallZones(db: DbClient): FirewallZones { const zoneTiers: Array<{ name: string; subnet: string }> = []; for (const zone of ZONE_TIER_ORDER) { const subnet = readZoneSubnet(db, zone); if (subnet) zoneTiers.push({ name: zone, subnet }); } // The BASE set only — derived control plane + operator override. Module // registrations are DELIBERATELY excluded: the provider unions them at // converge time from the live store. // // Injecting them here too would give one value two sources of truth, and the // snapshot is taken when the capability is built — before any hook runs. A // hook that WITHDRAWS a registration then converges would have the stale // snapshot put the subnet straight back, so the set could grow but never // shrink. That is exactly what happened: `on_uninstall` withdrew the VPN's // trusted source, logged success, and the reach rules were re-rendered anyway. // Every zone that HAS a declared subnet, keeping the zone NAME. Interface // classification needs the name — `external` and `internal` are the only two // legs a default route may leave through (design D11), which is not a question // a bare list of subnets can answer. // // `external` is deliberately absent from this list even when something has set // `network.external.subnet`: `external` is the RESIDUAL, decided by // `isPubliclyRoutable`, never by containment (design D1, amended). Matching an // interface to `external` by subnet would reintroduce the overload this change // removes. // // EVERY declared network, not just the six `NETWORK_ZONES`. celilo declares // networks that are not placement zones — `network.control-plane-vpn.subnet`, // written by `wireguard`'s `on_install` — and the design is explicit that the // firewall "classifies `wg0` by the same subnet containment it uses for every // other leg". Reading only `NETWORK_ZONES` left that declaration invisible: // the module declared the subnet before bringing the interface up, exactly as // designed, and the converge still could not attribute `wg0` and refused. On a // firewall that already had a baseline it would have gone further and ISOLATED // the interface — celilo shutting down the admin VPN, which for a remote // operator is the way back in. // // So the source is the config itself. A hardcoded list here could only ever // describe the networks celilo knew about when this line was written. return { zoneTiers, trustedSubnets: loadTrustedSubnets(db).map((e) => e.subnet), controlPlaneSubnet: loadControlPlaneSubnet(db) ?? readZoneSubnet(db, 'internal'), declaredNetworks: () => readDeclaredNetworks(db), // Placement ZONES only — deliberately NOT `declaredNetworks`, which is wider. // Fronting a subnet renders translation for it; the control-plane VPN // reaches the fleet as a trusted source instead, and giving it a second // mechanism would change the rendered ruleset for every existing fleet. frontedSubnets: NETWORK_ZONES.filter((zone) => zone !== 'external').flatMap((zone) => { const subnet = readZoneSubnet(db, zone); return subnet ? [subnet] : []; }), }; } /** * The interface baseline as stored in module config: a comma-separated list of * interface names, or absent. * * Absent and empty are the SAME thing here and both mean "no baseline" — a * firewall with zero interfaces does not exist, so an empty string can only be * a cleared or never-written value. Treating it as a baseline of nothing would * mean every interface is "new", which is the opposite of what it says. */ function parseInterfaceBaseline(raw: unknown): string[] | undefined { if (typeof raw !== 'string') return undefined; const names = raw .split(',') .map((n) => n.trim()) .filter((n) => n.length > 0); return names.length > 0 ? names : undefined; } /** * What a firewall build hands back. * * `chain` is the layer a CONSUMER talks to (the innermost). `self` is the layer * belonging to the module currently running a hook, and is null unless that * module is itself one of the providers — it is what `on_consumer_removed` * converges, and it differs from `chain` whenever the provider sits further out * than the innermost layer. */ interface FirewallChain { chain: unknown; self: unknown | null; } async function buildFirewallChain( allProviders: Array<{ id: number; moduleId: string; capabilityName: string; data: Record; zones: string[] | null; }>, moduleInfo: { script: string; legacyFactoryName: string }, masterKey: Buffer, db: DbClient, logger: HookLogger, debugLog: (msg: string) => void, consumingModuleId: string, ): Promise { // The shared-core port-forward registry, injected into every firewall provider // in the chain so exposeService/converge reconcile against the one canonical // store (openspec/changes/unified-management-no-ssh/proposal.md). Bound to the // CONSUMING module so every forward it declares is attributable (D2). const store = buildPortForwardStore(db, consumingModuleId); // Trusted-source registry, bound to the CONSUMING module so a registration is // attributable. Only the layers that render their own ruleset receive it. const trustedSourceStore = buildTrustedSourceStore(db, consumingModuleId); // The delegation order, most downstream first. Shared with the console's // closure so the wiring and the picture cannot disagree about which firewall // stands on which — see `orderFirewallChain`. const chainOrder = orderFirewallChain(allProviders); if (chainOrder.length === 0) { debugLog('firewall chain: no provider with external interface found'); return { chain: null, self: null }; } // The leaf is the upstream end: the provider with the direct internet leg. const hasExternal = chainOrder[chainOrder.length - 1] as (typeof chainOrder)[number]; // Build the leaf (external) firewall first const leafModule = db.select().from(modules).where(eq(modules.id, hasExternal.moduleId)).get(); if (!leafModule) return { chain: null, self: null }; const leafModulePath = join(leafModule.sourcePath, moduleInfo.script); if (!existsSync(leafModulePath)) { debugLog(`firewall chain: leaf module not found at ${leafModulePath}`); return { chain: null, self: null }; } const leafConfig = await loadModuleConfig(hasExternal.moduleId, db); const leafSecrets = await loadModuleSecrets(hasExternal.moduleId, masterKey, db); const leafMod = await import(leafModulePath); const leafExported = typeof leafMod.default === 'function' ? leafMod.default : leafMod[moduleInfo.legacyFactoryName]; if (typeof leafExported !== 'function') return { chain: null, self: null }; // Branded compiled factory (Phase 8 path): call with the canonical // context. wrapWithLogging is applied internally so the leaf interface // already auto-logs. let leafFirewall: unknown; if (isCompiledCapabilityFactory(leafExported)) { leafFirewall = leafExported({ config: leafConfig, secrets: leafSecrets, systems: getModuleSystems(hasExternal.moduleId, db), logger, consumerModuleId: consumingModuleId, }); leafFirewall = stampProvider(leafFirewall, hasExternal.moduleId); debugLog( `firewall chain: built leaf via defineCapabilityFunction from ${hasExternal.moduleId}`, ); } else { // Legacy factory path leafFirewall = buildCapabilityInterface( 'firewall', leafExported, leafConfig, leafSecrets, store, loadFirewallZones(db), trustedSourceStore, hasExternal.moduleId, ); if (leafFirewall) { leafFirewall = wrapWithLogging(leafFirewall as object, logger, 'firewall'); } debugLog(`firewall chain: built leaf via legacy factory from ${hasExternal.moduleId}`); } // Build downstream providers, wiring each to the upstream. iptables // remains a legacy factory because its second arg (upstreamFirewall) // doesn't fit defineCapabilityFunction's single-context shape. // Back into wiring order: each layer is built taking the previous one as its // upstream, so the build runs from the leaf outwards while the chain reads // from the consumer inwards. const downstream = chainOrder.slice(0, -1).reverse(); // Each layer as its OWN provider sees it, so `on_consumer_removed` converges // the firewall that declares the hook rather than whichever layer a consumer // happens to talk to. const selfLayer = (id: string, iface: unknown): unknown | null => id === consumingModuleId ? iface : null; let self = selfLayer(hasExternal.moduleId, leafFirewall); if (downstream.length === 0) { return { chain: leafFirewall, self }; } let currentUpstream = leafFirewall; for (const provider of downstream) { const provModule = db.select().from(modules).where(eq(modules.id, provider.moduleId)).get(); if (!provModule) continue; const provModulePath = join(provModule.sourcePath, moduleInfo.script); if (!existsSync(provModulePath)) continue; const provConfig = await loadModuleConfig(provider.moduleId, db); const provMod = await import(provModulePath); const provFactory = typeof provMod.default === 'function' ? provMod.default : provMod[moduleInfo.legacyFactoryName]; if (typeof provFactory !== 'function') continue; if (isCompiledCapabilityFactory(provFactory)) { // Defensive: a downstream firewall can't be a compiled factory // because it needs the upstream argument. Skip with a warning. debugLog( `firewall chain: downstream ${provider.moduleId} is a compiled factory — cannot wire upstream, skipping`, ); continue; } // iptables factory takes (config, upstreamFirewall) const firewallIp = provConfig.firewall_ip as string; const natIp = provConfig.nat_ip as string; if (!firewallIp || !natIp) continue; // Apply rules by default. Set CELILO_FIREWALL_DRY_RUN=1 to preview without applying. const dryRun = process.env.CELILO_FIREWALL_DRY_RUN === '1'; // Third arg (logger) lets iptables route its internal status lines // ("[iptables] Rule already exists…", etc.) through the parent // celilo's ProgressDisplay instead of dumping to stderr. Older // iptables modules ignore it — the factory's signature is // backward-compatible. // Zone matrix for the default-DROP posture. The same BASE set for every // layer; each layer's own registrations are unioned in by its provider at // converge time, from the live store. const zones = loadFirewallZones(db); const downstreamFirewall = provFactory( { firewallIp, natIp, dryRun, zoneTiers: zones.zoneTiers, trustedSubnets: zones.trustedSubnets, controlPlaneSubnet: zones.controlPlaneSubnet, frontedSubnets: zones.frontedSubnets, // The recorded baseline (D12), from this firewall's own module config. // Absent means the box has never converged cleanly, so an interface // celilo cannot attribute refuses rather than being disabled. interfaceBaseline: parseInterfaceBaseline(provConfig.interface_baseline), // The managed-address resolver consults this before it reads the live // address table. Dropping it makes the fallback select the management // (SSH) interface, after which the bad alias can corrupt the learned map. interfaceZoneMap: provConfig.interface_zone_map as string | undefined, // Which declared zone carries this firewall's default route, and // whether fronted zones may initiate into it. Both are operator // settings and both are read HERE or nowhere: the module declares them // on `FirewallConfig`, and nothing else in celilo constructs one. defaultRouteZone: provConfig.default_route_zone as string | undefined, isolateTransitNetwork: provConfig.isolate_transit_network as boolean | undefined, }, store, currentUpstream, logger, trustedSourceStore, // LIVE — see FirewallZones.declaredNetworks. Cheap insurance against a // failure whose consequence is isolating the admin VPN. { list: zones.declaredNetworks }, ); debugLog(`firewall chain: wired ${provider.moduleId} → ${hasExternal.moduleId}`); // Wrap each downstream layer with auto-logging. currentUpstream = stampProvider( wrapWithLogging(downstreamFirewall as object, logger, 'firewall'), provider.moduleId, ); self = self ?? selfLayer(provider.moduleId, currentUpstream); } return { chain: currentUpstream, self }; } /** * Build RouteOps for the public_web capability implementation. * Wraps database operations so the capability never touches the DB directly. */ function buildRouteOps(db: DbClient): RouteOps { return { getRoutes(moduleId: string) { return db.select().from(webRoutes).where(eq(webRoutes.moduleId, moduleId)).all(); }, getAllRoutes() { return db.select().from(webRoutes).all(); }, upsertRoute(route) { // Path uniqueness is scoped to (hostname, path). Same module + // same (hostname, path) is treated as an upsert, so delete first // to keep the operation atomic in the eyes of the unique index. db.delete(webRoutes) .where( and( eq(webRoutes.hostname, route.hostname), eq(webRoutes.path, route.path), eq(webRoutes.moduleId, route.moduleId), ), ) .run(); db.insert(webRoutes) .values({ slug: route.slug, moduleId: route.moduleId, type: route.type, path: route.path, hostname: route.hostname, targetHost: route.targetHost ?? null, targetPort: route.targetPort ?? null, websocket: route.websocket ?? false, contentHash: route.contentHash ?? null, }) .run(); }, deleteRoute(moduleId: string, path: string) { db.delete(webRoutes) .where(and(eq(webRoutes.moduleId, moduleId), eq(webRoutes.path, path))) .run(); }, deleteRoutesBySlug(slug: string) { db.delete(webRoutes).where(eq(webRoutes.slug, slug)).run(); }, deleteRoutesByModule(moduleId: string) { db.delete(webRoutes).where(eq(webRoutes.moduleId, moduleId)).run(); }, }; }