CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m17s
CI / image-build (pull_request) Successful in 1m10s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
The Dashboard had one address. The API served the page only at `/`, both reference proxies forwarded only `/`, and the open module lived in React state, so a refresh went back to the first module, nothing could be bookmarked, and Back left the Dashboard (DESIGN.md §2.7). ADR 0057 makes the address bar the source of truth: - `/` is a host-owned Overview, now the landing page; `/modules/<id>` mounts that module. An id the server's navigation does not list shows "This page isn't available" and loads nothing; any other Dashboard address is a host-owned "Page not found". - `/modules`, `/settings`, and `/account` are reserved whole, from one list (`@ekklesia/shell/dashboard-routes`) that the shell's router and the API's page routes both read. The API serves the same frozen page, ETag, and security headers anywhere inside them; look-alikes and other methods stay the sanitized 404. - A small host-owned history replaces the selected-module state, built in the entry point so it stays the only file touching `window`. Navigation items are real links; modified clicks are left to the browser. Focus moves to the page after an in-place change, and the tab is named after the page. - Sign-in still happens in place with no return value (ADR 0034), so a deep link opens its own page after sign-in. - Both reference Caddyfiles forward the new paths; the deploy smoke now reloads a module's address through the real proxy and requires it to mount again. Co-Authored-By: Claude Opus 5.5 <[email protected]>
155 lines
6.3 KiB
Caddyfile
155 lines
6.3 KiB
Caddyfile
# Ekklesia reference proxy configuration for the split topology (ADR 0045): the Dashboard and the API
|
|
# on separate hostnames of one site, for example dash.example.org and api.example.org. For the
|
|
# default same-origin topology use Caddyfile.example instead. Copy this file to deploy/Caddyfile.
|
|
#
|
|
# Hostnames, the ACME contact, and the tunnel connector's address come from the proxy service's
|
|
# environment (deploy/.env): EKKLESIA_DASHBOARD_HOSTNAME is the Dashboard's, EKKLESIA_HOSTNAME the
|
|
# API's (which also serves the display host), EKKLESIA_STORAGE_HOSTNAME the storage origin's. Only
|
|
# the two site blocks below differ from Caddyfile.example.
|
|
{
|
|
# ACME account contact. Certificates use the HTTP-01 challenge on port 80 by default, which needs
|
|
# all three 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
|
|
}
|
|
|
|
# The Dashboard: its page — at `/` and at every Dashboard page (/modules, /settings, and /account,
|
|
# each with everything below it), so a refreshed deep link stays put — and its one script, nothing
|
|
# else. Emailed verification and reset links land on `/` with a query. `/api/*` is refused here on
|
|
# purpose, so the session cookie has exactly one home, the API hostname, and the Dashboard can never
|
|
# fall back to a same-origin API.
|
|
{$EKKLESIA_DASHBOARD_HOSTNAME} {
|
|
@dashboard path / /assets/ekklesia-dashboard/* /modules /modules/* /settings /settings/* /account /account/*
|
|
|
|
handle @dashboard {
|
|
reverse_proxy api:3000 {
|
|
import ekklesia_forwarding
|
|
}
|
|
}
|
|
|
|
handle {
|
|
respond 404
|
|
}
|
|
}
|
|
|
|
# The API, and the display host on the API's own origin: a screen's pages, service worker, device
|
|
# cookies, and requests are all same-origin (ADR 0045). Nothing is served at `/`.
|
|
{$EKKLESIA_HOSTNAME} {
|
|
@api path /api/* /display/* /assets/ekklesia-display/*
|
|
|
|
# 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 @api {
|
|
# 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} {
|
|
# Delivery-layer CORS belongs to the deployment, not to the object store: this block decides it,
|
|
# so it stays the same with any S3 backend behind it. Only the origins whose pages read objects
|
|
# with fetch() get CORS: the API origin, where displays run, and the Dashboard origin,
|
|
# where a module's preview may cache images too. An <img> needs none. Every object
|
|
# URL is still a signed, expiring capability; CORS only decides which pages may read the bytes.
|
|
@cors_origin {
|
|
header Origin https://{$EKKLESIA_HOSTNAME}
|
|
header Origin https://{$EKKLESIA_DASHBOARD_HOSTNAME}
|
|
}
|
|
|
|
# A preflight is answered here and never reaches storage. Only GET and HEAD are ever allowed, and
|
|
# no request header beyond the CORS-safelisted ones.
|
|
@preflight method OPTIONS
|
|
handle @preflight {
|
|
header @cors_origin {
|
|
Access-Control-Allow-Origin {http.request.header.Origin}
|
|
Access-Control-Allow-Methods "GET, HEAD"
|
|
Access-Control-Max-Age 600
|
|
}
|
|
header Vary Origin
|
|
respond 204
|
|
}
|
|
|
|
handle {
|
|
# Set when the response is written, after the upstream's own CORS headers were removed below.
|
|
header @cors_origin >Access-Control-Allow-Origin {http.request.header.Origin}
|
|
header >Vary Origin
|
|
|
|
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
|
|
# The object store's own CORS answer (SeaweedFS reflects any origin, with every method)
|
|
# is not the deployment's policy.
|
|
header_down -Access-Control-Allow-Origin
|
|
header_down -Access-Control-Allow-Methods
|
|
header_down -Access-Control-Allow-Headers
|
|
header_down -Access-Control-Expose-Headers
|
|
header_down -Access-Control-Allow-Credentials
|
|
header_down -Access-Control-Max-Age
|
|
header_down -Vary
|
|
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
|
|
# HTTP/1.1 only. Over HTTP/2, SeaweedFS answers a conditional request with a 304 that
|
|
# declares a Content-Length it never sends; Caddy then aborts the browser's stream,
|
|
# which a browser reports as ERR_HTTP2_PROTOCOL_ERROR on every revalidation. Its
|
|
# HTTP/1.1 304 is clean. Browsers still reach this proxy over HTTP/2 and HTTP/3.
|
|
versions 1.1
|
|
}
|
|
}
|
|
}
|
|
}
|