Files
Ekklesia/deploy/Caddyfile.example
T
ekklesiaandClaude Opus 5.5 9afe9376c4
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
feat(shell): give every Dashboard page a real URL
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]>
2026-10-14 08:11:02 -05:00

129 lines
5.4 KiB
Caddyfile

# 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 / and at its own pages (/modules, /settings, and
# /account, each with everything below it, so a refreshed deep link stays put), 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/* /modules /modules/* /settings /settings/* /account /account/*
# 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} {
# 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 Ekklesia origin, which serves the Dashboard and the displays. 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}
# 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
}
}
}
}