From 167b4c4f14c95f2d83574b9324d665b1515ecf22 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 1 Oct 2026 20:44:18 -0500 Subject: [PATCH] feat(deploy): add the reference deployment Compose project Implement ADR 0044's reference deployment as a base Compose file plus overlays, so an installation that already runs a proxy can use the base alone. - compose.yaml: api, postgres, object-storage, and the operator-only migrate (profile ops, plan by default). No published port. api and migrate run init, read-only, tmpfs /tmp, cap_drop ALL, no-new-privileges. postgres is only on the internal data network. - TLS everywhere from one installation-private CA: PostgreSQL TLS 1.3 only, with pg_hba rejecting every non-TLS connection, and the API on verify-full via EKKLESIA_DATABASE_CA (PEM text). SeaweedFS serves S3 over HTTPS (ADR 0030 requires https in production), trusted by the API through NODE_EXTRA_CA_CERTS. No Core change. - compose.proxy.yaml + Caddyfile.example: Caddy pinned by digest, ACME HTTP-01 (DNS-01 documented), routes /, /api/*, /display/*, /assets/* to api and refuses everything else; the storage hostname preserves Host. The API trusts exactly the proxy's fixed /32 on an edge network whose dynamic range excludes the fixed addresses; Caddy overwrites X-Forwarded-For and strips caller-supplied forwarding headers. - compose.tunnel.yaml: cloudflared pinned by digest, token from its own env file; Caddy honors CF-Connecting-IP only from its fixed address. - Four per-service root-owned env files, examples with no values; scripts/init-tls.sh (idempotent, never overwrites a key) and scripts/gen-secrets.sh (prints only). - CI: pnpm deploy:config in ci:verify for all three combinations, and a deploy-smoke job (scripts/smoke-deploy.sh) proving readiness, the Dashboard, signed storage delivery, the client-address chain, initial owner sign-in/out, verify-full database TLS, and Doctor through the proxy, against disposable services. tests/deploy.test.ts pins the structural guarantees. - ADR 0044 (still Proposed) amended to match: API-to-storage HTTPS, DNS-01 needs a custom Caddy build and tunnel-only needs DNS-01, the tunnel's origin TLS settings live in the Cloudflare dashboard, port 80 serves HTTP-01, and secrets are one env file per service. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 23 + deploy/Caddyfile.example | 87 ++++ deploy/README.md | 399 +++++++++++++----- deploy/compose.proxy.yaml | 80 ++++ deploy/compose.tunnel.yaml | 30 ++ deploy/compose.yaml | 153 +++++++ deploy/env/compose.env.example | 29 ++ deploy/env/ekklesia.env.example | 71 ++++ deploy/env/object-storage.env.example | 4 + deploy/env/postgres.env.example | 8 + deploy/env/tunnel.env.example | 4 + deploy/postgres/pg_hba.conf | 16 + deploy/scripts/gen-secrets.sh | 39 ++ deploy/scripts/init-tls.sh | 148 +++++++ .../0044-self-hosted-reference-deployment.md | 33 +- docs/development/security.md | 16 +- package.json | 4 +- scripts/check-deploy-config.sh | 41 ++ scripts/lib/deploy-smoke-client.mjs | 254 +++++++++++ scripts/smoke-deploy.sh | 348 +++++++++++++++ tests/deploy.test.ts | 231 ++++++++++ 21 files changed, 1902 insertions(+), 116 deletions(-) create mode 100644 deploy/Caddyfile.example create mode 100644 deploy/compose.proxy.yaml create mode 100644 deploy/compose.tunnel.yaml create mode 100644 deploy/compose.yaml create mode 100644 deploy/env/compose.env.example create mode 100644 deploy/env/ekklesia.env.example create mode 100644 deploy/env/object-storage.env.example create mode 100644 deploy/env/postgres.env.example create mode 100644 deploy/env/tunnel.env.example create mode 100644 deploy/postgres/pg_hba.conf create mode 100755 deploy/scripts/gen-secrets.sh create mode 100755 deploy/scripts/init-tls.sh create mode 100755 scripts/check-deploy-config.sh create mode 100644 scripts/lib/deploy-smoke-client.mjs create mode 100755 scripts/smoke-deploy.sh create mode 100644 tests/deploy.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8265992..d881d12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,3 +135,26 @@ jobs: - name: Remove the image from the runner if: always() run: docker image rm --force ekklesia-ci:"$GITHUB_SHA" || true + + deploy-smoke: + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + DOCKER_BUILDKIT: "1" + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # `actions/checkout` defaults to leaving the job token in `.git/config`, where any later + # step — including one a pull request added to this file — could read or reuse it. Nothing + # in this workflow pushes, so the credential is never needed after checkout. + persist-credentials: false + + # The reference deployment end to end (ADR 0044): a platform-only image, base + proxy with + # Caddy's internal CA (test-only), an explicit migration apply against this job's own fresh + # PostgreSQL, then the API, Dashboard, storage delivery, client-address chain, and verify-full + # database TLS through the proxy. Every secret is generated for this run and discarded with + # it; nothing is pushed, published, or logged in to. + - name: Smoke-test the reference deployment + run: bash scripts/smoke-deploy.sh diff --git a/deploy/Caddyfile.example b/deploy/Caddyfile.example new file mode 100644 index 0000000..0bb9357 --- /dev/null +++ b/deploy/Caddyfile.example @@ -0,0 +1,87 @@ +# Ekklesia reference proxy configuration (ADR 0044). Copy to deploy/Caddyfile, which the proxy +# overlay mounts read-only. Hostnames, the ACME contact, and the tunnel connector's address come from +# the proxy service's environment (deploy/.env), so this file normally needs no edits. +{ + # ACME account contact. Certificates use the HTTP-01 challenge on port 80 by default, which needs + # the Ekklesia and storage hostnames to resolve publicly to this host with 80 and 443 reachable. + email {$EKKLESIA_ACME_EMAIL} + + # DNS-01, for an installation that keeps Ekklesia off the public Internet, serves only a LAN + # (split-horizon DNS), or is reachable only through Cloudflare Tunnel. Cloudflare terminates TLS at + # its edge, so HTTP-01 cannot reach this proxy through a tunnel. + # + # DNS-01 needs a DNS provider module that the stock Caddy image does not contain. Build an image + # with it (for example `xcaddy build --with github.com/caddy-dns/cloudflare`), pin that image by + # digest in your own overlay, give it CLOUDFLARE_API_TOKEN through a root-owned env file, then + # enable: + # + # acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN} + + servers { + # A client-address header is honored only from the tunnel connector's fixed address + # (compose.tunnel.yaml). From every other peer, the client address is the TCP peer itself and + # any such header is ignored. Without a tunnel, nothing has this address. + trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32 + client_ip_headers CF-Connecting-IP + trusted_proxies_strict + } + + # Access logs are off unless enabled. If you enable them, keep request headers out: they carry + # cookies and forwarding chains (docs/development/security.md, launch checklist item 7). +} + +# What every request to the API carries upstream: exactly one forwarding header, overwritten with +# the client address Caddy resolved, and none of the headers a caller could use to choose its own. +(ekklesia_forwarding) { + header_up X-Forwarded-For {client_ip} + header_up -CF-Connecting-IP + header_up -X-Real-IP + header_up -Forwarded + header_up -True-Client-IP +} + +{$EKKLESIA_HOSTNAME} { + # Everything the API serves: the Dashboard at /, the API, display pages and their service + # worker, and the Dashboard and display host scripts. Any other path is refused here. + @ekklesia path / /api/* /display/* /assets/* + + # Module realtime is Server-Sent Events: flush every event immediately. + @realtime path /api/v1/module-realtime/* + + handle @realtime { + reverse_proxy api:3000 { + import ekklesia_forwarding + flush_interval -1 + } + } + + handle @ekklesia { + # Request bodies stream through unbuffered and unlimited here; the API enforces the platform's + # ceilings itself (8 KiB for authentication, up to 32 MiB for a streamed module route). + reverse_proxy api:3000 { + import ekklesia_forwarding + } + } + + handle { + respond 404 + } +} + +# Browsers fetch signed object URLs here (S3_DELIVERY_ENDPOINT, ADR 0031). +{$EKKLESIA_STORAGE_HOSTNAME} { + reverse_proxy https://object-storage:8443 { + # An S3 signature covers the Host header: pass the original through unchanged, or every signed + # URL fails verification. + header_up Host {host} + header_up -CF-Connecting-IP + header_up -X-Real-IP + header_up -Forwarded + header_up -True-Client-IP + transport http { + # object-storage presents a certificate from the installation-private CA. + tls_trust_pool file /etc/caddy/ekklesia-ca.crt + tls_server_name object-storage + } + } +} diff --git a/deploy/README.md b/deploy/README.md index 48c21bd..c45b1d0 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,9 +1,273 @@ # Deployment This directory holds Ekklesia's reference self-hosted deployment -([ADR 0044](../docs/architecture/decisions/0044-self-hosted-reference-deployment.md)). Today it -contains the production container image. The Compose project, proxy configuration, and backup job -the ADR describes are separate, later work. +([ADR 0044](../docs/architecture/decisions/0044-self-hosted-reference-deployment.md)): one Linux +host, one Docker Compose project, and the production container image it runs. Nothing here is +specific to one organization. + +| File | What it is | +| --- | --- | +| [`compose.yaml`](compose.yaml) | The base: `api`, `postgres`, `object-storage`, and the operator-only `migrate`. Publishes no port. | +| [`compose.proxy.yaml`](compose.proxy.yaml) | Overlay: Caddy as the TLS proxy, the only service with published ports. | +| [`compose.tunnel.yaml`](compose.tunnel.yaml) | Overlay on top of the proxy: a Cloudflare Tunnel connector. | +| [`Caddyfile.example`](Caddyfile.example) | The proxy's configuration; copy to `Caddyfile`. | +| [`postgres/pg_hba.conf`](postgres/pg_hba.conf) | PostgreSQL client authentication: TLS only, SCRAM passwords. | +| [`env/`](env/) | Commented examples of every env file. No values. | +| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. | +| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. | +| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). | + +## The reference deployment + +### Shape + +```sh +cd deploy +# Base and proxy: the reference. +docker compose -f compose.yaml -f compose.proxy.yaml up -d +# Base, proxy, and Cloudflare Tunnel. +docker compose -f compose.yaml -f compose.proxy.yaml -f compose.tunnel.yaml up -d +# Base only, behind a reverse proxy the installation already runs. +docker compose -f compose.yaml -f compose.myproxy.yaml up -d +``` + +Pass the same `-f` files to every later `up`, `down`, `ps`, and `logs`. `migrate` commands need only +the base: `docker compose run --rm migrate …` from this directory uses `compose.yaml` by default. + +| Network | Kind | Members | +| --- | --- | --- | +| `data` | internal | `postgres`, `api`, `migrate` | +| `storage` | internal | `object-storage`, `api`, `migrate` | +| `egress` | bridge | `api`, `migrate` (outbound only, for example an SMTP relay) | +| `edge` | bridge, fixed subnet (proxy overlay) | `proxy` (fixed address), `api`, `object-storage`, `tunnel` (fixed address) | + +PostgreSQL is only ever on `data`, so no proxy and no tunnel can reach it. `internal` networks have +no route off the host. Object storage joins `edge` only when the proxy overlay attaches it. + +TLS is everywhere, without any exception in Core: + +- **Browsers to the proxy:** publicly trusted ACME certificates. +- **API to PostgreSQL:** `verify-full` against the installation-private CA, passed as PEM text in + `EKKLESIA_DATABASE_CA`. PostgreSQL accepts only TLS 1.3 network connections. +- **API (and proxy) to object storage:** HTTPS on port 8443 with a certificate from the same CA. + Production storage must be https (ADR 0030). Core's S3 client uses Node's default trust store, so + Compose adds the CA with `NODE_EXTRA_CA_CERTS`. + +### Where configuration and secrets live + +| File (on the host) | Holds | Read by | +| --- | --- | --- | +| `deploy/.env` (from [`env/compose.env.example`](env/compose.env.example)) | Non-secret settings: the image, hostnames, paths, the edge subnet | Compose itself | +| `/etc/ekklesia/ekklesia.env` | Everything the API needs, including `DATABASE_URL` and the S3 keys | `api`, `migrate` | +| `/etc/ekklesia/postgres.env` | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | `postgres` | +| `/etc/ekklesia/object-storage.env` | The S3 key pair | `object-storage` | +| `/etc/ekklesia/tunnel.env` | `TUNNEL_TOKEN` | `tunnel` | +| `/etc/ekklesia/tls/` | The CA and server certificates and keys | mounted file by file | + +The secret files are owned by root with mode 600, one per service, so no container receives another +service's credentials. None is committed, and `deploy/.env` is ignored by Git. `EKKLESIA_ENV_DIR` +and `EKKLESIA_TLS_DIR` in `deploy/.env` move the directories. + +### First boot + +1. **Get the image.** Build it ([Building](#building)) or pull it, and set `EKKLESIA_IMAGE` in + `deploy/.env`. +2. **Create the TLS material**, as root: + + ```sh + sudo sh deploy/scripts/init-tls.sh /etc/ekklesia/tls + ``` + + It creates the CA once and a certificate for each of `postgres` and `object-storage`, gives each + key to its server's uid (PostgreSQL 70, SeaweedFS 1000) with mode 600, and prints the exact + `EKKLESIA_DATABASE_CA="…"` line. It never overwrites an existing key or certificate and never + prints a private key. `ca.key` is needed only to issue certificates: keep a copy offline. +3. **Generate secrets** with `sh deploy/scripts/gen-secrets.sh`, and paste each line into the file + named above it. +4. **Write the env files** from [`env/`](env/), then install each one owned by root with mode 600: + + ```sh + sudo install -d -m 755 /etc/ekklesia + sudo install -o root -g root -m 600 ekklesia.env /etc/ekklesia/ekklesia.env + ``` + + `EKKLESIA_IDENTITY_BASE_URL` and `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` are `https://` and your + Ekklesia hostname. `S3_DELIVERY_ENDPOINT` is `https://` and your storage hostname. +5. **Configure the proxy:** copy `Caddyfile.example` to `Caddyfile`, and set `EKKLESIA_HOSTNAME`, + `EKKLESIA_STORAGE_HOSTNAME`, and `EKKLESIA_ACME_EMAIL` in `deploy/.env`. Both hostnames must + resolve to this host with ports 80 and 443 reachable for HTTP-01, or see [DNS-01](#dns-01). +6. **Start the data services, then migrate explicitly:** + + ```sh + docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait postgres object-storage + docker compose run --rm migrate # plan: read-only + docker compose run --rm migrate node core/dist/cli/apply-migrations.js + ``` + + Nothing ever applies migrations automatically: the API never does, and `migrate` runs only when + you run it. +7. **Start everything:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`. +8. **Check it:** `curl https:///api/v1/health/ready` answers `{"status":"ready"}`, and + `docker compose run --rm migrate node core/dist/cli/doctor.js` reports `READY` (with warnings + for anything optional you left unconfigured, such as email). + +### The initial owner + +```sh +docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email owner@example.org +``` + +It prompts for the password with hidden input and never accepts it on the command line. The owner +is created already verified, so they can sign in before any email relay exists +([ADR 0035](../docs/architecture/decisions/0035-initial-platform-owner-bootstrap.md)). To supply the +password from a secret manager instead, pipe it on standard input and use `-T` instead of `-it`. + +### Upgrading + +1. **Pull or build the new image**, and change `EKKLESIA_IMAGE` in `deploy/.env`. Keep the old tag. +2. **Back up** ([Backups](#backups)). +3. **Plan, review, then apply:** `docker compose run --rm migrate` with the new image, then + `docker compose run --rm migrate node core/dist/cli/apply-migrations.js`. +4. **Restart on the new image:** `docker compose -f compose.yaml -f compose.proxy.yaml up -d --wait`. + +### Rolling back + +Set `EKKLESIA_IMAGE` back to the previous tag and run `up -d --wait` again. That is enough only when +the upgrade applied no migrations (the plan in step 3 showed none pending). Migrations are +forward-only. If any were applied, restore the backup taken in step 2 before starting the previous +image, because it was not built for the newer schema. + +### Backups + +Slice 6 adds the scheduled backup job and the restore procedure (ADR 0044, Backups). Until then, +take a `pg_dump` and a copy of every bucket before each upgrade by hand. + +### Behind an existing reverse proxy + +Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of +your own, for example `compose.myproxy.yaml`: + +```yaml +services: + api: + networks: + proxy: {} + environment: + # Exactly one trusted peer: your proxy, at an address that cannot change. + EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy + EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for + EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: 10.0.100.254/32 + object-storage: + networks: + proxy: {} + +networks: + proxy: + external: true + name: your-proxy-network +``` + +Then make your proxy do what `Caddyfile.example` does: + +- forward `/`, `/api/*`, `/display/*`, and `/assets/*` on the Ekklesia hostname to `api:3000`; +- **overwrite** `X-Forwarded-For` with the one client address it resolved, and strip + `CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`; +- stream responses on `/api/v1/module-realtime/*` without buffering (Server-Sent Events); +- set no request-body limit below 32 MiB; the API enforces its own ceilings; +- forward the storage hostname to `https://object-storage:8443`, verifying it against + `/etc/ekklesia/tls/ca.crt` with server name `object-storage`, and **pass the original `Host` + through unchanged**, or every signed URL fails (ADR 0031). + +The proxy's address must be fixed and must never be handed to anything else. With Docker, give its +network an explicit subnet, limit `ip_range` to part of it, and fix the proxy's address outside that +range, as `compose.proxy.yaml` does. + +### Fixed addresses on `edge` + +`compose.proxy.yaml` gives `edge` the subnet `10.249.250.0/24` and lets Docker assign dynamic +addresses only from its lower half (`10.249.250.0/25`). The proxy (`.254`) and the tunnel connector +(`.253`) take fixed addresses from the top of the upper half, which Docker never assigns to anything +else. That is what makes "only the proxy" and "only the connector" expressible as `/32` trust +entries. If the subnet collides with one on your host, change `EKKLESIA_EDGE_SUBNET`, +`EKKLESIA_EDGE_DYNAMIC_RANGE`, `EKKLESIA_PROXY_ADDRESS`, and `EKKLESIA_TUNNEL_ADDRESS` together, +keeping the fixed addresses at the top and outside the dynamic range. + +### Cloudflare Tunnel + +`compose.tunnel.yaml` runs `cloudflared` with a token, so the tunnel is managed in the Cloudflare +dashboard. No Cloudflare API is called from here. + +1. In Cloudflare Zero Trust, under **Networks → Tunnels**, create a Cloudflared tunnel. Copy its + token into `/etc/ekklesia/tunnel.env` as `TUNNEL_TOKEN`. +2. Add a **public hostname** (shown as a *published application route* in newer dashboards) for + the Ekklesia hostname. Service type **HTTPS**, URL `proxy:443`. +3. Under that hostname's **TLS** settings, set **Origin Server Name** to the same Ekklesia hostname, + and leave **No TLS Verify** off. The connector then verifies the proxy's certificate for that name. +4. Add a second public hostname for the storage hostname, the same way, with its own origin server + name. +5. Start with all three files. The proxy honors `CF-Connecting-IP` only from the connector's fixed + address. From any other peer it is stripped. + +The proxy's certificate must still be publicly trusted, and an HTTP-01 challenge cannot reach the +proxy through a tunnel, because Cloudflare terminates TLS at its edge. A tunnel-only installation +therefore uses [DNS-01](#dns-01). An installation that also serves local displays should give the +same hostnames a LAN path to the proxy (split-horizon DNS), so displays do not depend on the +Internet link. + +### DNS-01 + +For an installation off the public Internet, LAN-only, or tunnel-only. DNS-01 needs a DNS provider +module, which the stock Caddy image does not contain: + +1. Build a Caddy image with the module (for example + `xcaddy build --with github.com/caddy-dns/cloudflare`) and pin it by digest. +2. Override the proxy's `image:` in an overlay of your own. Give it `CLOUDFLARE_API_TOKEN` (a token + scoped to DNS edits for the zone) through a root-owned env file. +3. Enable the commented `acme_dns` line in your `Caddyfile`. + +### Launch checklist + +The ten items in [Security § launch checklist](../docs/development/security.md) mapped to this +deployment: + +| # | Item | Where it is met | +| --- | --- | --- | +| 1 | The proxy forwards the Dashboard and `/api` on the intended origin | `Caddyfile.example` routes `/`, `/api/*`, `/display/*`, `/assets/*` on one hostname to `api`; the API serves the Dashboard itself. | +| 2 | The origin port is unreachable from the Internet | The base publishes no port; only `proxy` publishes 80 and 443. | +| 3 | The origin port is unreachable from untrusted networks | `api` is reachable only on Docker networks; `EKKLESIA_PUBLISH_ADDRESS` limits the proxy to one interface. Firewall the host itself. | +| 4 | The proxy overwrites the trusted client-IP header | `header_up X-Forwarded-For {client_ip}` in `Caddyfile.example`. | +| 5 | Rotating forwarding headers cannot rotate the rate-limit bucket | The same, plus stripping `CF-Connecting-IP`, `X-Real-IP`, `Forwarded`, and `True-Client-IP`; the API overwrites its internal header. Proven by the smoke test. | +| 6 | The proxy peer is the only trusted proxy | `compose.proxy.yaml` sets `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` to the proxy's fixed `/32`. | +| 7 | Proxy logs record no cookies, credentials, bodies, or forwarding chains | Caddy access logs are off in `Caddyfile.example`. If you enable them, exclude request headers. | +| 8 | PostgreSQL only from the API, over verified TLS | `postgres` is only on internal `data`; `pg_hba.conf` rejects non-TLS; `verify-full` with `EKKLESIA_DATABASE_CA`. Proven by the smoke test. | +| 9 | The deployed login and logout flow keeps its contract | Unchanged by the proxy; the smoke test signs in and out through it. | +| 10 | A supported way to create the first production identity | `create-initial-owner.js` through `migrate` ([The initial owner](#the-initial-owner)). Exercised by the smoke test. | + +### Testing this deployment + +- `pnpm deploy:config` validates the base, base + proxy, and base + proxy + tunnel with the example + env files. CI runs it in `pnpm ci:verify`. +- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the whole deployment end to end, against a + platform-only image and a disposable PostgreSQL and SeaweedFS that exist only for that run. It + follows the first boot above and checks the following through the proxy: + - readiness and the Dashboard; + - a signed object URL fetched through the storage hostname; + - the client-address chain, both from an ordinary peer and from the tunnel connector's address; + - initial-owner sign-in and sign-out; + - that the API's database connection is TLS 1.3, and that it is refused by a foreign CA and by a + wrong host name; + - Doctor. + + CI runs it on every change. +- **Test-only:** the smoke test runs Caddy with its internal CA (`local_certs`), because a test run + has no public hostname. Never use it in an installation; `Caddyfile.example` does not contain it. + +The smoke test uses no module, so it does not exercise realtime streams or large uploads. Through +this same proxy configuration, a scratch module confirmed both: + +- a Server-Sent Event crossed the proxy in milliseconds; +- a 32 MiB streamed upload passed with and without `Content-Length`; +- one byte more was refused by the API's own `413 REQUEST_BODY_TOO_LARGE`. ## The production image @@ -79,8 +343,7 @@ example, process one image). ### Configuration Configuration comes **only from the container environment**, never from a file inside the image. -Supply it from a root-owned environment file on the host, outside any repository (ADR 0044 -Secrets). The image sets exactly three variables itself: +The image sets exactly three variables itself: | Variable | Image value | Why | | --- | --- | --- | @@ -89,48 +352,26 @@ Secrets). The image sets exactly three variables itself: | `EKKLESIA_API_PORT` | `3000` | The platform default, made explicit; `EXPOSE 3000`. | Everything else is the ordinary host configuration in -[API § Required configuration](../docs/development/api.md#required-configuration). In production -that means `DATABASE_URL` with certificate-verified TLS: `EKKLESIA_DATABASE_CA` is the CA's PEM text -itself, passed in the environment, so no certificate file is mounted. +[API § Required configuration](../docs/development/api.md#required-configuration), supplied by the +reference deployment's `ekklesia.env`. `EKKLESIA_DATABASE_CA` is the CA's PEM text itself, on one +double-quoted line with `\n` escapes. Compose expands those escapes; plain `docker run --env-file` +does not, which is why every command here goes through Compose. Never publish the API's port to the host or the Internet. Only the proxy publishes ports. -### The `api` service +### Process, signals, and the read-only posture -The image's default command is `node apps/api/dist/server.js`: `pnpm api:start` without its build -step and without `--env-file`. It never applies migrations. +`compose.yaml` runs `api` and `migrate` with `init: true`, `read_only: true`, `tmpfs: /tmp`, +`cap_drop: [ALL]`, and `no-new-privileges`. That is the supported production posture: -```yaml -services: - api: - image: ekklesia:0.1.0 - init: true - read_only: true - tmpfs: - - /tmp - cap_drop: - - ALL - security_opt: - - no-new-privileges:true - env_file: - - /etc/ekklesia/ekklesia.env - restart: unless-stopped -``` - -**Use `init: true`.** It runs a minimal init as PID 1 that forwards signals and reaps children, so -`docker stop` reaches the API's own SIGTERM handler promptly. The API then drains in-flight -requests, stops modules in reverse order, and closes its pool. - -**The read-only flags are the supported production posture.** The image was verified under exactly -`--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges`, with `--init`: - -- the API reached `healthy` and served `/api/v1/health/ready`, `GET /`, and the Dashboard script; -- `plan-migrations.js`, `apply-migrations.js`, and `doctor.js` ran under the same flags; -- nothing wrote to `/tmp` in that run. It stays mounted as headroom for module code that needs - scratch space. - -The installation, `/home/node` included, is not writable, so anything that needs a writable path -other than `/tmp` should be reported as a defect rather than worked around. +- **`init: true`** runs a minimal init as PID 1 that forwards signals and reaps children, so + `docker stop` reaches the API's own SIGTERM handler promptly. The API then drains in-flight + requests, stops modules in reverse order, and closes its pool. +- **Read-only:** the image was verified under exactly these flags. The API reached `healthy`, and + the migration and Doctor commands ran. Nothing wrote to `/tmp`, which stays mounted as headroom + for module code that needs scratch space. The installation, `/home/node` included, is not + writable, so anything that needs a writable path other than `/tmp` is a defect to report, not to + work around. ### Health @@ -138,68 +379,32 @@ The image's `HEALTHCHECK` calls `GET /api/v1/health/ready` on `127.0.0.1` with N `fetch`, every 30 seconds after a 30-second start period, with three retries. Readiness means the database answered within 2 seconds and the instance is not shutting down; see [API § Health](../docs/development/api.md#health). Because it probes `127.0.0.1`, keep -`EKKLESIA_API_HOST` at `0.0.0.0` (or any address that includes loopback) inside the container. +`EKKLESIA_API_HOST` at `0.0.0.0` (or any address that includes loopback) inside the container. The +API trusts only the proxy's address for client-address headers, so the loopback probe is an +ordinary direct peer. -A Compose health check or external monitor can use the same endpoint through the proxy. Use -`/api/v1/health/live` to ask only whether the process is serving. +An external monitor can use the same endpoint through the proxy. Use `/api/v1/health/live` to ask +only whether the process is serving. ### Operator commands -The same image runs every CLI, directly with `node core/dist/cli/.js`. The `pnpm` scripts -of the same names rebuild first and read `.env`, so they are for a development checkout, not for the -image. +The same image runs every CLI, directly with `node core/dist/cli/.js`, through the +`migrate` service. The `pnpm` scripts of the same names rebuild first and read `.env`, so they are +for a development checkout, not for the image. -**Nothing applies migrations automatically.** Neither the API nor the image's default command ever -runs `apply-migrations.js`; only an operator does, by running the command below. +`migrate` has `profiles: ["ops"]`, so `up` never starts it. It never restarts, has no health check, +and its default command is the read-only plan. **Nothing applies migrations automatically.** -A `migrate` service makes those commands one line each. It shares the API's image and environment, -never restarts, and does not run the API's health check: +| Task | Command (from `deploy/`) | +| --- | --- | +| Plan migrations (read-only) | `docker compose run --rm migrate` | +| Apply migrations | `docker compose run --rm migrate node core/dist/cli/apply-migrations.js` | +| Doctor (read-only report) | `docker compose run --rm migrate node core/dist/cli/doctor.js` | +| Create the initial owner (interactive) | `docker compose run --rm -it migrate node core/dist/cli/create-initial-owner.js --email owner@example.org` | +| Enable or disable a module | `docker compose run --rm migrate node core/dist/cli/set-module-enabled.js --enable --module ` (or `--disable`) | -```yaml -services: - migrate: - image: ekklesia:0.1.0 - profiles: ["operator"] - restart: "no" - read_only: true - tmpfs: - - /tmp - cap_drop: - - ALL - security_opt: - - no-new-privileges:true - env_file: - - /etc/ekklesia/ekklesia.env - healthcheck: - disable: true - command: ["node", "core/dist/cli/plan-migrations.js"] -``` - -| Task | Compose | Plain `docker run` | -| --- | --- | --- | -| Plan migrations (read-only) | `docker compose run --rm migrate` | `docker run --rm --env-file /etc/ekklesia/ekklesia.env ekklesia:0.1.0 node core/dist/cli/plan-migrations.js` | -| Apply migrations | `docker compose run --rm migrate node core/dist/cli/apply-migrations.js` | `docker run --rm --env-file /etc/ekklesia/ekklesia.env ekklesia:0.1.0 node core/dist/cli/apply-migrations.js` | -| Doctor (read-only report) | `docker compose run --rm migrate node core/dist/cli/doctor.js` | `docker run --rm --env-file /etc/ekklesia/ekklesia.env ekklesia:0.1.0 node core/dist/cli/doctor.js` | -| Create the initial owner (interactive) | `docker compose run --rm migrate node core/dist/cli/create-initial-owner.js --email owner@example.org` | `docker run --rm -it --env-file /etc/ekklesia/ekklesia.env ekklesia:0.1.0 node core/dist/cli/create-initial-owner.js --email owner@example.org` | -| Enable or disable a module | `docker compose run --rm migrate node core/dist/cli/set-module-enabled.js --enable --module ` | `docker run --rm --env-file /etc/ekklesia/ekklesia.env ekklesia:0.1.0 node core/dist/cli/set-module-enabled.js --disable --module ` | - -Notes: - -- `docker compose run` allocates a terminal by default. With plain `docker run`, the initial-owner - command needs `-it`: it prompts for the password with hidden input and never accepts it on the - command line ([Identity](../docs/development/identity.md)). It also accepts the password on - standard input with `-i` and no `-t`. -- Run `--rm` commands on the same Docker network as `postgres`, so `DATABASE_URL` resolves; with - Compose that is automatic. -- Add the same read-only flags to plain `docker run` commands if you want them; every command above - was verified under them except the interactive and module-enablement ones, which were not run. - -### Upgrading - -The ADR's procedure, with this image: build the new image → back up → run -`docker compose run --rm migrate` (plan), review it, then -`docker compose run --rm migrate node core/dist/cli/apply-migrations.js` → restart `api` on the -new image. Migrations always run before the new API starts, and only when an operator runs them. +The plan, apply, Doctor, and initial-owner commands are exercised by the smoke test; module +enablement is not. ### Size diff --git a/deploy/compose.proxy.yaml b/deploy/compose.proxy.yaml new file mode 100644 index 0000000..f1585de --- /dev/null +++ b/deploy/compose.proxy.yaml @@ -0,0 +1,80 @@ +# The reference TLS proxy overlay (ADR 0044's `proxy` service). Use it on top of the base: +# +# docker compose -f compose.yaml -f compose.proxy.yaml up -d +# +# Caddy terminates TLS with publicly trusted ACME certificates and publishes the deployment's only +# ports. It reaches `api` and `object-storage` on the `edge` network, which this overlay creates and +# attaches them to; `postgres` is never on it. +# +# Client addresses. Caddy is the only peer the API trusts to say who the client is: this overlay +# puts the API in trusted-proxy mode for exactly the proxy's fixed address, and the Caddyfile +# overwrites X-Forwarded-For with the one address Caddy resolved. Because the address must be +# fixed for "only the proxy" to be expressible, `edge` has an explicit subnet whose dynamic range is +# its lower half; the proxy (and the tunnel connector, if used) take addresses from the upper half, +# which Docker never hands to another container. + +services: + proxy: + image: caddy:2.11.4-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b + restart: unless-stopped + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + security_opt: + - no-new-privileges:true + ports: + # 80 answers ACME HTTP-01 challenges and redirects everything else to HTTPS. Set + # EKKLESIA_PUBLISH_ADDRESS to bind one host interface instead of all of them. + - "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTP_PORT:-80}:80" + - "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTPS_PORT:-443}:443" + - "${EKKLESIA_PUBLISH_ADDRESS:-0.0.0.0}:${EKKLESIA_HTTPS_PORT:-443}:443/udp" + environment: + EKKLESIA_HOSTNAME: ${EKKLESIA_HOSTNAME:?EKKLESIA_HOSTNAME is required, for example ekklesia.example.org} + EKKLESIA_STORAGE_HOSTNAME: ${EKKLESIA_STORAGE_HOSTNAME:?EKKLESIA_STORAGE_HOSTNAME is required, for example storage.ekklesia.example.org} + EKKLESIA_ACME_EMAIL: ${EKKLESIA_ACME_EMAIL:?EKKLESIA_ACME_EMAIL is required for ACME account notices} + EKKLESIA_TUNNEL_ADDRESS: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253} + volumes: + - ${EKKLESIA_CADDYFILE:-./Caddyfile}:/etc/caddy/Caddyfile:ro + # The installation-private CA, so Caddy can verify object-storage's certificate. + - ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/etc/caddy/ekklesia-ca.crt:ro + - proxy-data:/data + - proxy-config:/config + networks: + edge: + ipv4_address: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254} + depends_on: + api: + condition: service_healthy + object-storage: + condition: service_healthy + + api: + networks: + edge: {} + environment: + # Exactly one trusted peer: this proxy. Set here from the same variable that fixes the proxy's + # address, so the two cannot drift apart. These override any value in ekklesia.env. + EKKLESIA_CLIENT_ADDRESS_MODE: trusted-proxy + EKKLESIA_CLIENT_ADDRESS_HEADER: x-forwarded-for + EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: ${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}/32 + + object-storage: + networks: + edge: {} + +networks: + edge: + ipam: + config: + - subnet: ${EKKLESIA_EDGE_SUBNET:-10.249.250.0/24} + # Docker assigns dynamic addresses only from this range. Keep the fixed proxy and tunnel + # addresses outside it. + ip_range: ${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25} + +volumes: + proxy-data: + proxy-config: diff --git a/deploy/compose.tunnel.yaml b/deploy/compose.tunnel.yaml new file mode 100644 index 0000000..8c7fb85 --- /dev/null +++ b/deploy/compose.tunnel.yaml @@ -0,0 +1,30 @@ +# The Cloudflare Tunnel overlay (ADR 0044, optional ingress). Use it on top of base and proxy: +# +# docker compose -f compose.yaml -f compose.proxy.yaml -f compose.tunnel.yaml up -d +# +# `cloudflared` makes an outbound connection to Cloudflare and forwards visitors to the proxy over +# HTTPS. The tunnel is token-managed, so its public hostnames and origin settings live in the +# Cloudflare dashboard: each hostname's service is `https://proxy:443` with the matching origin +# server name and TLS verification left on (deploy/README.md, "Cloudflare Tunnel"). +# +# The connector has a fixed address on `edge`. The Caddyfile trusts a client-address header +# (CF-Connecting-IP) only from that address, so no other peer can choose its own client address. + +services: + tunnel: + image: cloudflare/cloudflared:2026.9.3@sha256:072c067d25ccbe61d46e18f0d0723255f2bb5304f7317caa95b27031520ff92c + restart: unless-stopped + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + # The image's entrypoint is `cloudflared --no-autoupdate`; the token comes from TUNNEL_TOKEN. + command: ["tunnel", "run"] + env_file: + - ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/tunnel.env + networks: + edge: + ipv4_address: ${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253} + depends_on: + - proxy diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..24f1451 --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,153 @@ +# Ekklesia's reference self-hosted deployment: the base project (ADR 0044). +# +# `api`, `postgres`, and `object-storage`, plus the operator-only `migrate` service. Nothing here +# publishes a port. Add `compose.proxy.yaml` for the reference TLS proxy, and `compose.tunnel.yaml` +# on top of that for a Cloudflare Tunnel connector; or attach `api` and `object-storage` to an +# existing proxy's network yourself (deploy/README.md). +# +# Non-secret settings come from `deploy/.env` (copy `env/compose.env.example`). Secrets live in +# root-owned, mode-600 files under EKKLESIA_ENV_DIR, one per service, so no container receives +# another service's credentials. TLS material comes from `scripts/init-tls.sh`. + +name: ekklesia + +x-ekklesia-hardening: &ekklesia-hardening + init: true + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + +x-ekklesia-app: &ekklesia-app + <<: *ekklesia-hardening + image: ${EKKLESIA_IMAGE:?EKKLESIA_IMAGE is required, for example registry.example.org/ekklesia:0.1.0} + env_file: + - ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/ekklesia.env + environment: + # Core's object-storage client trusts Node's default CA set. Adding the installation-private CA + # to it, from the file below, is what lets the API verify `object-storage`'s certificate + # without any storage TLS exception. The database CA travels separately, as PEM text in + # EKKLESIA_DATABASE_CA, and is the only CA the database connection trusts. + NODE_EXTRA_CA_CERTS: /run/ekklesia/tls/ca.crt + volumes: + - ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/ca.crt:/run/ekklesia/tls/ca.crt:ro + networks: + data: {} + storage: {} + egress: {} + +services: + api: + <<: *ekklesia-app + restart: unless-stopped + # The image's own HEALTHCHECK (GET /api/v1/health/ready) applies unchanged. + depends_on: + postgres: + condition: service_healthy + object-storage: + condition: service_healthy + + # Operator commands only. `profiles` keeps `docker compose up` from ever starting it; it runs only + # through `docker compose run --rm migrate …`. The default command is the read-only plan. + migrate: + <<: *ekklesia-app + profiles: + - ops + restart: "no" + healthcheck: + disable: true + command: ["node", "core/dist/cli/plan-migrations.js"] + depends_on: + postgres: + condition: service_healthy + object-storage: + condition: service_healthy + + postgres: + image: postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15 + restart: unless-stopped + security_opt: + - no-new-privileges:true + env_file: + - ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/postgres.env + environment: + POSTGRES_INITDB_ARGS: --data-checksums --auth-host=scram-sha-256 --auth-local=trust + TZ: UTC + # TLS with the installation-private CA's server certificate for the name `postgres`, TLS 1.3 + # only, and a host-based authentication file that rejects every non-TLS network connection. + # The API connects with verify-full; there is no plaintext path to fall back to. + command: + - postgres + - -c + - ssl=on + - -c + - ssl_cert_file=/run/ekklesia/tls/postgres.crt + - -c + - ssl_key_file=/run/ekklesia/tls/postgres.key + - -c + - ssl_min_protocol_version=TLSv1.3 + - -c + - hba_file=/etc/ekklesia/pg_hba.conf + volumes: + - postgres-data:/var/lib/postgresql + - ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/postgres.crt:/run/ekklesia/tls/postgres.crt:ro + - ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/postgres.key:/run/ekklesia/tls/postgres.key:ro + - ./postgres/pg_hba.conf:/etc/ekklesia/pg_hba.conf:ro + # Only `data`: never a proxy-facing network, and no route out of the host. + networks: + data: {} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] + interval: 5s + timeout: 5s + retries: 12 + start_period: 20s + + object-storage: + image: chrislusf/seaweedfs:4.28@sha256:d071979843142451d41566bc09f6adaa36902c112683d900a417247c82e9c3c1 + restart: unless-stopped + security_opt: + - no-new-privileges:true + env_file: + - ${EKKLESIA_ENV_DIR:-/etc/ekklesia}/object-storage.env + environment: + TZ: UTC + # The S3 API over HTTPS on 8443, with the installation-private CA's certificate for the name + # `object-storage`: production storage must be https (ADR 0030), for the API and for the proxy. + command: + - mini + - -dir=/data + - -s3.port.https=8443 + - -s3.cert.file=/run/ekklesia/tls/object-storage.crt + - -s3.key.file=/run/ekklesia/tls/object-storage.key + volumes: + - object-storage-data:/data + - ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/object-storage.crt:/run/ekklesia/tls/object-storage.crt:ro + - ${EKKLESIA_TLS_DIR:-/etc/ekklesia/tls}/object-storage.key:/run/ekklesia/tls/object-storage.key:ro + networks: + storage: {} + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9333/cluster/status"] + interval: 5s + timeout: 5s + retries: 18 + start_period: 15s + +networks: + # The database network: postgres, api, and migrate. Internal, so nothing on it reaches beyond the + # host and no proxy is ever attached to it. + data: + internal: true + # The storage network: object-storage, api, and migrate. Internal for the same reason; browsers + # reach storage only through a proxy that joins it on `edge`. + storage: + internal: true + # Outbound access for api and migrate (an SMTP relay, for example). Nothing listens on it. + egress: {} + +volumes: + postgres-data: + object-storage-data: diff --git a/deploy/env/compose.env.example b/deploy/env/compose.env.example new file mode 100644 index 0000000..613a3c2 --- /dev/null +++ b/deploy/env/compose.env.example @@ -0,0 +1,29 @@ +# Non-secret settings for the reference deployment's Compose project. Copy to deploy/.env, which +# Compose reads automatically from this directory. Nothing here is a secret; secrets live in the +# per-service files under EKKLESIA_ENV_DIR. + +# The image to run, by registry, name, and tag (or digest). Required. +EKKLESIA_IMAGE= + +# Where the root-owned, mode-600 env files and the TLS directory live on the host. +EKKLESIA_ENV_DIR=/etc/ekklesia +EKKLESIA_TLS_DIR=/etc/ekklesia/tls + +# --- compose.proxy.yaml --------------------------------------------------------------------------- +# The Ekklesia hostname (Dashboard, API, displays) and the storage hostname (signed object URLs). +EKKLESIA_HOSTNAME= +EKKLESIA_STORAGE_HOSTNAME= +# Contact address for the ACME account (certificate expiry notices). +EKKLESIA_ACME_EMAIL= +# EKKLESIA_CADDYFILE=./Caddyfile +# EKKLESIA_PUBLISH_ADDRESS=0.0.0.0 +# EKKLESIA_HTTP_PORT=80 +# EKKLESIA_HTTPS_PORT=443 + +# The edge network. Change the subnet only if it collides with one already on the host. Docker +# assigns dynamic addresses only from EKKLESIA_EDGE_DYNAMIC_RANGE (the lower half); the proxy and +# the tunnel connector take fixed addresses from the upper half, so nothing else can ever hold them. +# EKKLESIA_EDGE_SUBNET=10.249.250.0/24 +# EKKLESIA_EDGE_DYNAMIC_RANGE=10.249.250.0/25 +# EKKLESIA_PROXY_ADDRESS=10.249.250.254 +# EKKLESIA_TUNNEL_ADDRESS=10.249.250.253 diff --git a/deploy/env/ekklesia.env.example b/deploy/env/ekklesia.env.example new file mode 100644 index 0000000..34db9b8 --- /dev/null +++ b/deploy/env/ekklesia.env.example @@ -0,0 +1,71 @@ +# Ekklesia API and operator commands (`api` and `migrate`). Reference deployment, ADR 0044. +# +# Install as EKKLESIA_ENV_DIR/ekklesia.env (default /etc/ekklesia/ekklesia.env), owned by root with +# mode 600: +# install -o root -g root -m 600 env/ekklesia.env.example /etc/ekklesia/ekklesia.env +# Never commit the filled-in file. Generate secrets with scripts/gen-secrets.sh, and the database CA +# line with scripts/init-tls.sh. +# +# The image already sets NODE_ENV=production, EKKLESIA_API_HOST=0.0.0.0, and EKKLESIA_API_PORT=3000. +# Compose sets NODE_EXTRA_CA_CERTS. The proxy overlay sets the three EKKLESIA_CLIENT_ADDRESS_* +# variables. None of those belong here. +# +# Values that are part of the reference topology are filled in; everything installation-specific is +# blank. Full reference: docs/development/api.md#required-configuration. + +# --- Database (ADR 0018) ------------------------------------------------------------------------ +# The host must be exactly `postgres`: the server certificate is issued for that name and +# verify-full checks it. User, password, and database must match postgres.env. Percent-encode any +# reserved character in the password. Never add sslmode or another TLS parameter to this URL. +DATABASE_URL=postgresql://:@postgres:5432/ +EKKLESIA_DATABASE_TLS_MODE=verify-full +# The installation-private CA as PEM text (not a path), on one double-quoted line with \n escapes. +# scripts/init-tls.sh prints this exact line. It is the only CA the database connection trusts. +EKKLESIA_DATABASE_CA= + +# --- Identity (ADR 0011, ADR 0018, ADR 0034) ---------------------------------------------------- +# The public https origin of the Ekklesia hostname, with no path. Production rejects http and +# loopback origins. +EKKLESIA_IDENTITY_BASE_URL= +# Comma-separated exact origins allowed to make credentialed requests. Normally just the base URL. +EKKLESIA_IDENTITY_TRUSTED_ORIGINS= +# At least 32 random characters. Rotating it signs every user out. +EKKLESIA_IDENTITY_SECRET= +# Leave unset: the Dashboard is served from the base URL (ADR 0044). +# EKKLESIA_DASHBOARD_ORIGIN= +# Production refuses public sign-up. Leave unset. +# EKKLESIA_PUBLIC_SIGNUP_ENABLED= + +# --- Client address (ADR 0017) ------------------------------------------------------------------ +# Set by compose.proxy.yaml. Only a base-only deployment behind its own proxy sets these here, with +# that proxy's fixed address as the only trusted entry (deploy/README.md): +# EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy +# EKKLESIA_CLIENT_ADDRESS_HEADER=x-forwarded-for +# EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES=/32 + +# --- Object storage (ADR 0030, ADR 0031) -------------------------------------------------------- +# The API reaches SeaweedFS over HTTPS on the private network; production storage must be https. +S3_ENDPOINT=https://object-storage:8443 +# The https origin browsers fetch signed object URLs from: the storage hostname on the proxy. +S3_DELIVERY_ENDPOINT= +# Must match object-storage.env. +S3_ACCESS_KEY= +S3_SECRET_KEY= +S3_REGION=us-east-1 +S3_FORCE_PATH_STYLE=true +S3_BUCKET_PREFIX=ekklesia + +# --- Outbound email (ADR 0037), optional -------------------------------------------------------- +# Without a relay, email verification and password recovery cannot deliver. See +# docs/development/email.md. +# EKKLESIA_SMTP_HOST= +# EKKLESIA_SMTP_PORT=587 +# EKKLESIA_SMTP_SECURITY=starttls +# EKKLESIA_SMTP_USERNAME= +# EKKLESIA_SMTP_PASSWORD= +# EKKLESIA_SMTP_FROM_ADDRESS= +# EKKLESIA_SMTP_FROM_NAME=Ekklesia +# EKKLESIA_SMTP_HELO_NAME= + +# --- Module secrets (ADR 0029), as installed modules declare them -------------------------------- +# EKKLESIA_MODULE_SECRET____= diff --git a/deploy/env/object-storage.env.example b/deploy/env/object-storage.env.example new file mode 100644 index 0000000..07d86b6 --- /dev/null +++ b/deploy/env/object-storage.env.example @@ -0,0 +1,4 @@ +# SeaweedFS S3 credentials (`object-storage` only). Install as EKKLESIA_ENV_DIR/object-storage.env, +# owned by root with mode 600. Must match S3_ACCESS_KEY and S3_SECRET_KEY in ekklesia.env. +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= diff --git a/deploy/env/postgres.env.example b/deploy/env/postgres.env.example new file mode 100644 index 0000000..328bbb1 --- /dev/null +++ b/deploy/env/postgres.env.example @@ -0,0 +1,8 @@ +# PostgreSQL superuser for the reference deployment (`postgres` only). Install as +# EKKLESIA_ENV_DIR/postgres.env, owned by root with mode 600. Read only on the database's first +# start; changing it later does not change the stored password. +# +# Must match the user, password, and database in ekklesia.env's DATABASE_URL. +POSTGRES_USER=ekklesia +POSTGRES_DB=ekklesia +POSTGRES_PASSWORD= diff --git a/deploy/env/tunnel.env.example b/deploy/env/tunnel.env.example new file mode 100644 index 0000000..1ed0682 --- /dev/null +++ b/deploy/env/tunnel.env.example @@ -0,0 +1,4 @@ +# Cloudflare Tunnel connector token (`tunnel` only, compose.tunnel.yaml). Install as +# EKKLESIA_ENV_DIR/tunnel.env, owned by root with mode 600. Copy the token from the tunnel's page in +# the Cloudflare dashboard; it is the tunnel's credential. +TUNNEL_TOKEN= diff --git a/deploy/postgres/pg_hba.conf b/deploy/postgres/pg_hba.conf new file mode 100644 index 0000000..45fa520 --- /dev/null +++ b/deploy/postgres/pg_hba.conf @@ -0,0 +1,16 @@ +# Host-based authentication for the reference deployment's PostgreSQL (ADR 0018, ADR 0044). +# +# Mounted read-only and selected with `-c hba_file=...` (deploy/compose.yaml), so it replaces the +# image's generated file entirely. Rules are matched top to bottom; the first match decides. + +# TYPE DATABASE USER ADDRESS METHOD + +# The container's own Unix socket: the image's first-start initialization and the health check. +# Nothing outside the container can reach it. +local all all trust + +# Every network connection must be TLS (the server enforces TLS 1.3) and authenticate with SCRAM. +hostssl all all all scram-sha-256 + +# A network connection without TLS is refused outright, before authentication. +hostnossl all all all reject diff --git a/deploy/scripts/gen-secrets.sh b/deploy/scripts/gen-secrets.sh new file mode 100755 index 0000000..e9174e3 --- /dev/null +++ b/deploy/scripts/gen-secrets.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# Prints freshly generated secrets for the reference deployment's env files (ADR 0044). Writes +# nothing: paste each line into the file named above it, then install that file root-owned with +# mode 600. +# +# sh deploy/scripts/gen-secrets.sh +# +# Every value is URL-safe (hexadecimal), so the database password needs no percent-encoding in +# DATABASE_URL. + +set -eu + +command -v openssl >/dev/null 2>&1 || { + echo "gen-secrets: openssl is required." >&2 + exit 69 +} + +hex() { + openssl rand -hex "$1" +} + +postgres_password=$(hex 24) +s3_access_key=$(hex 10) +s3_secret_key=$(hex 32) + +cat <" >&2 + exit 64 +} + +[ "$#" -eq 1 ] || usage +out=$1 +case "$out" in +-*) usage ;; +esac + +# The uids the two images run their servers as: postgres:18.4-alpine3.24 (postgres) and +# chrislusf/seaweedfs:4.28 (seaweed). Each server refuses or fails to read a key it cannot own. +POSTGRES_UID=${EKKLESIA_POSTGRES_UID:-70} +SEAWEED_UID=${EKKLESIA_SEAWEED_UID:-1000} + +CA_DAYS=3650 +SERVER_DAYS=825 + +mkdir -p "$out" +chmod 700 "$out" + +created="" + +# Only generating needs openssl. A later root-only pass that applies ownership to files that already +# exist runs without it, in any image that has a POSIX shell. +require_openssl() { + command -v openssl >/dev/null 2>&1 || { + echo "init-tls: openssl is required to create keys and certificates." >&2 + exit 69 + } +} + +note_created() { + created="$created $1" +} + +# A file this script would create already exists: keep it, and say so. +exists() { + if [ -e "$out/$1" ]; then + echo "init-tls: keeping existing $out/$1" + return 0 + fi + return 1 +} + +if ! exists ca.key; then + require_openssl + openssl ecparam -name prime256v1 -genkey -noout -out "$out/ca.key" + note_created ca.key +fi + +if ! exists ca.crt; then + require_openssl + openssl req -x509 -new -sha256 -days "$CA_DAYS" -key "$out/ca.key" -out "$out/ca.crt" \ + -subj "/CN=Ekklesia installation CA" \ + -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" \ + -addext "subjectKeyIdentifier=hash" + note_created ca.crt +fi + +# issue : a key and a certificate whose only subject alternative name is , the Compose +# service name the client verifies against. +issue() { + name=$1 + if ! exists "$name.key"; then + require_openssl + openssl ecparam -name prime256v1 -genkey -noout -out "$out/$name.key" + note_created "$name.key" + fi + if ! exists "$name.crt"; then + require_openssl + extensions=$(mktemp) + request=$(mktemp) + trap 'rm -f "$extensions" "$request"' EXIT + cat >"$extensions" </dev/null + rm -f "$extensions" "$request" + trap - EXIT + note_created "$name.crt" + fi +} + +issue postgres +issue object-storage + +# Public material is world-readable; private keys are readable only by the process that needs them. +chmod 644 "$out/ca.crt" "$out/postgres.crt" "$out/object-storage.crt" +chmod 600 "$out/ca.key" "$out/postgres.key" "$out/object-storage.key" +[ -e "$out/ca.srl" ] && chmod 600 "$out/ca.srl" + +if [ -n "$created" ]; then + echo "init-tls: created$created in $out" +else + echo "init-tls: nothing to create in $out" +fi + +if [ "$(id -u)" -ne 0 ]; then + echo "init-tls: ownership not applied: re-run as root to give postgres.key to uid $POSTGRES_UID and object-storage.key to uid $SEAWEED_UID." >&2 + exit 77 +fi + +chown root:root "$out" "$out/ca.key" "$out/ca.crt" "$out/postgres.crt" "$out/object-storage.crt" +[ -e "$out/ca.srl" ] && chown root:root "$out/ca.srl" +chown "$POSTGRES_UID:$POSTGRES_UID" "$out/postgres.key" +chown "$SEAWEED_UID:$SEAWEED_UID" "$out/object-storage.key" +# The directory itself must be traversable by the two server uids for their bind mounts. +chmod 755 "$out" + +echo "init-tls: postgres.key is owned by uid $POSTGRES_UID and object-storage.key by uid $SEAWEED_UID (mode 600)." +echo +echo "Set EKKLESIA_TLS_DIR=$out in deploy/.env, and put this line in ekklesia.env (the CA" +echo "certificate is public; this is not a secret):" +echo +printf 'EKKLESIA_DATABASE_CA="%s"\n' "$(awk 'BEGIN { ORS = "\\n" } { print }' "$out/ca.crt")" diff --git a/docs/architecture/decisions/0044-self-hosted-reference-deployment.md b/docs/architecture/decisions/0044-self-hosted-reference-deployment.md index 3a652c8..9acdb99 100644 --- a/docs/architecture/decisions/0044-self-hosted-reference-deployment.md +++ b/docs/architecture/decisions/0044-self-hosted-reference-deployment.md @@ -48,7 +48,7 @@ One host, one Compose project, four long-running services on a private network: | Service | Role | | --- | --- | -| `proxy` | Caddy. Terminates TLS, serves the only published ports (443, 80 for redirect), routes `/api/*`, `/display/*`, `/assets/*` and the Dashboard to `api`, and a separate storage origin to `object-storage`. | +| `proxy` | Caddy. Terminates TLS, serves the only published ports (443; and 80, which answers ACME HTTP-01 challenges and redirects everything else to HTTPS), routes `/api/*`, `/display/*`, `/assets/*` and the Dashboard to `api`, and a separate storage origin to `object-storage`. | | `api` | The `apps/api` image: Core, the enabled modules, and the built Dashboard and display host. Listens on the private network only. | | `postgres` | PostgreSQL 18 with TLS enabled, using a server certificate issued by an installation-private CA. | | `object-storage` | SeaweedFS, the same image family as development, reachable by browsers only through the proxy. | @@ -65,21 +65,31 @@ server. The proxy never serves files from disk. ### TLS everywhere, without weakening ADR 0018 - **Browser-facing:** the proxy obtains publicly trusted certificates through ACME. The reference - configuration uses the HTTP-01 challenge; the DNS-01 challenge is documented for installations - that keep Ekklesia off the public Internet. A LAN-only installation can still hold a real - certificate for an internal hostname this way. + configuration uses the HTTP-01 challenge on port 80; the DNS-01 challenge is documented for + installations that keep Ekklesia off the public Internet. A LAN-only installation can still hold a + real certificate for an internal hostname this way. DNS-01 requires a Caddy build that includes a + DNS provider module, which the stock Caddy image does not; such an installation builds and pins + its own proxy image. A tunnel-only installation needs DNS-01 too: Cloudflare terminates TLS at its + edge, so an HTTP-01 challenge cannot reach the proxy through the tunnel. - **Tunnel ingress (optional):** an installation may publish the proxy through an outbound tunnel (the reference documents `cloudflared`) instead of opening inbound ports. The tunnel connector is a separate Compose service that connects to the proxy over HTTPS and verifies the proxy's - certificate. The proxy accepts a tunnel-supplied client-address header **only** from the - connector's network identity. It overwrites the single forwarding header it sends to `api`, and - `api` trusts only the proxy. An installation that serves local displays should give the same - hostname a LAN path to the proxy (split-horizon DNS), so displays do not depend on the - Internet link. + certificate. With a token-managed tunnel, those origin settings (the service `https://proxy:443`, + the origin server name, and TLS verification left on) live in the Cloudflare dashboard, not in the + repository; the deployment README documents them. The proxy accepts a tunnel-supplied + client-address header **only** from the connector's network identity. It overwrites the single + forwarding header it sends to `api`, and `api` trusts only the proxy. An installation that serves + local displays should give the same hostname a LAN path to the proxy (split-horizon DNS), so + displays do not depend on the Internet link. - **Database:** the deployment's setup script creates an installation-private CA once, issues a server certificate for the `postgres` service name, and gives the API only the CA certificate (`EKKLESIA_DATABASE_CA`). The API connects with `verify-full`. No plaintext or encryption-only exception is added to Core. +- **Object storage:** production storage must be `https` (ADR 0030), so the API reaches SeaweedFS + over HTTPS on the private network as well. The same setup script issues a server certificate for + the `object-storage` service name from the same CA. Core's storage client trusts Node's default CA + set, so the API adds the installation CA to it with `NODE_EXTRA_CA_CERTS`. No storage TLS + exception is added to Core either. ### Object storage @@ -134,8 +144,9 @@ an installation is treated as in production. ### Secrets -Secrets are supplied to Compose from a root-owned environment file on the host, never committed. -This includes `EKKLESIA_IDENTITY_SECRET`, database and storage credentials, SMTP credentials, and +Secrets are supplied to Compose from root-owned environment files on the host, never committed: +one per service, so no container receives another service's credentials. They include +`EKKLESIA_IDENTITY_SECRET`, database and storage credentials, SMTP credentials, and `EKKLESIA_MODULE_SECRET__*` values. A secret manager is not required. ## Consequences diff --git a/docs/development/security.md b/docs/development/security.md index ac3d779..89ad0d3 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -463,11 +463,12 @@ Until that exists, the trusted-code model above is the security model. ## Current deployment state -As of this branch, **Ekklesia is not deployed**. There is no production API instance, no reverse -proxy, no public listener, and no deployment automation in this repository — no deploy script, no -CI deployment step, no container publish, no orchestration manifest. The repository can **build** a -production image ([`deploy/`](../../deploy/README.md)), and CI builds it without modules on every -change, but nothing pushes, publishes, or runs it anywhere. No `.env` has ever been committed; only +As of this branch, **Ekklesia is not deployed**. There is no production API instance, no running +reverse proxy, no public listener, and no deployment automation in this repository — no deploy +script, no CI deployment step, no container publish. The repository provides a **reference deployment** +([`deploy/`](../../deploy/README.md)): a production image and the Compose project that runs it, which +CI builds and smoke-tests end to end on every change against disposable services. Nothing pushes, +publishes, or runs it against a real installation. No `.env` has ever been committed; only `.env.example`, which the image build context excludes. Two production defaults are relevant but are **not** by themselves a deployment guarantee: @@ -490,8 +491,9 @@ outstanding. 1. The reverse proxy forwards the Dashboard and `/api` on the intended origin, or an explicitly split origin is covered by the exact trusted-origin/CORS configuration. `apps/api` serves the - built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)); the repository - does not yet supply the proxy deployment. + built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)), and the + reference deployment's proxy routes it ([deploy/README.md](../../deploy/README.md#launch-checklist) + maps every item here to its configuration). 2. The origin port is unreachable from the public Internet. 3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private proxy path connects. Security must not depend on an unpublished DNS name or origin IP. diff --git a/package.json b/package.json index e1a7ea0..b05263e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "authorization:bootstrap-owner": "turbo run build && node --env-file-if-exists=.env core/dist/cli/bootstrap-platform-owner.js", "boundaries": "node scripts/check-boundaries.ts", "build": "turbo run build", - "ci:verify": "pnpm verify && pnpm infrastructure:config", + "ci:verify": "pnpm verify && pnpm infrastructure:config && pnpm deploy:config", + "deploy:config": "bash scripts/check-deploy-config.sh", + "deploy:smoke": "bash scripts/smoke-deploy.sh", "doctor": "node scripts/run-doctor.ts", "format": "biome format --write .", "format:check": "biome format .", diff --git a/scripts/check-deploy-config.sh b/scripts/check-deploy-config.sh new file mode 100755 index 0000000..7d44514 --- /dev/null +++ b/scripts/check-deploy-config.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Validates the reference deployment's Compose files (ADR 0044) in each supported combination: +# base, base + proxy, and base + proxy + tunnel. Uses only the committed example env files, copied +# to a temporary directory, so it needs no secret and starts nothing. + +set -Eeuo pipefail + +repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repository_root/deploy" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +for name in ekklesia postgres object-storage tunnel; do + cp "env/${name}.env.example" "$work/${name}.env" +done +cp env/compose.env.example "$work/compose.env" +cat >>"$work/compose.env" <&2 + exit 1 + fi + echo "valid: ${combination}" +done diff --git a/scripts/lib/deploy-smoke-client.mjs b/scripts/lib/deploy-smoke-client.mjs new file mode 100644 index 0000000..9a1a94b --- /dev/null +++ b/scripts/lib/deploy-smoke-client.mjs @@ -0,0 +1,254 @@ +// HTTP assertions for scripts/smoke-deploy.sh, run inside a disposable container on the reference +// deployment's `edge` network: every request goes through the proxy by its public hostname, exactly +// as a browser's would. Not a test of the API in isolation, and never run against a real +// installation. +// +// node deploy-smoke-client.mjs edge an ordinary, untrusted peer of the proxy +// node deploy-smoke-client.mjs tunnel the Cloudflare Tunnel connector's fixed address +// node deploy-smoke-client.mjs session the initial owner signing in and out through the proxy +// +// Output is one line per check; any failed check exits non-zero. Nothing printed carries a +// credential, cookie, signed URL, or response body beyond the fixed shapes asserted here. + +const mode = process.argv[2]; +const hostname = required("SMOKE_HOSTNAME"); +const origin = `https://${hostname}`; + +function required(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required.`); + } + return value; +} + +let failures = 0; + +function check(condition, description) { + console.log(`${condition ? "ok " : "FAIL"} ${description}`); + if (!condition) { + failures += 1; + } +} + +/** A sign-in attempt that can only fail authentication; what matters is whether the API's + * per-client limiter (3 attempts per 10 seconds) counts it against the expected client. */ +async function signInAttempt(headers = {}) { + const response = await fetch(`${origin}/api/auth/sign-in/email`, { + body: JSON.stringify({ email: "nobody@example.test", password: "definitely-not-the-password" }), + headers: { "content-type": "application/json", origin, ...headers }, + method: "POST", + }); + await response.arrayBuffer(); + return response.status; +} + +async function edgeChecks() { + const ready = await fetch(`${origin}/api/v1/health/ready`); + check( + ready.status === 200 && (await ready.text()) === '{"status":"ready"}', + "GET /api/v1/health/ready through the proxy is 200 ready", + ); + + const page = await fetch(`${origin}/`); + const html = await page.text(); + check(page.status === 200, "GET / through the proxy is 200"); + check( + page.headers.get("content-type") === "text/html; charset=utf-8" && + html.includes('id="ekklesia-host"') && + html.includes('src="/assets/ekklesia-dashboard/app.js"'), + "GET / is the Dashboard HTML", + ); + check( + page.headers.get("x-frame-options") === "DENY" && + page.headers.get("content-security-policy") === "frame-ancestors 'none'" && + page.headers.get("referrer-policy") === "no-referrer", + "the Dashboard page keeps its security headers through the proxy", + ); + + const script = await fetch(`${origin}/assets/ekklesia-dashboard/app.js`); + await script.arrayBuffer(); + check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200"); + + const outside = await fetch(`${origin}/favicon.ico`); + check( + outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"), + "a path the API does not serve is refused by the proxy, never forwarded", + ); + + const unknownApi = await fetch(`${origin}/api/v1/does-not-exist`); + check( + unknownApi.status === 404 && (await unknownApi.text()) === '{"code":"NOT_FOUND"}', + "an unknown /api/* path reaches the API and gets its sanitized 404", + ); + + await storageDeliveryCheck(); + + // Every caller-supplied forwarding header rotates on every attempt. If any of them reached the + // API's client-address resolution, each attempt would land in a fresh limiter bucket and the + // fourth would not be limited. + const statuses = []; + for (let attempt = 1; attempt <= 4; attempt += 1) { + statuses.push( + await signInAttempt({ + "cf-connecting-ip": `198.51.100.${attempt}`, + forwarded: `for=198.51.100.${attempt + 10}`, + "true-client-ip": `198.51.100.${attempt + 20}`, + "x-ekklesia-client-address": `198.51.100.${attempt + 50}`, + "x-forwarded-for": `198.51.100.${attempt + 30}`, + "x-real-ip": `198.51.100.${attempt + 40}`, + }), + ); + } + check( + statuses.slice(0, 3).every((status) => status !== 429 && status < 500) && statuses[3] === 429, + `spoofed forwarding headers do not choose the client address (statuses ${statuses.join(",")})`, + ); +} + +async function storageDeliveryCheck() { + // Core's own provider, from the image, exactly as the API composes it: it reaches object-storage + // over HTTPS on the private network and signs read URLs for the storage hostname. + const { createS3StorageProvider, createStorageProviderConfig } = await import( + "/opt/ekklesia/core/dist/storage/index.js" + ); + const config = createStorageProviderConfig( + { + accessKey: required("S3_ACCESS_KEY"), + bucketPrefix: "ekklesia-smoke", + deliveryEndpoint: `https://${required("SMOKE_STORAGE_HOSTNAME")}`, + endpoint: "https://object-storage:8443", + forcePathStyle: true, + region: "us-east-1", + secretKey: required("S3_SECRET_KEY"), + }, + { environment: "production" }, + ); + const provider = createS3StorageProvider(config); + try { + const bucket = "ekklesia-smoke-delivery"; + const body = new TextEncoder().encode("delivered through the proxy\n"); + await provider.ensureBucket(bucket); + await provider.put(bucket, "smoke/object.txt", body, { + contentLength: body.byteLength, + contentType: "text/plain", + }); + const readUrl = await provider.createReadUrl(bucket, "smoke/object.txt", { + expiresInSeconds: 300, + }); + const url = new URL(readUrl.url); + check( + url.origin === `https://${required("SMOKE_STORAGE_HOSTNAME")}`, + "a signed read URL names the storage hostname", + ); + const delivered = await fetch(url); + const text = await delivered.text(); + check( + delivered.status === 200 && text === "delivered through the proxy\n", + "a signed read URL verifies through the proxy (original Host preserved)", + ); + check( + (delivered.headers.get("cache-control") ?? "").startsWith("private"), + "a delivered object is Cache-Control: private", + ); + await provider.delete(bucket, "smoke/object.txt"); + } finally { + await provider.close(); + } +} + +async function tunnelChecks() { + // From the connector's fixed address, CF-Connecting-IP is the client: one visitor is limited on + // its fourth attempt, and a different visitor is not limited by the first one's attempts. + const first = []; + for (let attempt = 1; attempt <= 4; attempt += 1) { + first.push(await signInAttempt({ "cf-connecting-ip": "203.0.113.10" })); + } + check( + first.slice(0, 3).every((status) => status !== 429 && status < 500) && first[3] === 429, + `the connector's CF-Connecting-IP identifies the visitor (statuses ${first.join(",")})`, + ); + const second = await signInAttempt({ "cf-connecting-ip": "203.0.113.11" }); + check( + second !== 429 && second < 500, + `a second visitor behind the connector has its own limiter bucket (status ${second})`, + ); +} + +/** The deployed browser authentication contract (launch checklist item 9), as the initial owner + * created by the image's own CLI (item 10). */ +async function sessionChecks() { + for (const path of [ + "/api/auth/get-session", + "/api/auth/list-sessions", + "/api/auth/update-session", + "/api/auth/change-password", + ]) { + const response = await fetch(`${origin}${path}`, { + method: path.endsWith("get-session") || path.endsWith("list-sessions") ? "GET" : "POST", + headers: { origin }, + }); + check( + response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}', + `${path} is unavailable through the proxy`, + ); + } + + const signIn = await fetch(`${origin}/api/auth/sign-in/email`, { + body: JSON.stringify({ + email: required("SMOKE_OWNER_EMAIL"), + password: required("SMOKE_OWNER_PASSWORD"), + }), + headers: { "content-type": "application/json", origin }, + method: "POST", + }); + const signInBody = await signIn.text(); + const setCookie = signIn.headers.get("set-cookie") ?? ""; + check( + signIn.status === 200 && signInBody === '{"ok":true}', + 'sign-in returns the fixed {"ok":true}', + ); + const attributes = setCookie.toLowerCase(); + check( + attributes.includes("httponly") && + attributes.includes("secure") && + attributes.includes("samesite=lax") && + !attributes.includes("domain="), + "the session cookie is host-only, HttpOnly, Secure, and SameSite=Lax", + ); + const cookie = setCookie.split(";")[0] ?? ""; + + const principal = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } }); + const principalBody = await principal.json(); + check( + principal.status === 200 && principalBody.kind === "authenticated", + "the session resolves an authenticated principal through the proxy", + ); + + const signOut = await fetch(`${origin}/api/auth/sign-out`, { + body: "{}", + headers: { "content-type": "application/json", cookie, origin }, + method: "POST", + }); + await signOut.arrayBuffer(); + const after = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } }); + check( + signOut.status === 200 && (await after.json()).kind === "anonymous", + "sign-out ends the session", + ); +} + +if (mode === "edge") { + await edgeChecks(); +} else if (mode === "tunnel") { + await tunnelChecks(); +} else if (mode === "session") { + await sessionChecks(); +} else { + throw new Error('Mode must be "edge", "tunnel", or "session".'); +} + +if (failures > 0) { + console.log(`${failures} check(s) failed.`); + process.exit(1); +} diff --git a/scripts/smoke-deploy.sh b/scripts/smoke-deploy.sh new file mode 100755 index 0000000..7090761 --- /dev/null +++ b/scripts/smoke-deploy.sh @@ -0,0 +1,348 @@ +#!/usr/bin/env bash +# End-to-end smoke test of the reference deployment (ADR 0044): deploy/compose.yaml with +# deploy/compose.proxy.yaml, a platform-only image, and a disposable PostgreSQL and SeaweedFS that +# exist only for this run. CI runs it; it is safe to run on a workstation. +# +# bash scripts/smoke-deploy.sh builds the image first +# EKKLESIA_SMOKE_IMAGE= bash scripts/smoke-deploy.sh +# +# TEST-ONLY: the proxy runs with Caddy's internal CA (`local_certs`) instead of ACME, because a test +# run has no public hostname. Never use that option in an installation. +# +# Follows the documented first boot: TLS material, secrets, data services, an explicit migration +# apply against this run's own fresh database, then the API and proxy. Everything it starts, and +# every file it writes, is removed on exit. +# +# Bind mounts are resolved by the Docker daemon, on its host. When this script runs inside a CI job +# container that drives the host's Docker, its own files are not at those paths, so every file a +# container mounts is staged under an identical path in the Docker host's /tmp first. + +set -Eeuo pipefail + +repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repository_root" + +for command in docker openssl awk; do + command -v "$command" >/dev/null || { + echo "smoke-deploy: $command is required." >&2 + exit 1 + } +done +docker compose version >/dev/null 2>&1 || { + echo "smoke-deploy: Docker Compose is required." >&2 + exit 1 +} + +# The pinned PostgreSQL image doubles as a root shell for file ownership, so the run needs neither +# root nor sudo on the host. +postgres_image="postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15" + +run_id="$(openssl rand -hex 4)" +project="ekklesia-deploy-smoke-${run_id}" +work="$(mktemp -d)" +image="${EKKLESIA_SMOKE_IMAGE:-}" +built_image="" +hostname="ekklesia.smoke.test" +storage_hostname="storage.smoke.test" + +# A per-run edge subnet, so concurrent runs and the host's own networks do not collide. Fixed +# addresses come from the upper half, outside Docker's dynamic range, exactly as documented. +octet=$((RANDOM % 200 + 20)) +edge_subnet="10.249.${octet}.0/24" +edge_range="10.249.${octet}.0/25" +proxy_address="10.249.${octet}.254" +tunnel_address="10.249.${octet}.253" +# A test-only client address, also outside the dynamic range, so its limiter bucket is its own. +session_client_address="10.249.${octet}.252" + +# daemon_work: where the Docker daemon sees this run's mounted files. The same directory as `work` +# on a workstation; a staged copy on the Docker host when this script runs in a job container. +if [[ -f /.dockerenv ]]; then + daemon_work="/tmp/${project}" + docker run --rm --entrypoint mkdir -v /tmp:/host-tmp "$postgres_image" -p "/host-tmp/${project}" + # stage : copy it to the same relative path under daemon_work, keeping + # numeric ownership and modes. + stage() { + tar -C "$work" -cf - "$1" | + docker run --rm -i --entrypoint tar -v "$daemon_work:/w" "$postgres_image" -xpf - -C /w + } +else + daemon_work="$work" + stage() { :; } +fi + +compose=( + docker compose + --project-name "$project" + --project-directory "$daemon_work/deploy" + --env-file "$work/compose.env" + --file deploy/compose.yaml + --file deploy/compose.proxy.yaml +) + +cleanup() { + local status=$? + trap - EXIT INT TERM + if [[ -f "$work/compose.env" ]]; then + "${compose[@]}" --profile ops down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || status=1 + fi + # Some files are owned by the PostgreSQL and SeaweedFS uids by now; remove them as root, in the + # same image that set that ownership. In a job container the daemon cannot see `work` (and the + # job itself runs as root), so only the staged copy on the Docker host needs this. + if [[ "$daemon_work" == "$work" ]]; then + docker run --rm --entrypoint rm -v "$work:/work" "$postgres_image" -rf /work/tls /work/env \ + >/dev/null 2>&1 || true + else + docker run --rm --entrypoint rm -v /tmp:/host-tmp "$postgres_image" -rf "/host-tmp/${project}" \ + >/dev/null 2>&1 || true + fi + rm -rf "$work" || true + if [[ -n "$built_image" ]]; then + docker image rm --force "$built_image" >/dev/null 2>&1 || true + fi + exit "$status" +} +trap cleanup EXIT INT TERM + +step() { + echo "==> $*" +} + +fail() { + echo "smoke-deploy: $*" >&2 + exit 1 +} + +if [[ -z "$image" ]]; then + step "Building the platform-only image" + built_image="ekklesia-deploy-smoke:${run_id}" + DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" . >/dev/null + image="$built_image" +fi + +# The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here. +cp -R deploy "$work/deploy" +stage deploy + +step "Creating the installation-private CA and server certificates" +init_status=0 +sh deploy/scripts/init-tls.sh "$work/tls" >/dev/null 2>&1 || init_status=$? +if ((init_status != 0 && init_status != 77)); then + fail "init-tls.sh failed to create TLS material." +fi +stage tls +# The root-only ownership pass, exactly as an operator would re-run the script with root. +ownership_output="$( + docker run --rm --entrypoint sh -v "$daemon_work/tls:/tls" \ + -v "$daemon_work/deploy/scripts:/scripts:ro" "$postgres_image" /scripts/init-tls.sh /tls +)" || fail "init-tls.sh could not apply ownership." +database_ca_line="$(grep '^EKKLESIA_DATABASE_CA=' <<<"$ownership_output")" || + fail "init-tls.sh did not print the EKKLESIA_DATABASE_CA line." +# A second run must create nothing and still succeed. +rerun_output="$( + docker run --rm --entrypoint sh -v "$daemon_work/tls:/tls" \ + -v "$daemon_work/deploy/scripts:/scripts:ro" "$postgres_image" /scripts/init-tls.sh /tls +)" || fail "init-tls.sh failed on a second run." +grep -q 'nothing to create' <<<"$rerun_output" || fail "init-tls.sh is not idempotent." + +step "Writing env files from the examples, with freshly generated secrets" +secrets="$(sh deploy/scripts/gen-secrets.sh)" +secret() { + awk -F= -v key="$1" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' <<<"$secrets" +} +postgres_password="$(secret POSTGRES_PASSWORD)" +s3_access_key="$(secret S3_ACCESS_KEY)" +s3_secret_key="$(secret S3_SECRET_KEY)" +identity_secret="$(secret EKKLESIA_IDENTITY_SECRET)" + +mkdir -p "$work/env" +# set_env : replaces an uncommented KEY= line, keeping the value byte-exact. +set_env() { + KEY="$2" VALUE="$3" awk ' + BEGIN { found = 0 } + index($0, ENVIRON["KEY"] "=") == 1 { print ENVIRON["KEY"] "=" ENVIRON["VALUE"]; found = 1; next } + { print } + END { if (!found) print ENVIRON["KEY"] "=" ENVIRON["VALUE"] } + ' "$1" >"$1.next" + mv "$1.next" "$1" +} +for name in ekklesia postgres object-storage; do + cp "deploy/env/${name}.env.example" "$work/env/${name}.env" +done +ekklesia_env="$work/env/ekklesia.env" +set_env "$ekklesia_env" DATABASE_URL "postgresql://ekklesia:${postgres_password}@postgres:5432/ekklesia" +set_env "$ekklesia_env" EKKLESIA_DATABASE_CA "${database_ca_line#EKKLESIA_DATABASE_CA=}" +set_env "$ekklesia_env" EKKLESIA_IDENTITY_BASE_URL "https://${hostname}" +set_env "$ekklesia_env" EKKLESIA_IDENTITY_TRUSTED_ORIGINS "https://${hostname}" +set_env "$ekklesia_env" EKKLESIA_IDENTITY_SECRET "$identity_secret" +set_env "$ekklesia_env" S3_DELIVERY_ENDPOINT "https://${storage_hostname}" +set_env "$ekklesia_env" S3_ACCESS_KEY "$s3_access_key" +set_env "$ekklesia_env" S3_SECRET_KEY "$s3_secret_key" +set_env "$work/env/postgres.env" POSTGRES_PASSWORD "$postgres_password" +set_env "$work/env/object-storage.env" AWS_ACCESS_KEY_ID "$s3_access_key" +set_env "$work/env/object-storage.env" AWS_SECRET_ACCESS_KEY "$s3_secret_key" +chmod 600 "$work"/env/*.env + +# TEST-ONLY: Caddy's internal CA instead of ACME. Inserted as the first global option. +awk '!done && $0 == "{" { print; print "\tlocal_certs"; done = 1; next } { print }' \ + deploy/Caddyfile.example >"$work/Caddyfile" +grep -q $'^\tlocal_certs$' "$work/Caddyfile" || fail "could not enable local_certs for the test proxy." +stage Caddyfile + +cat >"$work/compose.env" </dev/null 2>&1 || + fail "PostgreSQL and object storage did not become healthy." + +step "Planning, then explicitly applying, migrations against this run's fresh database" +"${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "migrate (plan) failed." +"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null || + fail "migrate (apply) failed." + +step "Starting the API and the proxy" +"${compose[@]}" up --detach --wait --wait-timeout 180 >/dev/null 2>&1 || + fail "the API and proxy did not become healthy." + +# Caddy creates its internal CA on first start. The test clients trust it, plus the installation CA +# for their direct connection to object-storage. +for _ in $(seq 1 30); do + if "${compose[@]}" exec -T proxy cat /data/caddy/pki/authorities/local/root.crt \ + >"$work/caddy-root.crt" 2>/dev/null && [[ -s "$work/caddy-root.crt" ]]; then + break + fi + sleep 1 +done +[[ -s "$work/caddy-root.crt" ]] || fail "the test proxy's internal CA did not appear." +cat "$work/caddy-root.crt" "$work/tls/ca.crt" >"$work/client-ca.pem" +cp scripts/lib/deploy-smoke-client.mjs "$work/client.mjs" +stage client-ca.pem +stage client.mjs + +edge_network="${project}_edge" +client() { + local mode="$1" + shift + docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \ + --network "$edge_network" "$@" \ + --add-host "${hostname}:${proxy_address}" --add-host "${storage_hostname}:${proxy_address}" \ + -e NODE_EXTRA_CA_CERTS=/smoke/client-ca.pem -e SMOKE_HOSTNAME="$hostname" \ + -e SMOKE_STORAGE_HOSTNAME="$storage_hostname" \ + -e S3_ACCESS_KEY="$s3_access_key" -e S3_SECRET_KEY="$s3_secret_key" \ + -v "$daemon_work/client.mjs:/smoke/client.mjs:ro" \ + -v "$daemon_work/client-ca.pem:/smoke/client-ca.pem:ro" \ + --entrypoint node "$image" /smoke/client.mjs "$mode" +} + +step "Checking the API, Dashboard, storage delivery, and client-address chain through the proxy" +client edge || fail "edge checks failed." + +step "Checking the tunnel connector's client-address path from its fixed address" +client tunnel --ip "$tunnel_address" || fail "tunnel checks failed." + +step "Creating the initial owner with the image's own CLI, then signing in and out through the proxy" +owner_email="owner@smoke.test" +owner_password="$(openssl rand -hex 16)" +printf '%s' "$owner_password" | + "${compose[@]}" run --rm --no-deps -T migrate node core/dist/cli/create-initial-owner.js \ + --email "$owner_email" >/dev/null || fail "creating the initial owner failed." +echo "ok the initial owner was created from the image (password on standard input)" +client session --ip "$session_client_address" \ + -e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" || + fail "session checks failed." + +step "Proving the API's database connection is certificate-verified TLS" +api_container="$("${compose[@]}" ps -q api)" +api_data_address="$( + docker inspect --format "{{(index .NetworkSettings.Networks \"${project}_data\").IPAddress}}" \ + "$api_container" +)" +[[ -n "$api_data_address" ]] || fail "could not find the API's database-network address." +psql_as_superuser() { + "${compose[@]}" exec -T postgres psql -U ekklesia -d ekklesia -Atc "$1" +} +tls_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND s.ssl AND s.version = 'TLSv1.3'")" +plain_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND NOT s.ssl")" +((tls_connections > 0)) || fail "the API holds no TLS 1.3 database connection." +((plain_connections == 0)) || fail "the API holds a database connection without TLS." +echo "ok the API's database connections are TLS 1.3 (${tls_connections}), none plaintext" + +# PostgreSQL itself refuses a network connection without TLS. +if "${compose[@]}" exec -T -e PGPASSWORD="$postgres_password" postgres \ + psql "host=postgres user=ekklesia dbname=ekklesia sslmode=disable" -Atc 'SELECT 1' >/dev/null 2>&1; then + fail "PostgreSQL accepted a plaintext network connection." +fi +echo "ok PostgreSQL refuses a plaintext network connection" + +# The server cannot see whether a client verified it, so verification is proven by refusal: the +# same command fails against a CA that did not issue the certificate, and against a host the +# certificate does not name. +openssl ecparam -name prime256v1 -genkey -noout -out "$work/other-ca.key" 2>/dev/null +openssl req -x509 -new -sha256 -days 1 -key "$work/other-ca.key" -out "$work/other-ca.crt" \ + -subj "/CN=Unrelated CA" 2>/dev/null +# expect_connection_refused : the command must fail, and fail +# at the connection, not on a configuration error that would prove nothing about verification. +expect_connection_refused() { + local description="$1" + shift + local output + if output="$("${compose[@]}" run --rm --no-deps "$@" 2>&1)"; then + fail "the database connection accepted ${description}." + fi + grep -q 'MIGRATION_CONNECTION_FAILED' <<<"$output" || + fail "rejecting ${description} failed for a reason other than the connection." +} +expect_connection_refused "a certificate from a CA it was not given" \ + -e EKKLESIA_DATABASE_CA="$(cat "$work/other-ca.crt")" migrate +echo "ok the database connection rejects a certificate its CA did not issue" +postgres_address="$( + docker inspect --format "{{(index .NetworkSettings.Networks \"${project}_data\").IPAddress}}" \ + "$("${compose[@]}" ps -q postgres)" +)" +expect_connection_refused "a certificate for a different host name" \ + -e DATABASE_URL="postgresql://ekklesia:${postgres_password}@${postgres_address}:5432/ekklesia" migrate +echo "ok the database connection rejects a certificate that does not name the host" +"${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "the control connection failed." +echo "ok the same connection succeeds with the installation CA and the name postgres" + +step "Running Doctor from the image" +doctor_output="$("${compose[@]}" run --rm --no-deps migrate node core/dist/cli/doctor.js 2>&1)" || + fail "Doctor reported the installation not ready." +grep -q 'Result: READY' <<<"$doctor_output" || fail "Doctor did not report READY." +grep -q 'TLS required with certificate and hostname verification (explicit private CA supplied)' \ + <<<"$doctor_output" || fail "Doctor did not report verify-full with the private CA." +grep -q '✓ Object storage reachable' <<<"$doctor_output" || + fail "Doctor could not reach object storage over HTTPS." +for value in "$postgres_password" "$s3_secret_key" "$identity_secret"; do + if grep -qF -- "$value" <<<"$doctor_output"; then + fail "Doctor output contains a secret." + fi +done +echo "ok Doctor: READY, verify-full with the private CA, storage reachable, no secret printed" + +api_logs="$("${compose[@]}" logs --no-color api 2>&1)" +for value in "$postgres_password" "$s3_secret_key" "$identity_secret" "$owner_password"; do + if grep -qF -- "$value" <<<"$api_logs"; then + fail "the API's logs contain a secret." + fi +done +restarts="$(docker inspect --format '{{.RestartCount}}' "$api_container")" +((restarts == 0)) || fail "the API restarted ${restarts} time(s)." +echo "ok the API logged no secret and never restarted" + +echo "Reference deployment smoke test passed." diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts new file mode 100644 index 0000000..8dcfe6a --- /dev/null +++ b/tests/deploy.test.ts @@ -0,0 +1,231 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +/** + * Structural guarantees of the reference deployment (ADR 0044), checked on the files themselves so + * an edit cannot quietly weaken them. `scripts/smoke-deploy.sh` proves the same configuration + * actually runs; this proves it still says what the documentation promises. + */ + +interface ComposeService { + build?: unknown; + cap_add?: string[]; + cap_drop?: string[]; + command?: string[]; + env_file?: string[]; + environment?: Record; + healthcheck?: { disable?: boolean }; + image?: string; + init?: boolean; + networks?: Record; + ports?: string[]; + profiles?: string[]; + read_only?: boolean; + security_opt?: string[]; + tmpfs?: string[]; +} + +interface ComposeDocument { + networks?: Record< + string, + { internal?: boolean; ipam?: { config?: { subnet?: string; ip_range?: string }[] } } + >; + services?: Record; +} + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const deployDirectory = path.join(repositoryRoot, "deploy"); + +async function compose(file: string): Promise { + // `merge: true` resolves the base file's `<<:` hardening anchors exactly as Compose does. + return parse(await readFile(path.join(deployDirectory, file), "utf8"), { + merge: true, + }) as ComposeDocument; +} + +async function text(file: string): Promise { + return readFile(path.join(deployDirectory, file), "utf8"); +} + +function envAssignments(source: string): Map { + const assignments = new Map(); + for (const line of source.split("\n")) { + const match = /^([A-Z][A-Z0-9_]*)=(.*)$/u.exec(line); + if (match?.[1]) { + assignments.set(match[1], match[2] ?? ""); + } + } + return assignments; +} + +const DIGEST_PINNED = /^[^\s:@]+(?::[^\s@]+)?@sha256:[0-9a-f]{64}$/u; + +describe("reference deployment compose files", () => { + it("keeps PostgreSQL on its internal network only, and publishes no port from the base", async () => { + const base = await compose("compose.yaml"); + const services = base.services ?? {}; + + expect(Object.keys(services).sort()).toEqual(["api", "migrate", "object-storage", "postgres"]); + expect(Object.keys(services.postgres?.networks ?? {})).toEqual(["data"]); + expect(base.networks?.data?.internal).toBe(true); + expect(base.networks?.storage?.internal).toBe(true); + for (const [name, service] of Object.entries(services)) { + expect(service.ports, name).toBeUndefined(); + } + + for (const overlay of ["compose.proxy.yaml", "compose.tunnel.yaml"]) { + const document = await compose(overlay); + expect(document.services?.postgres, overlay).toBeUndefined(); + } + }); + + it("pins every third-party image by digest and runs the API only from EKKLESIA_IMAGE", async () => { + const documents = await Promise.all( + ["compose.yaml", "compose.proxy.yaml", "compose.tunnel.yaml"].map(compose), + ); + for (const document of documents) { + for (const [name, service] of Object.entries(document.services ?? {})) { + expect(service.build, name).toBeUndefined(); + if (service.image === undefined) { + continue; + } + if (name === "api" || name === "migrate") { + expect(service.image, name).toMatch(/^\$\{EKKLESIA_IMAGE:\?/u); + } else { + expect(service.image, name).toMatch(DIGEST_PINNED); + } + } + } + }); + + it("hardens api and migrate, keeps migrate operator-only, and never lets it apply by default", async () => { + const services = (await compose("compose.yaml")).services ?? {}; + for (const name of ["api", "migrate"]) { + const service = services[name]; + expect(service?.init, name).toBe(true); + expect(service?.read_only, name).toBe(true); + expect(service?.tmpfs, name).toEqual(["/tmp"]); + expect(service?.cap_drop, name).toEqual(["ALL"]); + expect(service?.security_opt, name).toContain("no-new-privileges:true"); + expect(service?.environment?.NODE_EXTRA_CA_CERTS, name).toBe("/run/ekklesia/tls/ca.crt"); + } + const migrate = services.migrate; + expect(migrate?.profiles).toEqual(["ops"]); + expect(migrate?.healthcheck?.disable).toBe(true); + expect(migrate?.command).toEqual(["node", "core/dist/cli/plan-migrations.js"]); + expect(JSON.stringify(services)).not.toContain("apply-migrations"); + }); + + it("serves PostgreSQL and object storage over TLS from the installation-private CA", async () => { + const services = (await compose("compose.yaml")).services ?? {}; + expect(services.postgres?.command).toEqual( + expect.arrayContaining([ + "ssl=on", + "ssl_min_protocol_version=TLSv1.3", + "hba_file=/etc/ekklesia/pg_hba.conf", + ]), + ); + expect(services["object-storage"]?.command).toEqual( + expect.arrayContaining(["-s3.port.https=8443"]), + ); + + const hba = (await text("postgres/pg_hba.conf")) + .split("\n") + .filter((line) => line.trim() !== "" && !line.trim().startsWith("#")) + .map((line) => line.trim().split(/\s+/u)); + expect(hba).toEqual([ + ["local", "all", "all", "trust"], + ["hostssl", "all", "all", "all", "scram-sha-256"], + ["hostnossl", "all", "all", "all", "reject"], + ]); + }); + + it("makes the proxy the API's only trusted peer, at a fixed address outside the dynamic range", async () => { + const proxyOverlay = await compose("compose.proxy.yaml"); + const proxy = proxyOverlay.services?.proxy; + const proxyAddress = proxy?.networks?.edge?.ipv4_address; + expect(proxyAddress).toBe("${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}"); + expect(proxyOverlay.services?.api?.environment).toEqual({ + EKKLESIA_CLIENT_ADDRESS_HEADER: "x-forwarded-for", + EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy", + EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: `${proxyAddress}/32`, + }); + expect(proxyOverlay.networks?.edge?.ipam?.config).toEqual([ + { + ip_range: "${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}", + subnet: "${EKKLESIA_EDGE_SUBNET:-10.249.250.0/24}", + }, + ]); + expect(proxy?.cap_drop).toEqual(["ALL"]); + expect(proxy?.cap_add).toEqual(["NET_BIND_SERVICE"]); + + const tunnel = (await compose("compose.tunnel.yaml")).services?.tunnel; + expect(tunnel?.networks?.edge?.ipv4_address).toBe("${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}"); + expect(tunnel?.env_file).toEqual(["${EKKLESIA_ENV_DIR:-/etc/ekklesia}/tunnel.env"]); + }); + + it("routes only the API's own paths, overwrites the forwarding header, and never ships local_certs", async () => { + const caddyfile = await text("Caddyfile.example"); + const directives = caddyfile + .split("\n") + .filter((line) => !line.trim().startsWith("#")) + .join("\n"); + + expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*"); + expect(directives).toContain("header_up X-Forwarded-For {client_ip}"); + expect(directives).toContain("header_up -CF-Connecting-IP"); + expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32"); + expect(directives).toContain("header_up Host {host}"); + expect(directives).toContain("tls_trust_pool file /etc/caddy/ekklesia-ca.crt"); + // Caddy's internal CA is a test-only substitution made by scripts/smoke-deploy.sh. + expect(directives).not.toContain("local_certs"); + expect(directives).not.toMatch(/tls\s+internal/u); + }); +}); + +describe("reference deployment env examples", () => { + it("contain no secret values", async () => { + const secretKeys = new Map([ + ["env/ekklesia.env.example", ["EKKLESIA_IDENTITY_SECRET", "S3_ACCESS_KEY", "S3_SECRET_KEY"]], + ["env/postgres.env.example", ["POSTGRES_PASSWORD"]], + ["env/object-storage.env.example", ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]], + ["env/tunnel.env.example", ["TUNNEL_TOKEN"]], + ]); + for (const [file, keys] of secretKeys) { + const assignments = envAssignments(await text(file)); + for (const key of keys) { + expect(assignments.get(key), `${file} ${key}`).toBe(""); + } + } + const ekklesia = envAssignments(await text("env/ekklesia.env.example")); + expect(ekklesia.get("DATABASE_URL")).toBe( + "postgresql://:@postgres:5432/", + ); + expect(ekklesia.get("EKKLESIA_DATABASE_TLS_MODE")).toBe("verify-full"); + expect(ekklesia.get("S3_ENDPOINT")).toBe("https://object-storage:8443"); + }); + + it("names every variable Core's host configuration reads", async () => { + // The single place executables read the environment (core/src/host/configuration.ts). A new + // variable there without a line here would leave an operator guessing. + const hostConfiguration = await readFile( + path.join(repositoryRoot, "core", "src", "host", "configuration.ts"), + "utf8", + ); + const read = new Set( + [...hostConfiguration.matchAll(/environment\.([A-Z][A-Z0-9_]+)/gu)].map((match) => match[1]), + ); + const example = await text("env/ekklesia.env.example"); + // Set by the image, not by an operator. + read.delete("NODE_ENV"); + // Guards against a pattern that silently stops matching and checks nothing. + expect(read.size).toBeGreaterThan(20); + for (const name of read) { + expect(example, String(name)).toMatch(new RegExp(`^#? ?${name}=`, "mu")); + } + expect(example).toContain("EKKLESIA_MODULE_SECRET____="); + }); +}); -- 2.54.0