# Celilo Core Modules

**What this is:** the production modules celilo ships in `modules/` — the infrastructure celilo
actually *deploys*. Companion to [CELILO_SUBSYSTEMS.md](./CELILO_SUBSYSTEMS.md) (which maps
celilo's own engine). Use this to know what already exists before building a new module, and to
understand how the fleet wires together via capabilities.

Each entry: `module id` — what it is — **provides** / **requires** capabilities. Its source is
`modules/<id>/manifest.yml`; capability contracts are listed in
[CELILO_SUBSYSTEMS.md](./CELILO_SUBSYSTEMS.md).

> **Paths below are relative to the celilo source-repo root** (e.g. `modules/caddy/manifest.yml`)
> — references into the source tree, not links relative to this file (this doc also ships inside
> the `@celilo/cli` npm package). Re-grep manifests if an entry looks stale.

> How the graph closes: a public app (`requires: public_web`) is served by **caddy**, which
> `requires: dns_registrar` (**namecheap**) to publish its name and `firewall` (**axon** /
> **greenwave** / **iptables**) to open the port. Internal name resolution comes from a **dns_internal** provider
> (**knot-unbound-internal** / **technitium**). Identity comes from **authentik** (`idp`). That's
> the whole edge: DNS + firewall + ingress + identity, each a swappable provider module.

> How the graph OPENS again: when a consumer is removed, celilo dispatches
> `on_consumer_removed` to every provider of every capability that consumer
> declared, and each withdraws what it minted on its behalf
> (openspec/changes/consumer-removal-cleanup). Providers implementing it:
> **caddy** and **caddy-internal** (drop the routes, reclaim `/srv/www/<slug>`),
> **iptables** / **greenwave** / **axon** (drop the forwards and trusted
> sources), **authentik** (delete the OIDC application), **forgejo**
> (deregister the CI runner), **generic-cpanel-hosting-provider** (remove the
> published docroot). Consumers do NOT withdraw their own state any more — a
> module gives a port or a subnet back by narrowing its own declaration, and
> departing entirely is the provider's job. A provider that fails to withdraw
> is marked ERROR; the removal still completes.

## Network fabric (DNS / firewall / DHCP)

- **axon** — Axon Networks Q1000K ISP router driver (Brightspeed-branded); port-forwarding + public-IP discovery + DHCP DNS + DHCP address-pool bounds via the TR-181 CGI API. **provides:** `firewall`, `dhcp_server`. Fork of **greenwave** — identical protocol, differing only in the vendor extension prefix (`X_AXON_` vs `X_GWS_`/`X_LANTIQ_COM_`). Pick by device: Q1000K → **axon**, C4000XG → **greenwave**. Two device behaviours shape this driver and are not shared by **greenwave** (celilo#739): the router **regenerates** `Pool.1.DNSServers` from its own upstream resolver list every ~15 min and on any config commit, so `setDhcpDns` also writes `Device.DNS.X_AXON_CustomServer` — the *input* to that computation, which survives — and **technitium re-asserts** the pool value on `timer.tick.1m`. Optional `dhcp_pool_start`/`dhcp_pool_end` bound the leased range; set them below the addresses IPAM allocates, because **IPAM has no knowledge of the router's DHCP pool** and nothing otherwise stops the router leasing an address celilo already assigned.
- **greenwave** — GreenWave C4000XG ISP router driver; port-forwarding + public-IP discovery via REST. **provides:** `firewall`, `dhcp_server`. Legacy device; new deployments on Axon hardware want **axon**.
- **iptables** — iptables firewall + NAT; cross-VLAN port exposure with recursive upstream delegation. Converge model: `exposeService` registers into the shared-core port-forward registry, then renders the complete ruleset and applies it atomically via `iptables-restore` (default-DROP FORWARD + coarse zone-tier matrix; SSH-free). **REQUIRES the networks it has legs on and defines none of them** (`requires.networks: [{from: $self:zones}]`) — the required set is resolved from the same `zones` value that says which NICs the box has, so the legs and the declared networks are ONE list. They used to be two: `zones` and a `provided_networks` array this module wrote into system config from its install hook, with nothing forcing them to agree. A leg whose network has no declared subnet classifies `alien`, and on a firewall with a recorded baseline an alien interface is DISABLED — so under-declaring was never cosmetic. `firewall-interface-classification` §3/D8 reached that conclusion for the test harness; `networks-are-declared-not-written` applies it in production and deletes `provided_networks` outright (breaking: iptables 3.0.0). celilo supplies each range, asking the operator when it holds none. **provides:** `firewall`.
- **dnsmasq-dhcp** — a DHCP-only dnsmasq in **`internal`**, providing the zone-scoped `dhcp_server` capability for a network with no upstream router to serve it. **provides:** `dhcp_server` (zone `internal`). The other two `dhcp_server` providers, **axon** and **greenwave**, do not run a DHCP server at all — they drive an ISP router's built-in one over its API, which works only while the home network shares a broadcast domain with that router. Put `internal` behind the celilo firewall and DHCP discovery, being a broadcast, no longer reaches it: celilo could describe that topology and not make it work. Importing this module at all depends on well-known-provider uniqueness being scoped PER ZONE (celilo#1028), since axon/greenwave already claim `dhcp_server` fleet-wide. **`port=0` is load-bearing**: dnsmasq ships a resolver as well as a DHCP server, and `dns_internal` is a different capability with its own providers — two resolvers answering on one network is a split-brain that is very hard to see from outside. celilo's transactional artifacts go to `/var/lib/celilo/config-state/` via `applyRenderedConfig`'s `statePath`, NOT beside the managed fragment, because dnsmasq parses every file in `/etc/dnsmasq.d` and a `.celilo-bak` there is configuration the daemon reads. It declares a `requires.system` and is placed by celilo like any other module; an earlier shape declared none, which meant no host in the inventory, an `apt-get` shelled over SSH from a hook, and an operator-typed address — and which also evaded `validateMachineRoleForModule`, the check that refuses to place a non-firewall module on a multi-interface router.
- **knot-unbound-internal** — split-horizon internal DNS via Knot (authoritative) + Unbound (recursive); lightweight, plain apt, no .NET. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/knot-unbound-internal/base-module-aspect/`).
- **knot-unbound-secondary** — a read-only secondary for **knot-unbound-internal**: Knot transfers every managed zone from the primary and Unbound serves the same split-horizon view. Lives in **`dmz`**, symmetric with the primary (design D5e) — NOT in `internal` as first written. That placement was chosen deliberately and the reasoning is worth keeping: design D5a records the secondary's justification as weak on purpose, since steady state is already a single resolver address and the one recurring non-bug case a secondary covers is redeploying the primary, which blanks fleet DNS for the duration. Same-zone covers that completely. A split placement buys zone-level independence, which D5a explicitly declined, and would not deliver it anyway — celilo has no host anti-affinity, so both LXCs land on the container service's `default_target_node`. **provides:** `dns_internal_secondary`, carrying its OWN addresses rather than being advertised by the primary: if the primary carried them its published data would be stale from the moment this module deployed until something recomputed it, and recomputing means redeploying the primary — the manual step D5 exists to remove. **requires:** `dns_internal` (hard, and honestly declared: it sat under `optional` until celilo#854, because the import-time secret gate refused a consumer over a `tsig_key` it never reads). Its protected-zone ingress is a `*_ingress_ip` / `source: infrastructure` variable that `ensureIngressIps` allocates from `network.internal.subnet`, so the operator types none of the three addresses the fork's version asked for. Registering it as a transfer peer on the primary is `registerTransferPeer` (design D5f) — ⚠️ the empty peer state MUST render an explicit `deny: on`, because a knot acl with `action: transfer` and no `address:` matches ANY source and `knotc conf-check` accepts that silently.
- **technitium** — internal split-horizon DNS resolver + authoritative server (web UI + HTTP API); heavier alternative to knot-unbound. **provides:** `dns_internal`. Ships a base-module-aspect (`modules/technitium/base-module-aspect/`).
- **namecheap** — public DNS A-record management via Namecheap Dynamic DNS API (HTTP, no browser automation). A caller supplies a NAME and nothing else: the address is the source IP of celilo's own update, re-derived on every assert. Registering `<domain>` also claims `www.<domain>` and vice versa (best effort, reported back as `outputs.companion_fqdn` so the framework's `public_dns` check watches it — Namecheap answers `ErrCount 0` for `www` updates it does not apply). DDNS passwords are keyed by the **registrable domain**, never the FQDN. **provides:** `dns_registrar`.
- **wireguard** — owns the admin WireGuard tunnel on the firewall host: interface, listen port, peers (as records), and client subnet are module config rather than hand-maintained state. Exposes the listen port and **registers the client subnet as a trusted source**, so VPN reach into the managed zones is in the firewall registry and every converge re-emits it. **REQUIRES the `control-plane-vpn` network and READS its range** (`requires.networks`; `client_subnet` is a `source: system` derive of `network.control-plane-vpn.subnet`). It does not write that network and has no way to — celilo owns the namespace, and the deploy will not reach any hook until the network is defined, asking for a range if one is missing. So `wg0` is attributable the moment it exists. This replaces a declare-before-you-create ordering inside `on_install`, which could only narrow the window and not close it: a consumer that captured config before the hook started could not see a value the hook wrote, whatever order it wrote it in, and that is exactly what left `wg0` unattributable (celilo#759). `health_check` still asserts the declaration matches what the tunnel serves — what it catches now is divergence, celilo's network having changed since the module resolved its config. That same key is what the internal resolver's split-horizon view consumes. Adopts a running tunnel in place (existing key retained; `wg syncconf`, never `wg-quick down`) because that tunnel is the operator's recovery path. **Adoption is a one-time IMPORT and it ENDS.** The marker is `registered_peers` — celilo's own config key, separate from the operator's `peers` so a machine can never rewrite what an operator typed. While that key has never been written the tunnel is not yet celilo's; the deploy that writes it (even as `[]`) CLAIMS the tunnel, importing whatever `[Peer]` blocks are running, and from then on the render is closed-world (`peers` ∪ `registered_peers`) with a `[Peer]` on the box in neither reported as drift by `on_install` and by `health_check`'s `unknown_peers`. An ABSENT `peers` declaration and a declared-EMPTY one are deliberately different (no `default: []` on either variable): they used to be the same value, so the module read every empty list as "adopt what is running", the last peer could not be revoked, and a hand-added peer rode along on every deploy unreported (celilo#765). On a tunnel celilo has not yet claimed, a declared-empty `peers` is REFUSED rather than obeyed — celilo's own variable-default seeding wrote a real `peers = []` row for every install of the previous version that never set one, so on the installed base an empty list cannot be told from a stored default, and obeying it would `wg syncconf` the admin tunnel down to zero peers. **PROVIDES `control_plane_vpn`** so another module can enrol clients without an operator editing YAML: `registerClient` / `revokeClient` write to `registered_peers` — the same key the adoption claim uses — and `getEndpoint` reads the server's public key LIVE off the host rather than storing a copy that could be republished after the key changed. `client_pool` is the range a consumer may allocate from, a strict subset of the client subnet, and `validate_config` REFUSES any operator-declared peer inside it: the two allocators cannot see each other, so the range is divided rather than negotiated, and that check is the only place a bad division is caught. ⚠️ Granting this capability grants fleet-wide reach — the tunnel's client subnet is a registered trusted source, so every client enrolled through it reaches every managed zone. **requires:** `firewall` (and the provider must support trusted-source registration — `iptables` does; the ISP-router drivers `greenwave` and `axon` do not).
- **wireguard-manager** — self-service enrolment for the control-plane VPN, so adding an administrator's laptop is a web page rather than an operator editing `peers` by hand. A browser generates the WireGuard keypair with WebCrypto and **the private key never leaves it** — celilo never sees it and the app never stores it. The app allocates an address from `client_pool` (the range `wireguard` reserved for a consumer; celilo's IPAM deliberately does not cover VPN clients) and enrols the peer through **`control_plane_vpn`**, so it never edits wireguard's config and cannot reach a peer the operator declared by hand. Its own device list is the app's SQLite, reconciled onto the tunnel by the `reconcile_clients` hook on a `timer.tick.1m` subscription. Two guards make a failed poll harmless and both are structural rather than a check: `planReconcile` accepts only the `read` variant of a discriminated union, so an unreadable poll cannot reach it, and revocation is TOMBSTONE-driven — a device is revoked because the app says it was, never because it is missing from a list — so a short or empty read can only fail to add someone, never cut anyone off. A peer on the tunnel the app does not know and has not tombstoned is reported as an orphan and LEFT ALONE (usually an app restored from an older backup). **REQUIRES `private_web`, never `public_web`**: there is no public record, no ACME certificate and no port forward, and the internal CA's certificate is bundled with each downloaded config so a freshly enrolled device trusts the manager it just connected to. **The app authenticates people ITSELF** — it runs the OIDC authorization-code + PKCE flow from its own page, exchanges the code server-side (the client is confidential), verifies the resulting access JWT against the issuer's JWKS and reads the group claim out of the token it verified. No cookie and no server-side session, because the page is self-contained client-side JS and a session store on the box that hands out fleet access is state worth not having. It previously trusted an `X-Forwarded-User` header from an authenticating ingress that celilo has never had — leaving it both unusable (the header was always absent) and forgeable (anyone reachable could set it); `identity_header` is gone with no fallback. ⚠️ **Everyone enrolled becomes a fleet administrator** — the tunnel's client subnet is a registered trusted source, so membership of `admin_group`, checked against the token's group claim, is the entire access-control story; per-peer narrowing is not possible, because server-side `AllowedIPs` is crypto-key routing rather than access control. Lives in **`app`**, one tier behind `caddy-internal` in `dmz`, and is never reached directly (celilo#879). Enrolment works from INSIDE the fleet only (a device not yet on the VPN cannot reach a manager reachable only over it) — from the LAN through the ingress IP, from a segmented zone at the ingress's dmz address, or over an existing VPN session, and a revocation takes effect at the next reconcile — that interval IS the window in which a revoked device still has reach, which is why the UI shows it pending and why `celilo module run-hook wireguard-manager reconcile_clients` exists. **requires:** `control_plane_vpn` (wireguard), `idp` (authentik), `private_web` (caddy-internal).

## Public edge (ingress / identity)

- **caddy** — reverse proxy with automatic HTTPS (HTTP-01 ACME); the standard HTTPS ingress. Emits a 301 redirect block for each served name's `www`/apex companion (kept out of the served-hostname set, so a companion never blocks the ACME wait). Its `health_check`'s DNS item is `internal_dns_resolution` — it digs from the management host behind the split-horizon resolver and is evidence about the in-fleet view only; public reachability is the framework's `public_dns` check. **provides:** `public_web`. **requires:** `dns_registrar`, `firewall`.
- **caddy-internal** — a SECOND Caddy that serves the fleet and nothing else, and is a sibling of **caddy** rather than a mode of it. It never exposes a port on the firewall's EXTERNAL interface, never publishes a public record, and takes its certificates from Caddy's own local CA (`tls internal`) — those absences ARE the capability, and `public_web` cannot express them because it treats an unreachable route as a deploy failure and publishes a public A record to prevent one (design D10). Lives in the **`dmz`** zone, in front of the things it fronts, mirroring the public `caddy` (celilo#879). LAN devices reach it through an IPAM-allocated `internal`-subnet `ingress_ip` that `on_install` passes to `firewall.exposeService({ ingressIp })` — one DNAT on the firewall's INTERNAL side and nothing external, the same mechanism the dmz-resident `dns_internal` resolver has used for `:53` since ISS-0156. Systems already inside `dmz`/`app`/`secure` use its dmz address instead (carried as `zoneRoutableValue` on the internal record), and VPN clients arrive as a registered trusted source. It previously lived in `internal` on the claim that a dmz ingress could not be reached from a LAN without a public port-forward; that conflated a public forward with an internal-side ingress IP, and cost it the ability to serve a browser inside a segmented zone at all. **`getCaCertificate()` is why the contract has a method `public_web` does not** — an internally-issued cert means clients must trust a CA celilo runs, and here that is nearly free because the people who must trust it are the ones who just downloaded a bundle from the service behind it, so the anchor ships with it. **The route table is this module's OWN config (`routes`), never celilo's `web_routes`** (celilo#846): caddy derives its served hostnames from every row of that table, so a private route stored there would be picked up and served PUBLICLY — storage is the privacy boundary, not policy. Route changes reconcile synchronously in the capability call (there is no `routes_changed` event for private routes, and inventing one would only add delay). **Every proxied route strips a client-supplied `X-Forwarded-User`** (`header_up -X-Forwarded-User`): this ingress routes and does not authenticate, so a backend believing that header would believe whatever the client sent — which `wireguard-manager` shipped doing. That module verifies a signed token now, so the strip protects the NEXT backend written against the same assumption. It is defence in depth, NOT an authenticating proxy: adding `forward_auth` to `private_web` is a capability change and deliberately not bundled with it. A consumer cannot register a route on a hostname the ingress is not already configured for: a capability factory gets no capabilities of its own, so it cannot add the internal DNS record a new name would need, and it refuses rather than serving an unresolvable site block. **provides:** `private_web`. **requires:** `dns_internal` — a hard requirement, since `on_install` throws without it. It sat under `optional.capabilities` until celilo#854 was fixed, because the import-time secret gate refused a consumer over a secret it never reads.
- **generic-cpanel-hosting-provider** — publishes static sites into a subfolder of a domain on a cPanel/SSH web host celilo does **not** govern (the host owns the domain, DNS, TLS and web server). Systemless like **namecheap** — no `requires.system`, no zone, no IPAM; it holds credentials for an external party. One provider serves many accounts, resolved by hostname; onboarding takes the account password once, `ssh-copy-id`s celilo's key, then discards it. **provides:** `external_web`.
- **authentik** — Authentik identity provider with OIDC (Docker Compose: server, worker, Postgres, Redis). **provides:** `idp`. **requires:** `public_web`, `dns_registrar`, `firewall`.

## Celilo's own infrastructure (self-hosted)

- **celilo-mgmt** — the celilo management server itself, deployed as a module (replaces install.sh + `system init`; ships daemon, runs migrations). Has **no `on_install`** — celilo initialises the box itself during the deploy (`bootstrapControlPlane`), because a jailed hook cannot spawn the CLI it used to reach (celilo#1225). **provides:** `celilo_event_bus`, `celilo_module_deploy_worker`. **requires:** `cross_module_read`. See `openspec/specs/management-as-module/spec.md`.
- **celilo-registry** — module registry server (Cargo sparse protocol); stores `.netapp` files, serves index + search/download API. On install it provisions a confidential introspection OIDC client via `idp.create_oidc_client` (SECURE_MODULE_PUBLISH.md §5[D-A]) and converges its issuer + introspection endpoint + creds onto the box for RFC 7662 token verification. Its `sweep_revisions` hook is the store's ONLY delete path — `yank` flips a boolean in the index entry and frees nothing, so before it existed every build revision ever published was retained forever and a `+N` revision is auto-assigned on every publish. The production store reached 18 GB of which ~93% was superseded history, filled a 20 GB disk and took the release pipeline down. The sweep keeps the newest `sweep_keep_build_revisions` (default 1) of EVERY release, so no release can disappear and a manual rollback still works — that is what makes the hourly `timer.tick.1h` subscription defensible. It removes the index line BEFORE the payload, because the download route reads the payload file directly and never consults the index: that order leaves an interrupted sweep with an unlisted-but-served version rather than a listed one that 404s, and the orphan it leaves is reclaimed by the next run. On demand with `celilo module run-hook celilo-registry sweep_revisions` (add `dry_run=true` to see the plan). **provides:** `registry_publish`. **requires:** `public_web`, `dns_registrar`, `idp`.
- **celilo-apt-repo** — Debian apt repository (reprepro + Bun HTTP server) serving the celilo `.deb` at apt.celilo.computer. **provides:** `apt_publish`. **requires:** `public_web`, `dns_registrar`.
- **signal** — bidirectional Signal transport for alerts and deploy-interview questions; runs signal-cli in daemon mode with its JSON-RPC socket bound to the host's own address (never public) and **`--receive-mode=manual`**, which is load-bearing: signal-cli's default (`on-start`) leaves the daemon permanently receiving, so it drains every reply into an SSE stream nothing is attached to and REFUSES celilo's `receive` call — replies arrive and are unreadable, while `send` and every health check keep passing. Enrolled as a SECONDARY DEVICE of an existing Signal account rather than registering its own number — Signal blocks most VOIP ranges and bans bot-ish registrations. Recipient addresses live on celilo routes, not in module config, so adding a person never requires a redeploy. Runs on x86_64 and aarch64. `libsignal-client` ships no linux-aarch64 native, so celilo builds one (`modules/signal/build/`) and installs it as a `libsignal-jni` .deb on ARM hosts; x86_64 uses the JAR's bundled native. **provides:** `notification` (`send`, `receive`). **requires:** no capabilities — a transport that depended on the proxy, registrar or firewall could not tell you those were broken — and a system in the **`secure-mgmt`** zone: it holds a linked Signal account (the operator's own messaging identity and keys), and its job is to observe every tier while depending on none, which is what the control-plane zone is for. See `openspec/changes/add-alerting/`.
- **celilo-website** — public docs site (static Astro) served via Caddy on celilo.computer. **requires:** `public_web`, `dns_registrar`.
- **celilo-web-console** — the read-mostly operator console: the fleet drawn by zone, a live module roster, and the alert and backup state that says what needs attention. **Deployable, not yet servable.** The manifest, the SPA, the console server, the read verbs, the `control_plane_api` capability, the `on_install` that enrols the principal, and a full deployable body (`terraform/`, `ansible/` with a role, config variables, and a `build:` step compiling a per-architecture bun binary plus the SPA) all exist. What does not exist is an OIDC client: the console refuses to serve without an identity provider, and nothing creates one yet, so a deployed unit starts, fails its start limit and sits in `failed` with the reason in its journal. That is deliberate rather than hidden — leaving the service disabled would make an unfinished module look like a clean converge. The e2e suite does not exist either. It holds **no database handle**: every fact it displays arrives over the SSH remote API as a principal whose grants derive from the `COMMANDS` registry's read-only classifier, because an in-process console would hold the whole database including the encrypted secret store and be constrained only by its own code. **⚠️ `secure-mgmt`, and the placement is a security boundary rather than a preference:** celilo reaches into every data-plane zone by trust and no data-plane zone reaches back, so the ONLY client path in is the control-plane VPN. It registers no web route, requests no port forward, takes no `natIp` or ingress address, and gets no DNS record resolvable from a data-plane zone. `apps/celilo/src/console/control-plane-boundary.test.ts` asserts that on every pull request rather than only in e2e, because the way it erodes is a one-line manifest edit adding `private_web` so someone can reach it without bringing up the VPN. It offers no deploy, uninstall, pause, restore or config control: those can raise an interview question, a browser has no responder, and they are ABSENT rather than disabled. **provides:** nothing. **requires:** `idp`, `control_plane_api`. See `openspec/changes/web-ui-console/`.
- **celilo-canary** — a deliberately minimal nginx serving one static page in **`dmz`**, permanently deployed, whose health check IS the assertion that the deploy path still works. Nothing about it is interesting except that it is *always there*: modules already deployed keep running when the pipeline breaks, so without a canary a regression in IPAM allocation, Terraform provisioning, Ansible convergence or capability wiring stays invisible until the next real deploy — which is exactly when nobody wants to discover it. One deploy exercises all four, plus a live cross-module capability call. **Fleet-only, and the absence is the design**: it registers one route through `private_web` and requires nothing that reaches the perimeter, so there is no public record, no ACME certificate and no port forward. Its `health_check` probes nginx over systemd and its `/healthz` endpoint with **`probeHttp`, from the management server** rather than by SSHing in and running `curl` — the production `ubuntu-22.04-standard` LXC ships no curl, so the old form could not tell a missing binary from a dead service, and reaching the canary at its own address additionally catches a service bound only to loopback. **requires:** `private_web`.

## Git forge & CI pipeline

- **forgejo** — self-hosted Forgejo git forge (git-over-SSH, OIDC, public ingress). **provides:** `source_forge`. **requires:** `public_web`, `idp`, `firewall`, `dns_registrar`. See `openspec/specs/forgejo-runner/spec.md`.
- **forgejo-runner** — host-mode (LXC) Forgejo Actions runner for unit/lint jobs; outbound-only, lives in dmz. **requires:** `source_forge`. See `openspec/specs/forgejo-runner/spec.md`.
- **forgejo-builder** — VM-based, Docker-capable Forgejo Actions runner (`requires.system.type: vm`) for hermetic release builds + the cele2e suite. **requires:** `source_forge`.
- **npm-cache-node** — self-hosted npm registry: a pass-through disk cache (proxies upstream) plus a `PUT /<pkg>` publish endpoint that stores locally published `@celilo/*` tarballs as authoritative local-origin (never overwritten by upstream). PUT is gated on a static operator-set publish token (`publish_tokens` secret, SHA-256 model; fail-closed — absent/bad token → 401); reads stay open (it's a mirror). **requires:** `public_web`, `dns_registrar`. See `openspec/changes/private-npm-registry/proposal.md`.

## Applications

- **burner** — fleet-only ISO library and on-demand optical-disc writer. An unprivileged controller LXC owns a deliberately disposable content-addressed image store, SQLite job queue, HTTP Basic-protected dashboard, and the only inbound surface; small host workers poll it over private HTTPS and alone receive `cdrom` access to each configured `/dev/sr0`. Jobs accept only blank media with sufficient capacity, stage by SHA-256, burn through argv-only `xorriso`, read exactly the source image length back for SHA-256 verification, and never automatically retry after the laser may have started. Images are uploaded/deleted in the UI and are not covered by a Celilo backup hook; a future NAS can replace the controller store without changing the digest-addressed worker protocol. **requires:** `private_web`.
- **homebridge** — HomeKit bridge for smart-home devices (VeSync, Leviton, Lutron, TP-Link, Tuya). No capabilities (leaf app).

## E2E fixtures & probes (not production apps)

- **hello-foo** / **hello-bar** — hostless single-page sites (one hand-written `site/index.html`, no build step) published at a configurable `domain` + `path`. They exist so the caddy route-lifecycle e2e can put several independent routes on one caddy and watch them interact — celilo-website is the only other static-site module, and one site can't answer "does removing `/` break `/foo`?". **requires:** `public_web`, `dns_registrar`.
- **hello-trespass** — TEST FIXTURE, never deploy. Its `on_install` hook attempts three things a hook has no business doing (read the master key at its default location, write into a sibling module's install tree, read the SSH private key / `remoteExec` another module's system) and REPORTS which succeeded rather than failing. It is the recurrence gate for `openspec/changes/hook-process-boundary`: each stage flips exactly one row and the test asserts the rows that stage does NOT claim still succeed, so a gate cannot quietly grow to cover work that was never done. **requires:** nothing.
- **hello-private-foo** / **hello-private-bar** — the `private_web` twins of the pair above: hostless single-page sites published through the fleet-only ingress. Two of them for the same reason — one consumer cannot answer "does withdrawing `/foo` leave `/bar` alone?", and per-consumer scoping is exactly what `unregisterRoutes()` promises. Their hook is `private_web ?? public_web` with no branching after it, which is the spec's "same consumer code, either ingress" requirement made executable. **hello-private-foo** additionally calls `getCaCertificate()` and writes the PEM into its own site before publishing — the CA-bundling pattern a real consumer uses, and the only way to observe that the capability returned something usable. **requires:** `private_web`.
- **source-forge-probe** — hostless e2e probe that exercises the `source_forge` capability (issue/revoke runner token) against a deployed Forgejo. **requires:** `source_forge`.
- **test-ansible-output** — e2e fixture that runs 25×1s sleep tasks to assert Ansible output streams in real time. Not a real app.
- **vm-smoke** — throwaway module proving celilo's generic `type: vm` deploy path end-to-end (template clone → cloud-init → Ansible reach). No capabilities.

## Archived / superseded

`modules/__archive__/` holds retired modules — **dns-external** (VPS authoritative DNS + WireGuard, superseded by the `dns_internal`/`dns_registrar` split), **gmail** (email-reading capability), **namecheap-api** (registrar-config via Namecheap API, superseded by the **namecheap** DDNS module). Reference only; not deployed.
