CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 43s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m12s
CI / image-build (pull_request) Successful in 1m0s
CI / deploy-smoke (same-origin) (pull_request) Successful in 56s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
Module pages move from `/modules/<id>` to `/<module-id>` (`/signage`), beside `/settings` and `/account`, so the implementation concept "module" never appears in a user-facing address. ADR 0057 is rewritten to match. - Core reserves the top-level names the platform actually serves today (`account`, `api`, `assets`, `display`, `settings`), and discovery refuses a module that declares one with `MODULE_ID_RESERVED`. That covers startup, `modules:validate`, and package verification. Tests in `apps/api` hold the set to every top-level route the API registers and every page the shell interprets. - The API serves the Dashboard page at `/<module-id>` and below only for modules discovered with a Dashboard UI, enabled or not: a disabled module's bookmark still opens the Dashboard, which says it is unavailable. An arbitrary root path such as `/definitely-not-a-real-module` stays the sanitized 404. - Both reference proxies forward every top-level path shaped like a module id and let the API decide; the split Dashboard hostname still refuses `/api`, `/display`, and `/assets`. Installing a module needs no proxy change. - `/<module-id>/<page>` is already served and forwarded, so a later module sub-page contract needs no routing or deployment change; the shell shows "Page not found" there until it exists. - The deploy smoke checks a module's address before install, while installed, while disabled, after removal, and after reinstall, plus unknown names and the old `/modules/…` form through the real proxy. Co-Authored-By: Claude Opus 5.5 <[email protected]>
140 lines
5.8 KiB
Caddyfile
140 lines
5.8 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 /, the API, display pages and their service
|
|
# worker, and the Dashboard and display host scripts.
|
|
@ekklesia path / /api/* /display/* /assets/*
|
|
|
|
# The Dashboard's other pages (ADR 0057): its own Settings and account pages, and each installed
|
|
# module's page at /<module-id> (/signage, and later /signage/timelines), so a refreshed deep link
|
|
# stays put. Module ids are installation-specific, so this forwards every top-level name shaped
|
|
# like one and the API decides: the Dashboard page where one exists, its sanitized 404 elsewhere.
|
|
# Any other path is refused here.
|
|
@dashboardPage path_regexp dashboardPage ^/[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:/.*)?$
|
|
|
|
# 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 @dashboardPage {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|