From 54ac6ab83f75480cf570e50c79682dc4e6211601 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 5 Oct 2026 18:38:29 -0500 Subject: [PATCH 1/2] test(deploy): Doctor must check the client-address configuration the API runs Behind the reference proxy the API runs in trusted-proxy mode, but compose.proxy.yaml gives those variables to `api` only. The documented Doctor command runs as `migrate`, so it validated an empty configuration and reported "Direct client-address mode" for an installation that trusts its proxy. That is what the first production installation saw. Fails today: - deploy.test.ts: the proxy overlay gives `migrate` no client-address environment; - doctor.test.ts: the check does not say whose configuration it read; - smoke-deploy.sh: Doctor through the proxy overlay does not report trusted-proxy mode (Docker). Co-Authored-By: Claude Opus 5.5 --- core/tests/doctor.test.ts | 15 +++++++++++++++ scripts/smoke-deploy.sh | 6 +++++- tests/deploy.test.ts | 5 +++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index 922ee83..32d5b92 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -958,6 +958,21 @@ describe("Ekklesia Doctor diagnostics", () => { expect(report.result).toBe("ready-with-warnings"); }); + it("says whose client-address configuration it checked: this process's, not a running API's", async () => { + for (const clientAddress of [ + undefined, + { header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["10.0.0.1"] }, + { header: "x-forwarded-for", mode: "trusted-proxy" as const, trustedProxies: ["bad"] }, + ]) { + const report = await diagnoseEkklesia( + { ...options(), ...(clientAddress ? { clientAddress } : {}) }, + dependencies(), + ); + const details = check(report, "identity.client-address").details; + expect(details.some((detail) => detail.includes("environment Doctor runs with"))).toBe(true); + } + }); + it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => { const report = await diagnoseEkklesia( { diff --git a/scripts/smoke-deploy.sh b/scripts/smoke-deploy.sh index 170370b..8ac81cc 100755 --- a/scripts/smoke-deploy.sh +++ b/scripts/smoke-deploy.sh @@ -555,12 +555,16 @@ grep -q 'TLS required with certificate and hostname verification (explicit priva <<<"$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." +# Doctor runs as `migrate`; it must check the client-address configuration the API runs with, which +# behind the proxy is trusted-proxy mode, not the direct mode an unconfigured process reports. +grep -q 'Trusted-proxy client-address mode' <<<"$doctor_output" || + fail "Doctor did not check the API's trusted-proxy client-address configuration." 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" +echo "ok Doctor: READY, verify-full with the private CA, storage reachable, the API's client-address mode, 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 diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 8dcfe6a..d072e9d 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -153,6 +153,11 @@ describe("reference deployment compose files", () => { EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy", EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: `${proxyAddress}/32`, }); + // Doctor runs as `migrate`. Given anything else, it validates a configuration the API is not + // running, and reports "Direct" for an installation that trusts its proxy. + expect(proxyOverlay.services?.migrate?.environment).toEqual( + proxyOverlay.services?.api?.environment, + ); expect(proxyOverlay.networks?.edge?.ipam?.config).toEqual([ { ip_range: "${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}", -- 2.54.0 From f1d6501239632a5a29b5be74274e2690793f74b2 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 5 Oct 2026 18:40:49 -0500 Subject: [PATCH 2/2] 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= -- 2.54.0