docs(deploy): reverse-proxies.md — BYO nginx/HAProxy/Traefik tuned-timeout configs (slice-G)
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 <himself@adlee.work>
This commit is contained in:
139
docs/deploy/reverse-proxies.md
Normal file
139
docs/deploy/reverse-proxies.md
Normal file
@@ -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`.
|
||||||
Reference in New Issue
Block a user