From 6c8c754cb17edfe6704647019feed88c5c5e9508 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sun, 5 Jul 2026 22:56:34 -0400 Subject: [PATCH] =?UTF-8?q?docs(deploy):=20reverse-proxies.md=20=E2=80=94?= =?UTF-8?q?=20BYO=20nginx/HAProxy/Traefik=20tuned-timeout=20configs=20(sli?= =?UTF-8?q?ce-G)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The universal 60s idle-timeout footgun stated up front; the five-point contract any proxy must honor (incl. RUTSTER_TRUSTED_PROXIES + honest X-Forwarded-* for signature validation); complete snippets: nginx (explicitly not-recommended, snippet-only support), HAProxy (timeout tunnel in defaults per #2280; hard-stop-after warning), Traefik (pin the exact version — #10601 / v2.11.2 / #11405 WS-breaking patch history). Signed-off-by: Aaron D. Lee --- docs/deploy/reverse-proxies.md | 139 +++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/deploy/reverse-proxies.md diff --git a/docs/deploy/reverse-proxies.md b/docs/deploy/reverse-proxies.md new file mode 100644 index 0000000..5c05a3f --- /dev/null +++ b/docs/deploy/reverse-proxies.md @@ -0,0 +1,139 @@ +# Bring your own reverse proxy — tuned-timeout configs + +Supported: disable the `caddy` service in the compose stack +([quickstart-docker.md](quickstart-docker.md#t2--modular-compose-stack)); the engine keeps its +plaintext `:8080` listener. Everything below terminates TLS in *your* proxy and forwards to +the engine. + +## The universal 60-second footgun + +**nginx, HAProxy, and Traefik all default their idle/read timeouts to ~60–180 seconds — every +one of them will kill a quiet, perfectly healthy call WebSocket.** A Media Streams WS can +legitimately be near-silent in one direction for hours, and neither Twilio nor Telnyx sends +protocol-level keepalives. The engine ships app-level WS pings (`RUTSTER_WS_PING_SECS`, +default 20) as belt-and-braces, but your proxy timeouts must still exceed the maximum call +duration. Every snippet below does that. + +## The contract any proxy must honor + +1. **Publicly-trusted cert**, auto-renewed — no self-signed path exists + ([certificates.md](certificates.md)). +2. **WS upgrade with no frame buffering and no connection-lifetime cap** — 20 ms audio frames, + calls up to 24 h. +3. **Honest `X-Forwarded-Proto` and `X-Forwarded-Host`** (including on the WS upgrade + request): `X-Twilio-Signature` is HMAC over the URL as Twilio saw it + ([Twilio's SSL-termination guidance](https://www.twilio.com/en-us/blog/developers/tutorials/building-blocks/handle-ssl-termination-twilio-node-js-helper-library)). +4. **Engine-side trust**: set `RUTSTER_TRUSTED_PROXIES` to your proxy's source IP/CIDR — + forwarded headers from unlisted sources are ignored, and signature validation will fail. +5. **TLS 1.2+1.3, mainstream ECDHE — never 1.3-only** (Twilio's 1.3 client support is + undocumented). + +## nginx (works if you insist — not a recommended edge) + +Why not recommended: no native DNS-01/wildcard support, and cert renewal requires a reload +whose old workers linger against hours-long WS connections (research basis: [TLS brief §4](../superpowers/specs/2026-07-05-tls-edge-decision-brief.md)). rutster ships this snippet and +nothing more for nginx. + +```nginx +map $http_upgrade $connection_upgrade { default upgrade; '' close; } + +server { + listen 443 ssl; + server_name pbx.example.com; + ssl_certificate /etc/letsencrypt/live/pbx.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/pbx.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; # never 1.3-only + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_buffering off; # 20 ms frames must not be coalesced + proxy_read_timeout 86400s; # the 60s default is the footgun + proxy_send_timeout 86400s; + } +} +``` + +## HAProxy (3.2 LTS or newer) + +The best WS mental model of the classic family: after the upgrade, only `timeout tunnel` +governs the connection — **but only if you set it**; put it in `defaults` or it silently +doesn't apply to the paths you think it does (see +[haproxy#2280](https://github.com/haproxy/haproxy/issues/2280)). Two extra notes: `acme.sh` +can hot-load renewed certs through the stats socket with zero reload; and `hard-stop-after` +kills calls that are still draining — leave it unset or above your drain deadline. + +```haproxy +defaults + mode http + timeout connect 5s + timeout client 75s + timeout server 75s + timeout tunnel 24h # governs upgraded WS; unset = the 60s-class footgun + +frontend fe_rutster + bind :443 ssl crt /etc/haproxy/certs/ ssl-min-ver TLSv1.2 + http-request set-header X-Forwarded-Proto https + http-request set-header X-Forwarded-Host %[req.hdr(Host)] + default_backend be_rutster + +backend be_rutster + server fob 127.0.0.1:8080 +``` + +## Traefik v3 — PIN THE EXACT VERSION + +Traefik has the best built-in DNS-01/wildcard of the classic family **and a track record of +breaking WebSockets in patch releases**: +[#10601](https://github.com/traefik/traefik/issues/10601), the v2.11.2 timeout-behavior flip, +and [#11405](https://github.com/traefik/traefik/issues/11405). **Pin the exact image tag and +never auto-update the edge.** + +```yaml +# traefik.yml (static config) +entryPoints: + websecure: + address: ":443" + transport: + respondingTimeouts: + readTimeout: 0 # 0 disables; the 60s-class default kills quiet WS + idleTimeout: 0 + writeTimeout: 0 +certificatesResolvers: + le: + acme: + email: you@example.com + storage: /data/acme.json # persist this volume — certificates.md + dnsChallenge: + provider: cloudflare +``` + +```yaml +# dynamic config +http: + routers: + rutster: + rule: "Host(`pbx.example.com`)" + entryPoints: [websecure] + service: rutster + tls: { certResolver: le } + services: + rutster: + loadBalancer: + servers: + - url: "http://127.0.0.1:8080" +``` + +Traefik forwards `X-Forwarded-Proto/Host` by default — you still must list its address in +`RUTSTER_TRUSTED_PROXIES`. + +## First call + +Any of the above in front of the engine, then the same two first-call paths as +[quickstart-docker.md](quickstart-docker.md): browser at `https://pbx.example.com/`, Twilio +webhook at `https://pbx.example.com/v1/trunk/webhook`.