From 4a9012f64cefd68e6318c639488695c6786a0600 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 5 Oct 2026 18:40:49 -0500 Subject: [PATCH] fix(deploy): give migrate the API's client-address settings, and say what Doctor checked Doctor checks the client-address configuration of the process it runs in, and operators run it as `migrate`. The proxy overlay set trusted-proxy mode on `api` only, so Doctor reported "Direct client-address mode" for an installation that trusts its proxy. - compose.proxy.yaml defines the three variables once, as a YAML anchor, and applies it to both `api` and `migrate`. Rendered with `docker compose config`, `api` is unchanged and `migrate` now matches it, keeping NODE_EXTRA_CA_CERTS from the base. - The example deploy/.env sets COMPOSE_FILE=compose.yaml:compose.proxy.yaml, as the instance already does. Plain `docker compose run --rm migrate ...` in the README then runs with the overlay; it used to read compose.yaml alone. - Every client-address check in Doctor now says it checked the environment Doctor runs with, and how to read the running API's own value (`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`, confirmed to work in the image). - The deploy README explains why operator commands need the same files as the stack. Co-Authored-By: Claude Opus 5.5 --- core/src/diagnostics/doctor.ts | 10 +++++++++- core/tests/doctor-report.test.ts | 1 + deploy/README.md | 8 ++++++++ deploy/compose.proxy.yaml | 19 +++++++++++++------ deploy/env/compose.env.example | 6 ++++++ 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts index cbff0fe..e2da93b 100644 --- a/core/src/diagnostics/doctor.ts +++ b/core/src/diagnostics/doctor.ts @@ -905,17 +905,25 @@ function databaseTransportCheck( } } +/** Doctor reads only its own process's environment, so it says so: run as the reference + * deployment's `migrate`, it is given the API's values by the proxy overlay, but run any other way + * it may be checking a configuration the API is not running. */ +const CLIENT_ADDRESS_SCOPE = + "Checked: the environment Doctor runs with. In the reference deployment `migrate` is given the API's client-address values; a running API's own value is shown by `docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`."; + function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck { try { const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER); if (config.mode === "direct") { return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [ "Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.", + CLIENT_ADDRESS_SCOPE, ]); } return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [ `Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`, "The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.", + CLIENT_ADDRESS_SCOPE, ]); } catch (error) { const code = @@ -926,7 +934,7 @@ function clientAddressCheck(input: ClientAddressConfigInput | undefined): Doctor "identity.client-address", "failure", "Client-address configuration invalid", - [`[${code}] Configured client-address values failed validation.`], + [`[${code}] Configured client-address values failed validation.`, CLIENT_ADDRESS_SCOPE], ); } } diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index 2f00dee..4113a44 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -117,6 +117,7 @@ Identity Identity runtime configuration (signing key, base URL, trusted origins) is not set. ✓ Direct client-address mode Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted. + Checked: the environment Doctor runs with. In the reference deployment \`migrate\` is given the API's client-address values; a running API's own value is shown by \`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE\`. Object storage ○ Object storage not configured diff --git a/deploy/README.md b/deploy/README.md index 779f9be..42c4618 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -440,6 +440,14 @@ for a development checkout, not for the image. `migrate` has `profiles: ["ops"]`, so `up` never starts it. It never restarts, has no health check, and its default command is the read-only plan. **Nothing applies migrations automatically.** +Run operator commands with the same Compose files as the stack. `COMPOSE_FILE` in `deploy/.env` +(set in [`env/compose.env.example`](env/compose.env.example)) does that for every plain +`docker compose` command below. It matters for Doctor in particular: Doctor checks the +client-address configuration of the process it runs in. The proxy overlay gives `migrate` the +API's trusted-proxy settings, so Doctor reports what the API runs. Without the overlay, Doctor +reports direct mode for an API that trusts its proxy. The running API's own value is shown by +`docker compose exec api printenv EKKLESIA_CLIENT_ADDRESS_MODE`. + | Task | Command (from `deploy/`) | | --- | --- | | Plan migrations (read-only) | `docker compose run --rm migrate` | diff --git a/deploy/compose.proxy.yaml b/deploy/compose.proxy.yaml index f015b0d..c0a75f5 100644 --- a/deploy/compose.proxy.yaml +++ b/deploy/compose.proxy.yaml @@ -13,6 +13,13 @@ # its lower half; the proxy (and the tunnel connector, if used) take addresses from the upper half, # which Docker never hands to another container. +# Exactly one trusted peer: this proxy. Set from the same variable that fixes the proxy's address, +# so the two cannot drift apart. These override any value in ekklesia.env. +x-ekklesia-client-address: &ekklesia-client-address + 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 + services: proxy: image: caddy:2.11.4-alpine@sha256:6aeddd44c3078b0f9a35206472a11420648a79c184603ef95957d0a20044cb2b @@ -59,12 +66,12 @@ services: 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 + environment: *ekklesia-client-address + + # Operator commands (migrations, Doctor) run as `migrate`. It gets the API's client-address + # configuration too, so that Doctor checks what the API actually runs rather than an empty one. + migrate: + environment: *ekklesia-client-address object-storage: networks: diff --git a/deploy/env/compose.env.example b/deploy/env/compose.env.example index fc7d462..2f9b094 100644 --- a/deploy/env/compose.env.example +++ b/deploy/env/compose.env.example @@ -2,6 +2,12 @@ # Compose reads automatically from this directory. Nothing here is a secret; secrets live in the # per-service files under EKKLESIA_ENV_DIR. +# Which Compose files make up this installation, so that every plain `docker compose …` command +# run from this directory, operator commands through `migrate` included, sees the same services and +# settings as the running stack. Add compose.tunnel.yaml if you use the tunnel connector. Without +# the proxy overlay here, Doctor checks a `migrate` that lacks the API's client-address settings. +COMPOSE_FILE=compose.yaml:compose.proxy.yaml + # The image to run, by registry, name, and tag (or digest). Required. EKKLESIA_IMAGE=