/** * Trusted-source registry — the DB-backed `TrustedSourceStore` for the * `firewall` capability, and the composition of the trusted-subnet set the * renderer consumes. * * A trusted source is an ORIGIN subnet permitted to initiate into every managed * zone (the admin VPN's client range being the motivating case). It is not a * port forward, so `exposeService` cannot express it — which is why the VPN's * rules belonged to no module and every converge removed them. */ import type { RegisterTrustedSourceRequest, TrustedSource, TrustedSourceStore, } from '@celilo/capabilities'; import { isCidr } from '@celilo/capabilities'; import { and, eq } from 'drizzle-orm'; import type { DbClient } from '../db/client'; import { trustedSources } from '../db/schema'; /** * Build the store bound to the module that will call through the capability. * `registeredBy` is stamped here, not supplied by the provider, so a * registration can never be attributed to the wrong module. */ export function buildTrustedSourceStore(db: DbClient, registeredBy: string): TrustedSourceStore { return { list(firewallIp: string): TrustedSource[] { return db .select() .from(trustedSources) .where(eq(trustedSources.firewallIp, firewallIp)) .all() .map((r) => ({ subnet: r.subnet, description: r.description, registeredBy: r.registeredBy, })); }, replace(firewallIp: string, source: RegisterTrustedSourceRequest): void { // The consumer's COMPLETE set for this firewall (D5b), scoped to its own // rows: a subnet it trusted before and omits now loses its reach, and a // subnet another module also trusts keeps it. Changing an admin VPN's // client subnet used to leave the old one reaching every zone forever. db.delete(trustedSources) .where( and( eq(trustedSources.firewallIp, firewallIp), eq(trustedSources.registeredBy, registeredBy), ), ) .run(); if (source.subnets.length === 0) return; db.insert(trustedSources) .values( source.subnets.map((subnet) => ({ firewallIp, subnet, description: source.description, registeredBy, })), ) .run(); }, }; } /** Where a trusted subnet came from — reach into every tier must be attributable. */ export type TrustedSubnetOrigin = 'derived-control-plane' | 'registered' | 'operator-override'; export interface TrustedSubnetEntry { subnet: string; origin: TrustedSubnetOrigin; /** Module id for `registered`; undefined for the derived and override origins. */ registeredBy?: string; description?: string; } /** * The operator's explicit trusted sources, in system config. Accepts the two * forms a config value takes in this area: a comma-separated CIDR list and a * JSON array of CIDRs (the form every sibling key uses, e.g. ddns_passwords). * * Every token must be a CIDR. The parser is the single choke point between the * operator's value and the ruleset renderer, which emits each subnet verbatim * into rules.v4 — a token that is not a CIDR used to ride through as a literal * subnet name and iptables-restore failed the whole converge with * "Bad argument `]'". Failing here names the token instead. * * ADDITIVE rather than replacing the derived control-plane subnet: dropping * control-plane trust would block the SSH celilo uses to run every hook and * converge, so an operator cannot strand celilo's own management by naming a * subnet here. */ export const TRUSTED_SUBNETS_CONFIG_KEY = 'firewall.trusted_subnets'; export function parseOperatorTrustedSubnets(raw: string | undefined): string[] { if (!raw || raw.trim().length === 0) return []; let tokens: string[]; const trimmed = raw.trim(); if (trimmed.startsWith('[') || trimmed.startsWith('{')) { let parsed: unknown; try { parsed = JSON.parse(trimmed); } catch { throw new Error( `${TRUSTED_SUBNETS_CONFIG_KEY} looks like JSON but does not parse: ${raw}. Expected a JSON array of CIDRs, e.g. ["10.0.0.0/24"], or a comma-separated CIDR list.`, ); } if (!Array.isArray(parsed) || parsed.some((t) => typeof t !== 'string')) { throw new Error( `${TRUSTED_SUBNETS_CONFIG_KEY} must be a JSON array of CIDR strings, e.g. ["10.0.0.0/24"], or a comma-separated CIDR list.`, ); } tokens = parsed; } else { tokens = trimmed.split(','); } const subnets = tokens.map((s) => s.trim()).filter((s) => s.length > 0); for (const subnet of subnets) { if (!isCidr(subnet)) { throw new Error( `${TRUSTED_SUBNETS_CONFIG_KEY} contains "${subnet}", which is not an IPv4 CIDR. Expected e.g. 10.0.0.0/24 (comma-separated) or ["10.0.0.0/24"] (JSON array).`, ); } } return subnets; } /** * Compose the trusted-subnet set from its three origins, deduped by subnet with * the FIRST origin winning (derived beats registered beats override), so the * report names the strongest claim on a subnet rather than an arbitrary one. * * With nothing registered and no override this returns exactly the derived * control-plane subnet — the property that makes composing safe to ship ahead of * any module that registers. */ export function composeTrustedSubnets(inputs: { controlPlaneSubnet?: string; registered: TrustedSource[]; operatorOverride: string[]; }): TrustedSubnetEntry[] { const entries: TrustedSubnetEntry[] = []; const seen = new Set(); const push = (entry: TrustedSubnetEntry): void => { if (seen.has(entry.subnet)) return; seen.add(entry.subnet); entries.push(entry); }; if (inputs.controlPlaneSubnet) { push({ subnet: inputs.controlPlaneSubnet, origin: 'derived-control-plane' }); } for (const source of inputs.registered) { push({ subnet: source.subnet, origin: 'registered', registeredBy: source.registeredBy, description: source.description, }); } for (const subnet of inputs.operatorOverride) { push({ subnet, origin: 'operator-override' }); } return entries; } /** The registered trusted sources for one firewall. Read-only; no binding needed. */ export function listTrustedSourcesFor(db: DbClient, firewallIp: string): TrustedSource[] { return db .select() .from(trustedSources) .where(eq(trustedSources.firewallIp, firewallIp)) .all() .map((r) => ({ subnet: r.subnet, description: r.description, registeredBy: r.registeredBy, })); } /** All registered trusted sources, across every firewall — for reporting. */ export function listAllTrustedSources(db: DbClient): Array { return db .select() .from(trustedSources) .all() .map((r) => ({ firewallIp: r.firewallIp, subnet: r.subnet, description: r.description, registeredBy: r.registeredBy, })); }