diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 00000000..ccdbe050 --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,84 @@ +# Same-origin reverse proxy for the browser-native uniweb chat client. +# +# This is the self-contained fragment that exposes uniweb on magnus +# (organic-machine.com). It is merged into magnus's /etc/caddy/Caddyfile, which +# also hosts unrelated services; only this service's blocks are versioned here +# (the other vhosts carry basic-auth secrets that do not belong in git). The live +# file imports the shared (security_headers) snippet that is duplicated below so +# this fragment validates on its own. +# +# One origin fronts the whole app so the SPA and the bus share an origin: no CORS, +# and the unibus cluster node IPs stay hidden behind this proxy. Caddy obtains and +# renews the Let's Encrypt certificate automatically (the *.organic-machine.com +# wildcard A record points here). +# +# / -> the static SPA (uniweb web/dist) with a single-page-app fallback +# /api/* -> the signed HTTPS control plane (membershipd :8470), prefix stripped +# /nats -> the NATS-over-WebSocket data plane (:8485 magnus / :8480 peers) +# +# Upstreams speak TLS with the bus's own self-signed CA, so Caddy skips upstream +# verification (the hop is still encrypted). The control plane signs requests over +# the UNPREFIXED path, so /api MUST be stripped (handle_path) or signatures fail. +# +# The membershipd nodes must run with the same-origin host in --cors-origins (so +# the NATS WebSocket Origin check accepts it) and with --trusted-proxies naming +# this Caddy node (127.0.0.1,::1,135.125.201.30) so the per-IP rate limit keys on +# the real client behind the proxy instead of collapsing to the proxy's one IP. + +(security_headers) { + header { + Strict-Transport-Security "max-age=31536000" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "no-referrer" + -Server + } +} + +chat-c200aa64c3125ce8b5f068e0.organic-machine.com { + import security_headers + + # Control plane: strip /api so /api/rooms reaches membershipd as /rooms (the + # path the client signs). Prefer the local node; lb_try_duration retries the + # next node within the request on a dial error (safe: a refused connection sent + # no bytes, so even a POST cannot double-apply), and fail_duration plus the + # active /healthz check take a down node out of rotation. + handle_path /api/* { + reverse_proxy https://127.0.0.1:8470 https://141.94.69.66:8470 https://51.91.100.142:8470 { + transport http { + tls_insecure_skip_verify + } + lb_policy first + lb_try_duration 5s + lb_try_interval 250ms + fail_duration 10s + health_uri /healthz + health_interval 10s + health_timeout 5s + } + } + + # Data plane: NATS over WebSocket. Strip /nats so the upgrade reaches the ws + # listener at its root. Caddy proxies the WebSocket upgrade natively. The ws + # listener speaks TLS on :8485 (magnus; :8480 is taken by unibus_admin there) + # and :8480 on the peers. Passive failover only (an HTTP health probe would be + # rejected by the NATS ws endpoint). + handle_path /nats* { + reverse_proxy https://127.0.0.1:8485 https://141.94.69.66:8480 https://51.91.100.142:8480 { + transport http { + tls_insecure_skip_verify + } + lb_policy first + lb_try_duration 5s + lb_try_interval 250ms + fail_duration 30s + } + } + + # SPA: static files with a client-side-routing fallback to index.html. + handle { + root * /opt/uniweb/dist + try_files {path} /index.html + file_server + } +}