CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 20s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / deploy-smoke (split) (pull_request) Successful in 1m6s
Every reload of a cached files. image failed in the browser with ERR_HTTP2_PROTOCOL_ERROR. A browser revalidates a cached signed URL with If-None-Match. Caddy reached SeaweedFS over HTTP/2, and there SeaweedFS answers with a 304 declaring content-type: application/xml and content-length: 327 while sending no body. Caddy then logged "aborting with incomplete response ... unexpected EOF" and reset the stream. Over HTTP/1.1 SeaweedFS's 304 is clean. Measured layer by layer: DevTools, curl through Caddy, and curl direct over HTTP/1.1 and HTTP/2. The storage upstream is now HTTP/1.1 only (`versions 1.1`). Browsers still reach the proxy over HTTP/2 and HTTP/3. Delivery-layer CORS is the deployment's, so it no longer depends on which S3 backend is behind it. SeaweedFS's default reflected any origin and advertised PUT, POST and DELETE. Now the proxy: - grants CORS only to the origins that read objects with fetch(): the Ekklesia origin, or, when split, the API origin (displays) and the Dashboard origin (a module's preview caches images through fetch()); - allows GET and HEAD only, with Vary: Origin; - answers preflights itself, so they never reach storage; - strips the object store's own CORS headers. The deploy README documents the policy, its reasons, and what a self-run proxy must do. Co-Authored-By: Claude Opus 5.5 <[email protected]>
153 lines
6.1 KiB
Caddyfile
153 lines
6.1 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 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/*
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|