# 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 /, 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/* # 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 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 } } } }