feat: support split Dashboard and API origins (ADR 0045)
CI / verify (pull_request) Successful in 51s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 17s
CI / deploy-smoke (same-origin) (pull_request) Successful in 48s
CI / deploy-smoke (split) (pull_request) Successful in 55s
CI / verify (pull_request) Successful in 51s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m49s
CI / image-build (pull_request) Successful in 17s
CI / deploy-smoke (same-origin) (pull_request) Successful in 48s
CI / deploy-smoke (split) (pull_request) Successful in 55s
An installation can now serve the Dashboard and the API on sibling hostnames (dash./api.) beside the default same-origin topology, from one image and with configuration alone. - The Dashboard learns its API origin at run time: the built page carries one empty <meta name="ekklesia-api-origin">, which the API fills with its identity base URL's origin at startup, before computing the page's ETag. The shell accepts only a canonical http(s) origin and otherwise fails closed without making a request. The build-time EKKLESIA_API_BASE_URL is gone from the bundle; the development server injects the same element. - The display host stays on the API's origin, so its device cookies, requests, and offline cache are unchanged. - The API trusts its own origin (trusted origins plus the identity base URL), as Better Auth already does, so the display page's mutations pass the origin check without operators listing it. - Doctor validates EKKLESIA_DASHBOARD_ORIGIN exactly as the API's startup does (it previously skipped it), reports the topology without printing hostnames, and warns when split hostnames are not siblings. - The module UI contract states the CORS header limit (safelisted headers plus Content-Type, safelisted response headers only). - Reference deployment: Caddyfile.split.example (Dashboard hostname serves / and its script; API hostname serves /api/*, /display/*, and the display script; each refuses the other's paths) and EKKLESIA_DASHBOARD_HOSTNAME in the proxy overlay and env examples. - The deploy smoke test runs either topology (CI runs both). The split run installs the module template as a test-only module, checks per-hostname routing and exact CORS, then drives a digest-pinned headless Chromium over the DevTools protocol, with no added dependency: sign-in on the Dashboard hostname, the session cookie host-only on the API hostname, the module UI imported cross-origin and its credentialed request succeeding, the display and its service worker on the API hostname, and sign-out. ADR 0045 is marked Accepted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
55accd0e65
commit
65908ac97c
+50
-5
@@ -10,7 +10,8 @@ specific to one organization.
|
||||
| [`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`. |
|
||||
| [`Caddyfile.example`](Caddyfile.example) | The proxy's configuration for the default same-origin topology; copy to `Caddyfile`. |
|
||||
| [`Caddyfile.split.example`](Caddyfile.split.example) | The proxy's configuration when the Dashboard and the API have separate hostnames ([Split Dashboard and API origins](#split-dashboard-and-api-origins)); copy to `Caddyfile` instead. |
|
||||
| [`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. |
|
||||
@@ -142,6 +143,41 @@ image, because it was not built for the newer schema.
|
||||
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.
|
||||
|
||||
### Split Dashboard and API origins
|
||||
|
||||
By default one hostname serves the Dashboard (`/`), the API (`/api/*`), and displays (`/display/*`).
|
||||
An installation may instead give the Dashboard its own hostname, for example `dash.example.org`
|
||||
beside `api.example.org` ([ADR 0045](../docs/architecture/decisions/0045-split-dashboard-and-api-origins.md)).
|
||||
The same image serves both; only configuration differs.
|
||||
|
||||
| Hostname | Serves | Refuses (`404`) |
|
||||
| --- | --- | --- |
|
||||
| Dashboard (`EKKLESIA_DASHBOARD_HOSTNAME`) | `/` and `/assets/ekklesia-dashboard/*` | everything else, including `/api/*` |
|
||||
| API (`EKKLESIA_HOSTNAME`) | `/api/*`, `/display/*`, `/assets/ekklesia-display/*` | everything else, including `/` |
|
||||
| Storage (`EKKLESIA_STORAGE_HOSTNAME`) | signed object URLs, unchanged | |
|
||||
|
||||
Displays live on the API's hostname (`https://api.example.org/display/<module>`), because their
|
||||
device cookies are bound to it and their offline cache keeps only same-origin responses. That
|
||||
hostname is what every screen and paired device depends on: choose it once.
|
||||
|
||||
To run split:
|
||||
|
||||
1. Copy `Caddyfile.split.example` to `Caddyfile` instead of `Caddyfile.example`.
|
||||
2. In `deploy/.env`, set `EKKLESIA_HOSTNAME` to the API's hostname and `EKKLESIA_DASHBOARD_HOSTNAME`
|
||||
to the Dashboard's.
|
||||
3. In `ekklesia.env`, set `EKKLESIA_IDENTITY_BASE_URL` to `https://` and the API's hostname,
|
||||
`EKKLESIA_DASHBOARD_ORIGIN` to `https://` and the Dashboard's, and
|
||||
`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` to the Dashboard's origin. The API always trusts its own
|
||||
origin, so it need not be listed.
|
||||
|
||||
**Both hostnames must belong to one registrable domain** (`dash.example.org` and `api.example.org`,
|
||||
not `dash.example.org` and `api.example.net`). The session cookie stays host-only on the API's
|
||||
hostname; browsers send it from the Dashboard only between origins of the same site. Doctor reports
|
||||
the topology and warns when the two hostnames are not siblings.
|
||||
|
||||
The Dashboard learns the API's origin at run time: the API writes its identity base URL into the page
|
||||
it serves (`<meta name="ekklesia-api-origin">`). Nothing about the topology is built into the image.
|
||||
|
||||
### Behind an existing reverse proxy
|
||||
|
||||
Run the base alone and attach `api` and `object-storage` to your proxy's network with an overlay of
|
||||
@@ -203,8 +239,9 @@ dashboard. No Cloudflare API is called from here.
|
||||
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.
|
||||
4. Add a public hostname for the storage hostname, the same way, with its own origin server name.
|
||||
In the [split topology](#split-dashboard-and-api-origins), add one for the Dashboard's hostname
|
||||
too: three in all, each 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.
|
||||
|
||||
@@ -232,7 +269,7 @@ 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. |
|
||||
| 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. In the split topology, `Caddyfile.split.example` gives each hostname exactly its own paths and the exact trusted-origin CORS of ADR 0045 covers the rest. |
|
||||
| 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`. |
|
||||
@@ -258,7 +295,15 @@ deployment:
|
||||
wrong host name;
|
||||
- Doctor.
|
||||
|
||||
CI runs it on every change.
|
||||
`EKKLESIA_SMOKE_TOPOLOGY=split` runs the [split topology](#split-dashboard-and-api-origins)
|
||||
instead: an image with the repository's module template installed as a test-only module, every
|
||||
hostname's served and refused paths, exact CORS for the Dashboard's origin and none for others,
|
||||
and then a real headless Chromium (pinned by digest, driven over the DevTools protocol) that signs
|
||||
in on the Dashboard's hostname, checks the session cookie lives only on the API's, mounts the
|
||||
module's UI from the API's origin and sees its own credentialed requests succeed, mounts its display
|
||||
on the API's hostname with its service worker, and signs out.
|
||||
|
||||
CI runs both topologies 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user