# Voice networking: TURN fallback (LF-019) LobbyForge voice runs on **LiveKit, an SFU (Selective Forwarding Unit)** — every client's media ALWAYS flows through the LiveKit server, which receives N upstream streams and redistributes ~N×(N−1) downstream copies. There is no client-to-client P2P mesh; server bandwidth is consumed even without TURN. TURN is an *additional* relay for the minority whose direct client→SFU ICE path fails (symmetric NATs, mobile carriers, corporate networks that block UDP). The production stack ships a standalone **coturn** container for exactly that case. **Capacity implication:** in an N-person room where everyone listens to everyone, expect roughly N uplink + N×(N−1) downlink audio streams on the SFU. Screen-share/video multiplies this. Measure with real devices before opening a public server (LF-028). ## How the pieces fit ``` client ── ICE: client-to-SFU direct ──► LiveKit SFU (udp 50000-60000) │ (all media relays THROUGH the SFU either way) │ └── direct path fails ──► coturn TURN (3478, relay 49160-49200/udp) ▲ The web app mints a per-user, time-limited TURN credential with each LiveKit token (VOICE-001) and the client applies it via rtcConfig.iceServers. ``` - `infra/turn/turnserver.conf.template` — coturn config in REST-auth mode (`use-auth-secret` + `static-auth-secret`); rendered per install with your domain + secret (git-ignored output). - `apps/web/lib/turn-credentials.ts` — derives the coturn REST-auth credential pair: `username = ${unixExpiry}:${userId}`, `credential = base64(HMAC-SHA1(secret, username))`. The token endpoint (`/api/livekit/token`) ships one alongside every LiveKit token (12 h, independent of the 10-minute LiveKit token TTL). - `infra/livekit/livekit.yaml.template` — NO `turn_servers` entries: LiveKit's config only accepts a STATIC credential, which used to hand every community member one permanent relay credential. Do not re-add it. - `infra/docker/docker-compose.prod.yml` — the `turn` service (`coturn/coturn:4.17.2`, pinned), host networking so the UDP relay range binds directly. - `scripts/render-configs.sh` — renders the configs; the secret is validated as 32-128 hex chars so it can never inject config syntax. LiveKit's built-in `turn:` block is intentionally NOT used — it provisions its own certificates and would conflict with the stack's certbot. The standalone coturn shares the Let's Encrypt certificate via the certbot volume. `install.sh` generates `LOBBYFORGE_TURN_SECRET` once, stores it in `.env.prod`, and re-uses it on re-runs (secret rotation per re-run would break issued TURN credentials for no benefit). ## Firewall checklist | Port | Protocol | Purpose | |---|---|---| | 80, 443 | tcp | web (nginx, HTTPS, WSS signaling) | | 7881 | tcp | LiveKit ICE/TCP | | ~~7880~~ | — | NOT public: LiveKit HTTP signaling is nginx-proxied at `wss:///livekit` (V5-005) | | 3478 | tcp + udp | TURN listening | | 5349 | tcp + udp | TURN/TLS (UDP-blocked networks) | | 49160-49200 | udp | TURN relay range | | 50000-60000 | udp | LiveKit RTC media | Example (ufw): ```bash ufw allow 80/tcp; ufw allow 443/tcp ufw allow 7881/tcp ufw allow 3478/tcp; ufw allow 3478/udp ufw allow 5349/tcp; ufw allow 5349/udp ufw allow 49160:49200/udp ufw allow 50000:60000/udp ``` Behind 1:1 NAT (cloud VM with a private interface), uncomment and set `external-ip=` in the rendered `infra/turn/turnserver.conf`. ## Manual network test matrix Automated CI cannot cover real-world NAT variety; run this matrix after deploying. In each environment, join a voice room from two clients and confirm bidirectional audio, then check the LiveKit participant connection quality in the room UI. | # | Client network | Expected path | Notes | |---|---|---|---| | 1 | Home fiber, no firewall | direct / STUN | baseline | | 2 | Same LAN, two devices | direct (host candidates) | | | 3 | Mobile LTE/5G | STUN, occasionally TURN | carrier CGNAT | | 4 | Corporate network w/ UDP blocked | TURN/TCP (5349 or 3478/tcp) | verify audio still flows | | 5 | Symmetric NAT (e.g. some hotel Wi-Fi) | TURN/UDP relay | hardest case | | 6 | VPN exit node | STUN via VPN egress | | **First, prove the relay is NOT open** (security-review INFRA-001). An unauthenticated Allocate must be refused with 401; coturn silently runs as an anonymous open relay when it cannot read its config, and voice still works in that state: ```bash node scripts/turn-auth-probe.mjs your.domain 3478 # exit 0: "answered 401 — authentication is enforced" # exit 2: "ACCEPTED an unauthenticated allocation — OPEN RELAY" → stop and # check `docker logs lobbyforge-turn` for "Cannot find config file" ``` The container starts as root (`user: "0:0"` in the prod compose, with every capability dropped except SETUID, SETGID, DAC_READ_SEARCH, KILL and the binary's own NET_BIND_SERVICE, plus `no-new-privileges`) so the 0600 config and the Let's Encrypt key are readable; `proc-user` / `proc-group` in the config drop turnserver to `nobody:nogroup` with an empty capability set after initialisation, and `infra/turn/cert-watcher.sh` refuses to start turnserver at all when the config is unreadable or lacks REST auth. Quick relay smoke checks from a laptop: ```bash # Derive a REST-auth credential with the stack secret, then allocate on # 3478/udp (expect "Allocate" success in coturn logs). coturn has NO # static user anymore — a raw -u/-w pair cannot be used directly. EXP=$(( $(date +%s) + 300 )); USER="$EXP:smoketest" PASS=$(printf '%s' "$USER" | openssl dgst -sha1 -hmac "$LOBBYFORGE_TURN_SECRET" -binary | base64) turnutils_uclient -u "$USER" -w "$PASS" -p 3478 -v your.domain # Listening ports are up nc -vz your.domain 3478 && nc -vz your.domain 5349 ``` If case 4/5 fails: check `docker logs lobbyforge-turn`, confirm the firewall range above, and verify the token endpoint returns `iceServers` (the client only falls back to coturn when it carries them — udp 3478, tcp 3478, tls 5349). ## Certificate lifecycle (V5-002/V5-003) - **nginx** watches the fullchain fingerprint and gracefully reloads on change (config-tested). - **coturn** cannot reload its TLS certificate in place — its watcher detects the renewal and restarts the process (a seconds-long TURN blip once per renewal window). - Behind 1:1 NAT, set `LOBBYFORGE_TURN_EXTERNAL_IP` (exported for install.sh on first run; stored in `.env.prod` and reused on re-runs) — it renders as coturn `external-ip`. - Relay targets in private/CGNAT space (10/8, 172.16/12, 192.168/16, 100.64/10) are denied — coturn runs in the host network namespace and a credential holder must not pivot into the Docker/VPC network. ## Scope limits — read before promising corporate-network support - **TURN/TLS on 5349** is advertised to clients and covers networks that block plain UDP/TCP but allow arbitrary outbound TLS ports. - **Networks that ONLY allow outbound 443/tcp** are NOT covered by this stack: nginx already terminates 443 on this host, so TURN/TLS cannot also bind there. Covering that case needs extra design — a second IP/domain for TURN, or L4 routing that splits 443 traffic between nginx and coturn. Do not claim "works behind any corporate firewall" until that exists. - The coturn healthcheck proves the 3478 listener is up; a real TURN allocation smoke test (credentials + egress) is the manual `turnutils_uclient` step above. - ~~V4-007 (known debt): all clients share one static TURN credential.~~ FIXED (VOICE-001): coturn runs in REST-auth mode; every voice client receives a per-user credential that expires with its LiveKit token, and coturn's `user-quota` counts allocations per user again.