/** * A placement zone a machine can be brought up in. * * `secure-mgmt` is celilo's own control plane, not a data-plane tier. It was * expressible for the management box alone until a module declared * `zone: secure-mgmt` (#436, the signal transport) and infrastructure * selection had nowhere to put it — so a module that named the zone correctly * could not be deployed by any test. */ import type { ModuleHost } from './module-host'; export type Zone = 'dmz' | 'app' | 'secure' | 'internal' | 'secure-mgmt'; /** * Where a network assertion is made FROM — named by the box's network location * (ISS-0117 §D4). A vantage is realized in the topology by an `observer` container * (passive spy with all probe tooling) placed at that location with a routing profile * that mirrors a real device there. `from` is always explicit — there is no default * vantage, so adding one can't silently change an existing test. */ export type Vantage = | 'internalDevice' // unmanaged device on the internal/Home LAN; NO route into dmz/app/secure (the real end-user) | 'dmzSystem' | 'appSystem' | 'secureSystem' | 'publicInternet' // outside the firewall — what the real internet sees | 'management'; // celilo-mgmt box; all-VLAN routes (the trap — explicit, rare) /** A request to inject an observer at a vantage (passive spy with controlled routing). */ export interface ObserverSpec { vantage: Vantage; } export interface MachineSpec { name: string; ip: string; zone: Zone; /** Use the Docker-capable image (normally only app zone; set for internal machines that need Docker) */ docker?: boolean; /** Override the Dockerfile used for this machine (relative to the e2e package) */ dockerfile?: string; } export type TopologyPreset = 'default' | 'direct-internet'; export interface NetworkConfig { topology: TopologyPreset; dmzMachines: MachineSpec[]; appMachines: MachineSpec[]; secureMachines: MachineSpec[]; internalMachines: MachineSpec[]; /** * Machines on celilo's control-plane network. Bringing one up implies the * `secure-mgmt` network exists, whether or not celilo-mgr itself sits there. */ secureMgmtMachines: MachineSpec[]; domain: string; ddnsPassword: string; verifyRouting: boolean; /** * Which real ISP router the `fw-isp` simulator stands in for, expressed as * its TR-181 vendor extension prefix. `X_GWS_` (the default) is the GreenWave * C4000XG that `modules/greenwave` drives; `X_AXON_` is the Axon Networks * Q1000K that `modules/axon` drives. The CGI protocol is identical across the * two — this prefix IS the entire difference, which is why one simulator * serves both rather than a near-duplicate second one. */ routerVendorPrefix?: string; /** * Whether the simulated ISP router serves DHCP on `internal`. Defaults to * true, which is what an operator's router does and what every existing suite * relies on. * * Set false when celilo serves DHCP instead (`modules/dnsmasq-dhcp`). Two * DHCP servers on one broadcast domain race — whichever answers a DISCOVER * first wins — so a suite asserting WHICH server issued a lease would be * asserting a coin toss. Turning the router's off is also what a real * operator does when moving DHCP to celilo, so this models the migration * rather than papering over a conflict. */ routerDhcp?: boolean; /** Extra volume mounts for the management container (host:container format) */ managementVolumes: string[]; /** Include a DHCP client container on the internal network */ dhcpClient: boolean; /** * Include the Proxmox API simulator on `secure-mgmt`. * * Turns on the ONLY path in the suite that deploys a module through a * `container_service` rather than the machine pool — IPAM allocation, the * deployed-system recording, the infrastructure-variable resolver and the * DNS-ingress reservation. Optional because it costs a container and a * `service add`, and every existing suite deploys onto the machine pool. * * On `secure-mgmt` with celilo-mgr rather than a data-plane tier: Proxmox * creates the fleet rather than being consumed by it (D6). */ proxmoxSim?: boolean; /** * Include a REAL signal-cli daemon on the internal network. * * Never linked to a Signal account — the JSON-RPC interface is served * locally and needs none. It exists so the contract celilo's client depends * on is verified against the actual binary rather than against a simulator * that encodes the same assumptions. See design.md D16. */ signalCli: boolean; /** * Include the signal-cli SIMULATOR on the internal network. * * The drivable counterpart to `signalCli`: it can queue an inbound reply, * fail a recipient inside a JSON-RPC success, and revoke the device link on * command, which is what a test of the delivery/ack loop needs and what an * unlinked real daemon cannot be made to do. */ signalSim: boolean; /** * Serve the signal-cli release tarball from the simulated internet, so the * signal module's deploy-time download resolves inside the sealed network. */ signalRelease: boolean; /** * Which zone the celilo management container sits in. Defaults to * `secure-mgmt` — the production topology, celilo-mgr on its own * control-plane network. (It used to default to `internal`, the * single-network topology, and that default is why the control-plane-network * bug reached production: with management always on `internal`, the hardcoded * "trusted subnet == network.internal.subnet" assumption was accidentally * true in every test.) * * A suite that genuinely needs the legacy single-network topology sets * `internal` EXPLICITLY and says why in a comment. */ managementZone?: 'internal' | 'secure-mgmt'; /** * Observer vantages to inject into the topology. Each becomes an `observer-*` * container — a passive spy carrying the probe tooling, placed at the named * vantage's network location with a routing profile that mirrors a real device * there (ISS-0117). Drive them via the VantageProbe transport. */ observers?: ObserverSpec[]; /** * Absolute path to the Celilo project root (the directory containing apps/celilo/). * Mounted into the management container at /celilo so the CLI is available. * If not set, auto-detected by walking up from the package directory. */ celiloRoot?: string; /** * Skip DNS zone scrubbing before the test starts. Useful for --keep * debugging where you want to inspect the exact DNS state left by a * previous test. Default: false (scrub runs). */ skipDnsScrub?: boolean; /** * Which management container variant to start. Default ('default') * uses the image baked with @celilo/cli + the source-mount shim. * 'vanilla' uses an image with bun/unzip but no celilo binary — the * caller is expected to install celilo at runtime, e.g. by running * install.sh inside the container. See packages/e2e/tests/install-sh.test.ts. */ managementVariant?: 'default' | 'vanilla'; /** * When set, brings up a SECOND management-class box named `celilo-mgr-2` * (at 10.226.1.101 on `internal`) using the given image variant. Used by * the migration restore e2e: the primary box is the backup source, the * second box is a fresh target that `apt install celilo` + `celilo restore`s * into. The second box is NOT auto-init'd by container-manager (which keys * off the `management` name), so it comes up as an empty restore target. */ secondaryManagementVariant?: 'default' | 'vanilla'; } export interface ExecResult { stdout: string; stderr: string; exitCode: number; } /** Error thrown when a celilo CLI command exits non-zero */ export class CeliloCommandError extends Error { constructor( public readonly cmd: string, public readonly result: ExecResult, ) { const tail = result.stdout.slice(-500) + result.stderr.slice(-200); super(`celilo ${cmd} failed (exit ${result.exitCode}):\n${tail}`); this.name = 'CeliloCommandError'; } } /** * Options for socksProxy() and browser(). Both accept the same vantage * choice — "where on the simulated network does the host-side traffic * enter from?". See SocksProxyOptions in socks-proxy.ts for the full * description of each vantage. */ export interface ProxyOptions { vantage?: 'isp-external' | 'internal'; /** * Resolver the proxy should use, overriding the vantage's baked default. * * The proxy is what actually resolves names: a browser configured with * `socks5://` hands the HOSTNAME to the proxy and never resolves it itself. * So a test driving a browser at a name only the fleet's own resolver knows * — anything on a private ingress — must point the proxy at that resolver. * * This is more faithful, not less: the rig's baked stub answers a fixed * split-horizon table, whereas a real device on the internal zone is handed * the fleet's resolver by DHCP. Pass the deployed resolver's DNS-ingress * address here to model that. */ nameserver?: string; } export interface SocksProxyHandle { /** URL the host can use to reach the proxy, e.g. 'socks5://127.0.0.1:54321' */ hostUrl: string; /** The published host port */ hostPort: number; /** Container name (deterministic per project + vantage) */ containerName: string; /** Tear down the proxy. Idempotent. */ stop(): Promise; } /** * Minimal shape of Playwright's `BrowserType` (the thing you import as * `chromium`/`firefox`/`webkit`). Declared structurally so @celilo/e2e * never imports playwright-core itself — the consumer brings their own * version, controlling the chromium binary alignment. */ export interface BrowserLauncher { launch(options: { proxy: { server: string } }): Promise; } /** * Options for browser(). The consumer must pass their own `chromium` * (or `firefox`/`webkit`) launcher from playwright/playwright-core. * @celilo/e2e has no playwright dep — this inversion of control means * each consuming module pins the playwright version they want, and * cached browser builds align naturally. * * Example: * import { chromium } from 'playwright-core'; * const browser = await net.browser({ vantage: 'isp-external', chromium }); */ export interface BrowserOptions extends ProxyOptions { /** Playwright BrowserType — typically `chromium` from `'playwright-core'`. */ chromium: BrowserLauncher; } /** * Browser handle returned by NetworkHandle.browser(). Wraps a Playwright * Browser plus the SOCKS proxy it routes through. Calling close() tears * down both — and net.stop() will close any unclosed browsers. * * The underlying `browser` is typed loosely (`unknown`) so @celilo/e2e * stays free of playwright deps. Cast at the call site: * * import type { Browser } from 'playwright-core'; * const handle = await net.browser({ vantage, chromium }); * const browser = handle.browser as Browser; */ export interface BrowserHandle { /** The underlying Playwright Browser. Cast to `Browser` from your playwright import. */ browser: unknown; /** The proxy this browser routes through (exposed for diagnostics) */ proxy: SocksProxyHandle; /** Close the browser and tear down the proxy. Idempotent. */ close(): Promise; } export interface NetworkHandle { /** * Run a celilo CLI command on the management machine. * Throws CeliloCommandError on non-zero exit. * Pass `{ check: false }` to get the raw ExecResult instead. */ celilo(cmd: string, timeoutMs?: number): Promise; celilo(cmd: string, opts: { check: false; timeoutMs?: number }): Promise; /** Execute an arbitrary command in any container */ exec(container: string, cmd: string, timeoutMs?: number): Promise; /** * Where celilo actually put a module, read from `module status`. * * Throws if the module is not deployed — "not deployed" and "I could not read * the placement" are different problems and a test should not have to tell * them apart from a silent empty result. */ moduleHost(moduleId: string): Promise; /** * The IPv4 address a module's deploy recorded, from `celilo module where * --json` (the `module_systems` inventory). * * Replaces `grep target_ip` over the module's `generated/` tree, which D4 * of control-plane-stops-building-modules deletes after every successful * deploy (celilo#1334). The inventory is the deploy's own durable record of * the address, so this works on the machine pool and the container service * alike. Throws when the deploy recorded nothing. */ targetIp(moduleId: string): Promise; /** * Tell celilo where its own control plane lives, and wait until the box is * usable again. * * Required by any suite that runs celilo-mgr on `secure-mgmt` AND deploys a * firewall — which is every container-service conversion, because the Proxmox * simulator lives on `secure-mgmt` and `service add proxmox` has to reach it. * * The firewall's trusted sources are DERIVED from where celilo-mgmt is * deployed, not hardcoded, and `secure-mgmt` is deliberately not a tier in the * rule matrix — it reaches every tier by trust. So this must run BEFORE * `deployFirewall()`, which is when those rules are computed. Skip it and * fw-main's default-DROP FORWARD chain has no rule for the control-plane * subnet: every SSH from celilo-mgr into a segmented zone times out, and it * surfaces as the deployed module's hook failing rather than as a firewall * policy gap. * * Also absorbs a race that is not the caller's business: the deploy rewrites * the management box's own `~/.ssh`, so this does not return until the private * key is readable again. */ registerControlPlane(options?: { moduleDir?: string }): Promise; /** * Execute a command on the host a module is deployed on, WITHOUT knowing how * that host came to exist. * * `exec('caddy', …)` names a compose service, which exists only because the * topology declared a machine. Place the same module on a container service * and celilo provisions the host itself: no compose service by that name, and * the call fails in a way that reads as the module breaking. * * This resolves the host from celilo and reaches it the right way — compose * exec for a declared machine, plain docker exec for a provisioned guest, * which is a real container the compose project knows nothing about. Use it * in any suite that could run either way. */ execOnModuleHost(moduleId: string, cmd: string, timeoutMs?: number): Promise; /** * Pause the test and drop into an interactive shell for debugging. * The test resumes when you exit the shell. * * @param container - Which container to shell into (default: 'management') */ debug(container?: string): Promise; /** Resolve a DNS name from the management machine */ dig(name: string): Promise; /** * Wait for a condition with timeout. On timeout, `onTimeout` (if given) is * invoked to collect live-state diagnostics that are appended to the error — * so a readiness failure self-diagnoses instead of speculating * (e2e-confidence #255). */ waitFor( check: () => Promise, timeoutMs: number, label: string, onTimeout?: () => string | Promise, ): Promise; /** * Set the Caddy ACME CA to the Pebble endpoint so TLS certificates are * issued by the simulated Let's Encrypt rather than the real one. * * Call this after importing the caddy module and before deploying it. * Every e2e test that involves TLS must call this — it is not automatic * so the call site is self-documenting. */ configureAcme(): Promise; /** * Package a local module and upload it to the e2e registry so it can be * imported via `celilo module import `. * * @param localPath - Absolute or relative path to the module source directory */ publishModule(localPath: string): Promise; /** * Spawn a SOCKS5 proxy attached to the test network. The proxy * resolves DNS *inside* the simulated environment, so HTTPS hostnames * like `iamtheinternet.org` work transparently. Use this for manual * operator sessions (configure Firefox to use the returned hostUrl). * * For automated browser-driven tests, prefer browser() — it creates * and owns its own proxy implicitly. * * Idempotent per-project per-vantage: calling twice returns the same * container. */ socksProxy(options?: ProxyOptions): Promise; /** * Launch a Playwright browser inside the simulated network. * Internally creates a SOCKS proxy and routes browser traffic through * it. The consumer must pass their own `chromium` launcher (from * `playwright-core` or `@playwright/test`) — @celilo/e2e has no * playwright dep. Cast the returned `browser` field to `Browser` from * the same playwright import. * * Trust boundary: the published proxy port is bound to 127.0.0.1, and * authentication happens at the app layer (bearer token). The test * should typically `newContext({ ignoreHTTPSErrors: true })` so * Caddy's Pebble-signed cert is accepted. */ browser(options: BrowserOptions): Promise; /** * Start a programmatic bus responder inside the management container * that auto-answers `config.required.*`, `secret.required.*`, and * `ensure.required.*` events from a values map. Run BEFORE any * `module deploy` that might have un-staged required config — * otherwise the deploy fires bus events looking for a responder and * hangs until its timeout (typically 5min). * * Stage 4 of the interactive-deploys-via-bus design removed the * `--no-interactive` flag that previously aborted on missing config. * Now missing config emits bus events expecting a responder. Tests * have two ways to satisfy this: * (a) pre-stage every required value via `module config set` / * `module secret set` before each deploy (verbose but * explicit); * (b) call `respondWith({...})` once per network with all the * values for the whole test, then proceed (this method). * * The responder runs detached inside the management container. * It's torn down automatically when `stop()` removes the container. * Idempotent — calling twice replaces the prior values map. * * Values format mirrors `infra/services/programmatic-responder.ts`: * ``` * { * config: { '.': , ... }, * secrets: { '.': '', ... }, * ensures: { * '.': { * configValues: { 'config.': }, * secretValues: { 'secret.': '' }, * } * } * } * ``` */ respondWith(values: ResponderValues): Promise; /** * Deploy the iptables firewall, which PROVIDES the given zones — it * writes network..subnet/gateway into system config so celilo can * place modules in (and infer machine zones for) dmz/app/secure. * * This mirrors the real deployment order: stand up the firewall before * placing services in the zones it segments. Since the harness no longer * pre-seeds dmz/app/secure (only `internal`, which the mgmt box is on), * any test that deploys into those zones must call this first. Tests * that are internal-only (or use a different edge, e.g. greenwave) don't * call it. * * Targets fw-main (the customer firewall) using the standard topology * subnets from ZONE_SUBNETS / ZONE_GATEWAYS. * * Both the declared zone list AND `provided_networks` are derived from the * topology this network was built with. There is no `zones` option: a firewall * holding an address on a segment celilo has no subnet for classifies `alien` * and the converge refuses, so the wired legs and the declared zones must be * the same list. A test that wants a firewall with fewer legs changes the * topology, which is the honest way to say it (design D8). * * @param opts.natIp - NAT IP for port-forwarded traffic (default 10.226.1.253) */ /** * @param opts.config - Extra `module config set iptables ` pairs, * applied after the topology-derived ones and before the deploy. For * settings whose whole point is that they are OPT-IN: the harness deploys a * DEFAULT firewall, so a suite testing an opt-in policy has to turn it on * the way an operator would, and asserting on a default deploy would only * ever measure the default. */ deployFirewall(opts?: { natIp?: string; check?: boolean; config?: Record; }): Promise; /** * Deploy the `greenwave` module against the ISP router (`fw-isp`) — the box * that actually owns the WAN in the `default` topology. * * This is what makes the production shape work end to end. celilo's own * firewall is DOWNSTREAM: it holds only RFC1918 legs, reports no external * edge, and `exposeService` therefore has to delegate up the chain to a * provider whose firewall capability declares `has_external: true`. greenwave * is that provider. Without it a `default`-topology test that exposes * anything publicly fails at the delegation step, which is correct — there is * genuinely nothing that knows the fleet's public address. * * Not needed under `direct-internet`, where fw-main owns the edge itself. */ /** * Plug the named containers into a NEW network celilo has no subnet for, and * return the address each was given. * * For interface-classification tests: the firewall gains a real interface * with a real global address that matches no declared zone, and a peer on the * same segment gives the isolation assertion a real signal to measure — can * that segment still reach a service on the firewall — rather than a rule * string in `iptables-save`. * * `subnet` must NOT be one of `ZONE_SUBNETS`; an attributable segment defeats * the purpose. */ attachAlienSegment(opts: { subnet: string; containers: string[]; }): Promise>; /** Unplug and remove the segment `attachAlienSegment` created. */ detachAlienSegment(): Promise; deployGreenwave(): Promise; /** Tear down the entire network */ stop(): Promise; /** The docker-compose project name (for debugging) */ projectName: string; } /** * Values map for the in-container bus responder. Keyed by * `.` for config + secret events, and * `.` for ensure events. */ export interface ResponderValues { /** * Plain config values, replied back as-is in the bus reply payload. */ config?: Record; /** * Secret values. The responder writes these to the encrypted store * out-of-band (never on the bus); the reply is `{ acknowledged: true }`. */ secrets?: Record; /** * Cross-module ensure inputs. For each `set_in_object` input in the * payload, the responder either replies with the value (config * targets) or writes the secret out-of-band. */ ensures?: Record< string, { configValues?: Record; secretValues?: Record; } >; /** * Aspect-consent decisions (ISS-0027 / #262). When a headless deploy emits * `aspect.required..`, the responder replies `{ consented }` * so a module's base_module_aspect fan-out is approved without a TTY and * without `--accept-aspects` at import. Lookup precedence: `.`, * then ``, then the `'*'` wildcard. Absent → skipped (never * silently approved). Passed straight through to `celilo events respond`. */ aspects?: Record; /** * Generic interview answers, keyed `.` (ISS-0127). * * The value's shape follows the question: string for text/select, string[] * for multiselect, boolean for confirm. This is how a command that is neither * a deploy nor a module — `service add proxmox`, say — gets driven headlessly. * * The responder has supported this family since ISS-0127; only this type was * missing it, which made the capability unreachable from a test. */ interview?: Record; } /** * The parent block every simulated PRIVATE zone lives inside (#539). * * The sim used to number its zones exactly like a real celilo fleet: its dmz, * app and secure /24s WERE the fleet's, and its internal LAN was the operator's. * That is not a cosmetic overlap — celilo's own forgejo-builder lives in the * REAL dmz, so a leaked stack put a second interface on the builder's own /24 * and blackholed every containerized CI job's route to the forge for ~135s. * Twice in one afternoon. Teardown can't be made reliable enough to fix that (a * SIGKILL runs no handler), so the fix is that a leak stops MATTERING. * * `10.226.0.0/16` — mnemonic `0xE2` = "e2", the e2e supernet. It must be * RFC-1918 (the private zones model private networks — CLAUDE.md inviolable * rules #1/#3), and this block collides with nothing plausible: * - the fleet numbers its zones out of the bottom of 10/8 → far away * - the operator's VPN sits at the very top of 10/8 → far away * - the operator's LAN is in 192.168/16 → different block * - Docker's default address pools are 172.16/12 and * 192.168/16 → different block * * The second reason matters more than the collision: there must be nothing * magic about the octets production happens to use. A suite that only passes on * those exact numbers is asserting one site's address plan rather than celilo's * behaviour. Renumbering proves address-independence. * * Do not move a zone out of this block. `address-plan.test.ts` fails if you do, * and fails if the retired fleet-colliding prefixes reappear anywhere in the * suite. */ export const SIM_PRIVATE_SUPERNET = '10.226.0.0/16'; /** * Third-octet plan inside {@link SIM_PRIVATE_SUPERNET}. Host octets are * unchanged from the pre-#539 numbering, so `caddy` is still `…10.10` and * fw-main is still `…1.254` — only the network part moved. */ const ZONE_PREFIXES: Record = { internal: '10.226.1', dmz: '10.226.10', app: '10.226.20', secure: '10.226.30', 'secure-mgmt': '10.226.120', }; /** * An address in a zone, by host octet. Reference this instead of writing an * octet: it is what makes the whole plan a one-line change, and what makes a * stray hardcoded address stand out as the anomaly it is. */ export function zoneIp(zone: Zone, host: number): string { return `${ZONE_PREFIXES[zone]}.${host}`; } /** The router/firewall leg in each zone. `internal`'s is fw-main's LAN side. */ export const ZONE_GATEWAYS: Record = { dmz: zoneIp('dmz', 1), app: zoneIp('app', 1), secure: zoneIp('secure', 1), internal: zoneIp('internal', 254), 'secure-mgmt': zoneIp('secure-mgmt', 1), }; export const ZONE_SUBNETS: Record = Object.fromEntries( (Object.keys(ZONE_PREFIXES) as Zone[]).map((zone) => [zone, `${ZONE_PREFIXES[zone]}.0/24`]), ) as Record; /** * The firewall's internal-side DNAT ingress (the natIp) in the e2e topology — the role by * which an internal device reaches a service that lives in a segmented zone. Single source * of truth: `deployFirewall` uses it as its default and tests assert against it, so the * asserted natIp is by construction the one that was deployed. Reference this instead of * hardcoding the address in a test. */ export function internalNatIp(): string { return zoneIp('internal', 253); } /** * The internal network's internet-facing gateway router (the greenwave / fw-isp sim) — the * default route for internal devices toward the internet. Distinct from * `ZONE_GATEWAYS.internal` (fw-main), which is the firewall to the segmented zones. Reference * this instead of hardcoding the address in a test. */ export function greenwaveRouterIp(): string { return zoneIp('internal', 1); } /** * The customer firewall's EXTERNAL (WAN) address — what the public internet * sees, and the only address a public A record for a fronted service may hold * (CLAUDE.md inviolable rule #1). Also the source address a DDNS update * acquires on its way out, which is what makes source-IP registration correct * by construction. Reference this instead of hardcoding it. */ export function externalWanIp(): string { return '203.0.113.100'; } /** * The customer's public /24 on `isp-external`. The ISP ROUTES this prefix; it * never re-NATs it, so a packet leaving the fleet still carries the firewall's * WAN address when it reaches a public service. */ export function externalWanSubnet(): string { return '203.0.113.0/24'; } /** The internal split-horizon DNS resolver (dns-int) in the e2e topology. */ export function internalResolverIp(): string { return zoneIp('internal', 10); } /** The public upstream recursive resolver (the comcast-resolver sim). */ export function publicResolverIp(): string { return '203.0.113.1'; }