feat(deploy): add the reference deployment Compose project #49
@@ -135,3 +135,26 @@ jobs:
|
|||||||
- name: Remove the image from the runner
|
- name: Remove the image from the runner
|
||||||
if: always()
|
if: always()
|
||||||
run: docker image rm --force ekklesia-ci:"$GITHUB_SHA" || true
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+300
-95
@@ -1,9 +1,273 @@
|
|||||||
# Deployment
|
# Deployment
|
||||||
|
|
||||||
This directory holds Ekklesia's reference self-hosted deployment
|
This directory holds Ekklesia's reference self-hosted deployment
|
||||||
([ADR 0044](../docs/architecture/decisions/0044-self-hosted-reference-deployment.md)). Today it
|
([ADR 0044](../docs/architecture/decisions/0044-self-hosted-reference-deployment.md)): one Linux
|
||||||
contains the production container image. The Compose project, proxy configuration, and backup job
|
host, one Docker Compose project, and the production container image it runs. Nothing here is
|
||||||
the ADR describes are separate, later work.
|
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://<hostname>/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 [email protected]
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## The production image
|
||||||
|
|
||||||
@@ -79,8 +343,7 @@ example, process one image).
|
|||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Configuration comes **only from the container environment**, never from a file inside the image.
|
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
|
The image sets exactly three variables itself:
|
||||||
Secrets). The image sets exactly three variables itself:
|
|
||||||
|
|
||||||
| Variable | Image value | Why |
|
| 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`. |
|
| `EKKLESIA_API_PORT` | `3000` | The platform default, made explicit; `EXPOSE 3000`. |
|
||||||
|
|
||||||
Everything else is the ordinary host configuration in
|
Everything else is the ordinary host configuration in
|
||||||
[API § Required configuration](../docs/development/api.md#required-configuration). In production
|
[API § Required configuration](../docs/development/api.md#required-configuration), supplied by the
|
||||||
that means `DATABASE_URL` with certificate-verified TLS: `EKKLESIA_DATABASE_CA` is the CA's PEM text
|
reference deployment's `ekklesia.env`. `EKKLESIA_DATABASE_CA` is the CA's PEM text itself, on one
|
||||||
itself, passed in the environment, so no certificate file is mounted.
|
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.
|
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
|
`compose.yaml` runs `api` and `migrate` with `init: true`, `read_only: true`, `tmpfs: /tmp`,
|
||||||
step and without `--env-file`. It never applies migrations.
|
`cap_drop: [ALL]`, and `no-new-privileges`. That is the supported production posture:
|
||||||
|
|
||||||
```yaml
|
- **`init: true`** runs a minimal init as PID 1 that forwards signals and reaps children, so
|
||||||
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
|
`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.
|
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 read-only flags are the supported production posture.** The image was verified under exactly
|
the migration and Doctor commands ran. Nothing wrote to `/tmp`, which stays mounted as headroom
|
||||||
`--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges`, with `--init`:
|
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
|
||||||
- the API reached `healthy` and served `/api/v1/health/ready`, `GET /`, and the Dashboard script;
|
work around.
|
||||||
- `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.
|
|
||||||
|
|
||||||
### Health
|
### 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
|
`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
|
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
|
[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
|
An external monitor can use the same endpoint through the proxy. Use `/api/v1/health/live` to ask
|
||||||
`/api/v1/health/live` to ask only whether the process is serving.
|
only whether the process is serving.
|
||||||
|
|
||||||
### Operator commands
|
### Operator commands
|
||||||
|
|
||||||
The same image runs every CLI, directly with `node core/dist/cli/<command>.js`. The `pnpm` scripts
|
The same image runs every CLI, directly with `node core/dist/cli/<command>.js`, through the
|
||||||
of the same names rebuild first and read `.env`, so they are for a development checkout, not for the
|
`migrate` service. The `pnpm` scripts of the same names rebuild first and read `.env`, so they are
|
||||||
image.
|
for a development checkout, not for the image.
|
||||||
|
|
||||||
**Nothing applies migrations automatically.** Neither the API nor the image's default command ever
|
`migrate` has `profiles: ["ops"]`, so `up` never starts it. It never restarts, has no health check,
|
||||||
runs `apply-migrations.js`; only an operator does, by running the command below.
|
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,
|
| Task | Command (from `deploy/`) |
|
||||||
never restarts, and does not run the API's health check:
|
| --- | --- |
|
||||||
|
| 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 <id>` (or `--disable`) |
|
||||||
|
|
||||||
```yaml
|
The plan, apply, Doctor, and initial-owner commands are exercised by the smoke test; module
|
||||||
services:
|
enablement is not.
|
||||||
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 [email protected]` | `docker run --rm -it --env-file /etc/ekklesia/ekklesia.env ekklesia:0.1.0 node core/dist/cli/create-initial-owner.js --email [email protected]` |
|
|
||||||
| Enable or disable a module | `docker compose run --rm migrate node core/dist/cli/set-module-enabled.js --enable --module <id>` | `docker run --rm --env-file /etc/ekklesia/ekklesia.env ekklesia:0.1.0 node core/dist/cli/set-module-enabled.js --disable --module <id>` |
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### Size
|
### Size
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
Vendored
+29
@@ -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
|
||||||
Vendored
+71
@@ -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://<user>:<password>@postgres:5432/<database>
|
||||||
|
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=<proxy address>/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__<MODULE_ID>__<KEY>=
|
||||||
+4
@@ -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=
|
||||||
Vendored
+8
@@ -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=
|
||||||
Vendored
+4
@@ -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=
|
||||||
@@ -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
|
||||||
Executable
+39
@@ -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 <<EOF
|
||||||
|
# ekklesia.env
|
||||||
|
EKKLESIA_IDENTITY_SECRET=$(hex 32)
|
||||||
|
DATABASE_URL=postgresql://ekklesia:$postgres_password@postgres:5432/ekklesia
|
||||||
|
S3_ACCESS_KEY=$s3_access_key
|
||||||
|
S3_SECRET_KEY=$s3_secret_key
|
||||||
|
|
||||||
|
# postgres.env
|
||||||
|
POSTGRES_PASSWORD=$postgres_password
|
||||||
|
|
||||||
|
# object-storage.env
|
||||||
|
AWS_ACCESS_KEY_ID=$s3_access_key
|
||||||
|
AWS_SECRET_ACCESS_KEY=$s3_secret_key
|
||||||
|
EOF
|
||||||
Executable
+148
@@ -0,0 +1,148 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Creates the installation-private CA (once) and the two server certificates the reference
|
||||||
|
# deployment needs (ADR 0044): `postgres` for the database and `object-storage` for SeaweedFS.
|
||||||
|
#
|
||||||
|
# sudo sh deploy/scripts/init-tls.sh /etc/ekklesia/tls
|
||||||
|
#
|
||||||
|
# Idempotent. An existing key or certificate is never overwritten: delete a file deliberately to
|
||||||
|
# reissue it. Ownership and modes are re-applied on every run, which needs root; a non-root run
|
||||||
|
# creates what is missing and then stops before that step, saying so. No private key is ever
|
||||||
|
# printed.
|
||||||
|
#
|
||||||
|
# Output (in the directory given):
|
||||||
|
# ca.key the CA's private key. Root only. Needed only to issue certificates; keep a
|
||||||
|
# copy offline and consider removing it from the server afterwards.
|
||||||
|
# ca.crt the CA certificate. Public. Mounted into api, migrate, and proxy.
|
||||||
|
# postgres.crt/.key for the name `postgres`; the key is owned by PostgreSQL's uid (70).
|
||||||
|
# object-storage.crt/.key for the name `object-storage`; the key is owned by SeaweedFS's uid (1000).
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: sh init-tls.sh <output directory>" >&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 <name>: a key and a certificate whose only subject alternative name is <name>, 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" <<EOF
|
||||||
|
basicConstraints=critical,CA:FALSE
|
||||||
|
keyUsage=critical,digitalSignature
|
||||||
|
extendedKeyUsage=serverAuth
|
||||||
|
subjectAltName=DNS:$name
|
||||||
|
subjectKeyIdentifier=hash
|
||||||
|
authorityKeyIdentifier=keyid
|
||||||
|
EOF
|
||||||
|
openssl req -new -sha256 -key "$out/$name.key" -subj "/CN=$name" -out "$request"
|
||||||
|
openssl x509 -req -sha256 -days "$SERVER_DAYS" -in "$request" \
|
||||||
|
-CA "$out/ca.crt" -CAkey "$out/ca.key" -CAcreateserial -CAserial "$out/ca.srl" \
|
||||||
|
-extfile "$extensions" -out "$out/$name.crt" 2>/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")"
|
||||||
@@ -48,7 +48,7 @@ One host, one Compose project, four long-running services on a private network:
|
|||||||
|
|
||||||
| Service | Role |
|
| 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. |
|
| `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. |
|
| `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. |
|
| `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
|
### TLS everywhere, without weakening ADR 0018
|
||||||
|
|
||||||
- **Browser-facing:** the proxy obtains publicly trusted certificates through ACME. The reference
|
- **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
|
configuration uses the HTTP-01 challenge on port 80; the DNS-01 challenge is documented for
|
||||||
that keep Ekklesia off the public Internet. A LAN-only installation can still hold a real
|
installations that keep Ekklesia off the public Internet. A LAN-only installation can still hold a
|
||||||
certificate for an internal hostname this way.
|
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
|
- **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
|
(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
|
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
|
certificate. With a token-managed tunnel, those origin settings (the service `https://proxy:443`,
|
||||||
connector's network identity. It overwrites the single forwarding header it sends to `api`, and
|
the origin server name, and TLS verification left on) live in the Cloudflare dashboard, not in the
|
||||||
`api` trusts only the proxy. An installation that serves local displays should give the same
|
repository; the deployment README documents them. The proxy accepts a tunnel-supplied
|
||||||
hostname a LAN path to the proxy (split-horizon DNS), so displays do not depend on the
|
client-address header **only** from the connector's network identity. It overwrites the single
|
||||||
Internet link.
|
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
|
- **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
|
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
|
(`EKKLESIA_DATABASE_CA`). The API connects with `verify-full`. No plaintext or encryption-only
|
||||||
exception is added to Core.
|
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
|
### Object storage
|
||||||
|
|
||||||
@@ -134,8 +144,9 @@ an installation is treated as in production.
|
|||||||
|
|
||||||
### Secrets
|
### Secrets
|
||||||
|
|
||||||
Secrets are supplied to Compose from a root-owned environment file on the host, never committed.
|
Secrets are supplied to Compose from root-owned environment files on the host, never committed:
|
||||||
This includes `EKKLESIA_IDENTITY_SECRET`, database and storage credentials, SMTP credentials, and
|
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.
|
`EKKLESIA_MODULE_SECRET__*` values. A secret manager is not required.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|||||||
@@ -463,11 +463,12 @@ Until that exists, the trusted-code model above is the security model.
|
|||||||
|
|
||||||
## Current deployment state
|
## Current deployment state
|
||||||
|
|
||||||
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no reverse
|
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no running
|
||||||
proxy, no public listener, and no deployment automation in this repository — no deploy script, no
|
reverse proxy, no public listener, and no deployment automation in this repository — no deploy
|
||||||
CI deployment step, no container publish, no orchestration manifest. The repository can **build** a
|
script, no CI deployment step, no container publish. The repository provides a **reference deployment**
|
||||||
production image ([`deploy/`](../../deploy/README.md)), and CI builds it without modules on every
|
([`deploy/`](../../deploy/README.md)): a production image and the Compose project that runs it, which
|
||||||
change, but nothing pushes, publishes, or runs it anywhere. No `.env` has ever been committed; only
|
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.
|
`.env.example`, which the image build context excludes.
|
||||||
|
|
||||||
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
|
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
|
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
|
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
|
built Dashboard itself at `/` ([API § Dashboard host](api.md#dashboard-host)), and the
|
||||||
does not yet supply the proxy deployment.
|
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.
|
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
|
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.
|
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
|
||||||
|
|||||||
+3
-1
@@ -14,7 +14,9 @@
|
|||||||
"authorization:bootstrap-owner": "turbo run build && node --env-file-if-exists=.env core/dist/cli/bootstrap-platform-owner.js",
|
"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",
|
"boundaries": "node scripts/check-boundaries.ts",
|
||||||
"build": "turbo run build",
|
"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",
|
"doctor": "node scripts/run-doctor.ts",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "biome format .",
|
"format:check": "biome format .",
|
||||||
|
|||||||
Executable
+41
@@ -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" <<EOF_ENV
|
||||||
|
EKKLESIA_IMAGE=registry.example.org/ekklesia:0.0.0
|
||||||
|
EKKLESIA_ENV_DIR=${work}
|
||||||
|
EKKLESIA_HOSTNAME=ekklesia.example.org
|
||||||
|
EKKLESIA_STORAGE_HOSTNAME=storage.ekklesia.example.org
|
||||||
|
[email protected]
|
||||||
|
EOF_ENV
|
||||||
|
|
||||||
|
combinations=(
|
||||||
|
"compose.yaml"
|
||||||
|
"compose.yaml compose.proxy.yaml"
|
||||||
|
"compose.yaml compose.proxy.yaml compose.tunnel.yaml"
|
||||||
|
)
|
||||||
|
for combination in "${combinations[@]}"; do
|
||||||
|
arguments=()
|
||||||
|
for file in $combination; do
|
||||||
|
arguments+=(--file "$file")
|
||||||
|
done
|
||||||
|
if ! docker compose --env-file "$work/compose.env" "${arguments[@]}" --profile ops config --quiet; then
|
||||||
|
echo "Reference deployment Compose configuration is invalid: ${combination}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "valid: ${combination}"
|
||||||
|
done
|
||||||
@@ -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: "[email protected]", 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);
|
||||||
|
}
|
||||||
Executable
+348
@@ -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=<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 <path relative to work>: 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 <file> <key> <value>: 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" <<EOF
|
||||||
|
EKKLESIA_IMAGE=${image}
|
||||||
|
EKKLESIA_ENV_DIR=${work}/env
|
||||||
|
EKKLESIA_TLS_DIR=${daemon_work}/tls
|
||||||
|
EKKLESIA_HOSTNAME=${hostname}
|
||||||
|
EKKLESIA_STORAGE_HOSTNAME=${storage_hostname}
|
||||||
|
[email protected]
|
||||||
|
EKKLESIA_CADDYFILE=${daemon_work}/Caddyfile
|
||||||
|
EKKLESIA_PUBLISH_ADDRESS=127.0.0.1
|
||||||
|
EKKLESIA_HTTP_PORT=0
|
||||||
|
EKKLESIA_HTTPS_PORT=0
|
||||||
|
EKKLESIA_EDGE_SUBNET=${edge_subnet}
|
||||||
|
EKKLESIA_EDGE_DYNAMIC_RANGE=${edge_range}
|
||||||
|
EKKLESIA_PROXY_ADDRESS=${proxy_address}
|
||||||
|
EKKLESIA_TUNNEL_ADDRESS=${tunnel_address}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
step "Starting PostgreSQL and object storage"
|
||||||
|
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage >/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="[email protected]"
|
||||||
|
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 <description> <compose run arguments...>: 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."
|
||||||
@@ -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<string, string>;
|
||||||
|
healthcheck?: { disable?: boolean };
|
||||||
|
image?: string;
|
||||||
|
init?: boolean;
|
||||||
|
networks?: Record<string, { ipv4_address?: string } | null>;
|
||||||
|
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<string, ComposeService>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const deployDirectory = path.join(repositoryRoot, "deploy");
|
||||||
|
|
||||||
|
async function compose(file: string): Promise<ComposeDocument> {
|
||||||
|
// `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<string> {
|
||||||
|
return readFile(path.join(deployDirectory, file), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function envAssignments(source: string): Map<string, string> {
|
||||||
|
const assignments = new Map<string, string>();
|
||||||
|
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://<user>:<password>@postgres:5432/<database>",
|
||||||
|
);
|
||||||
|
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__<MODULE_ID>__<KEY>=");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user