feat(infra): coturn TURN service + PeerJS ICE plumbing (iteration 26)

coturn 4.17.2-alpine with static-auth-secret mode (secret via env, never
in client bundles), STUN+TURN on 3478, relay range 49160-49200. Client
buildIceServers(): public STUN default, TURN appended only when all
VITE_TURN_* vars set; typed env + compose build args. Honest limits
documented: NAT traversal itself unverifiable locally; TLS/DTLS deferred.
9 new tests; vitest 168/14.

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
John Smith
2026-08-25 01:32:30 -04:00
co-authored by Claude
parent 5551a321b7
commit c8cf376feb
6 changed files with 282 additions and 0 deletions
+11
View File
@@ -6,6 +6,17 @@ FROM node:22-alpine as builder
WORKDIR /app
# ICE-server plumbing for the WebRTC mesh (client/src/render/webrtc_mesh.ts).
# Declared so `docker compose build vtt-client` build args actually reach
# Vite as environment variables. Empty by default => STUN-only bundle.
# No hardcoded credentials anywhere; values come from compose/host env.
ARG VITE_PEERJS_URL=http://localhost:9000
ARG VITE_PEERJS_ROOM=aethertable-live
ARG VITE_YSYNC_WS_URL=ws://localhost:6380
ARG VITE_TURN_URL
ARG VITE_TURN_USERNAME
ARG VITE_TURN_CREDENTIAL
COPY client/package*.json ./
RUN npm install
@@ -0,0 +1,55 @@
/**
* Unit coverage for the ICE plumbing added in iteration 26 (TURN/STUN gap).
*
* Scope honestly bounded: this suite proves the iceServers ARRAY that reaches
* the Peer constructor is built correctly from VITE_TURN_* inputs. It cannot
* prove NAT traversal end-to-end — that requires two distinct networks and a
* live coturn (see docker-compose.yml `vtt-coturn`). What it CAN prove:
* - STUN is always present (PeerJS's own default had exactly one, ours does
* too, explicitly),
* - TURN is appended ONLY when url+username+credential arrive TOGETHER,
* - no credential material is ever synthesized or defaulted in code.
*/
import { describe, expect, it } from 'vitest';
import { buildIceServers } from '../webrtc_mesh';
describe('buildIceServers', () => {
it('always includes public STUN', () => {
const servers = buildIceServers();
expect(servers).toEqual([{ urls: 'stun:stun.l.google.com:19302' }]);
});
it('appends a TURN entry only with a complete credential triple', () => {
const servers = buildIceServers(
'turn:turn.example.com:3478?transport=udp',
'1756100000:alice',
'base64hmac=='
);
expect(servers).toHaveLength(2);
expect(servers[1]).toEqual({
urls: 'turn:turn.example.com:3478?transport=udp',
username: '1756100000:alice',
credential: 'base64hmac==',
});
});
it.each([
['missing url', undefined, 'user', 'cred'],
['missing username', 'turn:t.example.com:3478', undefined, 'cred'],
['missing credential', 'turn:t.example.com:3478', 'user', undefined],
['empty-string url', '', 'user', 'cred'],
['empty-string username', 'turn:t.example.com:3478', '', 'cred'],
['empty-string credential', 'turn:t.example.com:3478', 'user', ''],
])('degrades to STUN-only when %s', (_label, url, user, cred) => {
expect(buildIceServers(url, user, cred)).toEqual([{ urls: 'stun:stun.l.google.com:19302' }]);
});
it('never fabricates credentials when nothing is configured', () => {
for (const s of buildIceServers(undefined, undefined, undefined)) {
expect(s.username).toBeUndefined();
expect(s.credential).toBeUndefined();
expect(String(s.urls)).not.toMatch(/^turn:/);
}
});
});
+48
View File
@@ -103,6 +103,50 @@ const SIGNALING_BASE = import.meta.env.VITE_PEERJS_URL ?? 'http://localhost:9000
const LOBBY_ID = import.meta.env.VITE_PEERJS_ROOM ?? 'aethertable-live';
const SIGNAING_KEY = 'aethertable';
const SIGNAING_PATH = '/peerjs';
/**
* ICE configuration (iteration 26 — closes the "no TURN/STUN config" gap).
*
* PeerJS's DEFAULT `config` ships exactly one public Google STUN server and
* no TURN at all, so peers behind symmetric NAT could never complete ICE and
* the mesh just reported honest failures forever. We now build the ICE list:
* - public STUN is always present (harmless, free server-reflexive hints);
* - a TURN hop is appended ONLY when all three of VITE_TURN_URL /
* VITE_TURN_USERNAME / VITE_TURN_CREDENTIAL are provided at build time.
* Credentials are never hardcoded and never defaulted: an incomplete or
* missing TURN setup degrades to STUN-only, which keeps the honest-failure
* contract intact (a broken TURN candidate simply fails to allocate and ICE
* reports real failure through onStatus — there is no fake success path).
*/
const PUBLIC_STUN_SERVERS: RTCIceServer[] = [{ urls: 'stun:stun.l.google.com:19302' }];
export interface IceServerConfig {
iceServers: RTCIceServer[];
}
/**
* Builds the `config.iceServers` array handed to the RTCPeerConnection via
* the Peer constructor. Pure + exported for unit coverage; no network I/O.
* TURN entries require URL + username + credential TOGETHER — anything less
* is treated as "no TURN configured" rather than half-configured guesswork.
*/
export function buildIceServers(
turnUrl?: string,
turnUsername?: string,
turnCredential?: string
): RTCIceServer[] {
const servers: RTCIceServer[] = [...PUBLIC_STUN_SERVERS];
if (turnUrl && turnUsername && turnCredential) {
servers.push({ urls: turnUrl, username: turnUsername, credential: turnCredential });
}
return servers;
}
const ICE_SERVERS = buildIceServers(
import.meta.env.VITE_TURN_URL,
import.meta.env.VITE_TURN_USERNAME,
import.meta.env.VITE_TURN_CREDENTIAL
);
/** Roster refresh cadence against the signaling server's discovery endpoint. */
const ROSTER_POLL_MS = 5000;
/** Mic RMS threshold matching the previous voice-detection behaviour. */
@@ -229,6 +273,10 @@ export class WebRTCMeshManager {
path: `${SIGNAING_PATH}/`,
key: SIGNAING_KEY,
debug: 1,
// ICE servers reach the underlying RTCPeerConnection here (peerjs
// ~1.5.x `config` option). STUN-only by default; TURN appended when
// VITE_TURN_* build args are provided.
config: { iceServers: ICE_SERVERS },
});
this.peer.on('open', (id: string) => {
+24
View File
@@ -37,6 +37,30 @@ interface ImportMetaEnv {
* (/ws/sessions/{id}/sync). Only used when the CRDT relay is unreachable.
*/
readonly VITE_ENGINE_WS_URL?: string;
/**
* TURN server URL for the WebRTC mesh ICE configuration, e.g.
* `turn:turn.example.com:3478?transport=udp`. Empty/unset ⇒ the mesh ships
* public STUN only and peers behind symmetric NAT keep failing honestly
* (see webrtc_mesh.ts — no fake-success path exists). Baked at build time
* by Vite; pair with VITE_TURN_USERNAME / VITE_TURN_CREDENTIAL.
*/
readonly VITE_TURN_URL?: string;
/**
* TURN username. For a static-auth-secret coturn deploy this must be the
* REST-API-shaped `"<unix-expiry-timestamp>:<userid>"` combo, NOT a fixed
* account name. Never hardcode credentials here or in code.
*/
readonly VITE_TURN_USERNAME?: string;
/**
* TURN credential: for static-auth-secret coturn this is
* base64(hmac_sha1(secret, username)) minted out-of-band from AUTH_SECRET
* / COTURN_TURN_SECRET. Time-limited by construction; regenerate per
* deployment rather than baking a long-lived value.
*/
readonly VITE_TURN_CREDENTIAL?: string;
}
interface ImportMeta {
+76
View File
@@ -0,0 +1,76 @@
# =============================================================================
# Minimal coturn (TURN/STUN) config for the self-hosted AetherTable stack.
#
# Mounted read-only by the `vtt-coturn` service in docker-compose.yml at
# /etc/coturn/turnserver.conf — the coturn/coturn image's entrypoint passes
# `-c /etc/coturn/turnserver.conf` by default.
#
# Auth mode choice: static-auth-secret (TURN REST API / time-limited
# credentials, draft-uberti-behave-turn-rest) instead of long-term
# credentials (`lt-cred-mech` + `user=...`). Rationale:
# - Long-term creds require a fixed username/password baked into every
# client bundle; anyone who reads it owns our relay bandwidth forever.
# - With use-auth-secret, the server only holds ONE secret (AUTH_SECRET,
# reused from the rest of the compose stack for self-hosted simplicity)
# and clients present short-lived HMAC credentials minted per session.
# - The client currently takes VITE_TURN_USERNAME/VITE_TURN_CREDENTIAL at
# build time (no credential-minting endpoint exists yet); that means the
# deployed client must be given a REST-API-shaped credential such as
# `username=<unix-ts>:<user>` / `credential=base64(hmac(secret, username))`
# generated out-of-band with:
# secret=...; user="$(date +%s):user"; \
# cred=$(printf '%s' "$user" | openssl dgst -binary -sha1 -hmac "$secret" | base64)
# This is still strictly better than a never-expiring shared password and
# keeps the door open to minting credentials server-side later.
#
# NOT configured here (documented gaps, not accidents):
# - TLS/DTLS (tls-listening-port 5349): needs certs; turn: URLs over plain
# 3478 are fine on a trusted/self-hosted network but media stays
# unencrypted toward the TURN server in TCP-relay fallbacks.
# - external-ip: required when this host sits behind NAT. Set it via env
# (COTURN_EXTERNAL_IP) in compose or edit here before deploying publicly;
# without it, allocations advertise the container's private IP and remote
# peers cannot reach the relay.
# =============================================================================
# STUN + TURN listener on both UDP and TCP (RFC 5766 / RFC 8489).
listening-port=3478
# Bind inside the container to all interfaces (compose bridge net).
listening-ip=0.0.0.0
relay-ip=0.0.0.0
# Relay port window. Deliberately narrow (49160-49200 = 41 ports ≈ 40
# concurrent relayed sessions) and matched 1:1 by the compose `ports:` block
# so only this range is published to the host.
min-port=49160
max-port=49200
# TURN REST API / static-auth-secret mode. Implies lt-cred-mech internally
# (they are mutually exclusive as explicit options — do not add lt-cred-mech).
#
# The SECRET VALUE itself is NOT written here: docker-compose passes
# `--static-auth-secret=$COTURN_SECRET` on the command line (expanded inside
# the container by the image entrypoint from COTURN_TURN_SECRET/AUTH_SECRET),
# and coturn CLI flags take precedence over this file anyway. Keeping the
# secret out of version control is the whole point.
use-auth-secret
# Realm reported to clients; matches the 'aethertable' naming used across the
# stack (PeerJS key, lobby ids).
realm=aethertable
# Always include fingerprints in TURN messages (integrity check).
fingerprint
# Server can relay to loopback/multicast peers is OFF by default — keep it
# that way. Multicast peers explicitly disallowed as defense-in-depth.
no-multicast-peers
# Do not run as an open relay for arbitrary destinations: nothing beyond the
# default peer-ip filtering is needed for WebRTC (peers are ICE candidates).
no-loopback-peers
# Log to stdout so `docker compose logs vtt-coturn` works.
log-file=stdout
simple-log
+68
View File
@@ -204,10 +204,76 @@ services:
networks:
- vtt-net
vtt-coturn:
# STUN + TURN relay for the WebRTC video mesh (Pillar 9). Without a TURN
# hop, peers behind symmetric NAT can never complete ICE and the mesh
# reports honest 'signaling-down'/'connection failed' states instead of
# media — this service is the fix path, not a fake-success shim.
#
# Auth: static-auth-secret (TURN REST API / time-limited HMAC creds) —
# see the full rationale in database/coturn/turnserver.conf. The secret is
# AUTH_SECRET, reused from the rest of this self-hosted stack so there is
# exactly ONE secret to rotate. Override with COTURN_SECRET if you must
# split it.
image: coturn/coturn:4.17.2-alpine
container_name: vtt-coturn
restart: unless-stopped
command:
- -c=/etc/coturn/turnserver.conf
- --log-file=stdout
# $$ = a literal $ after Compose interpolation; the coturn/coturn
# ENTRYPOINT bash-evals each argument at runtime, so $COTURN_SECRET is
# expanded INSIDE the container from `environment:` below — the secret
# never appears in the checked-in config file nor in the compose spec.
- --static-auth-secret=$${COTURN_SECRET}
# Auto-detect the host's public IP (needed when this host sits behind
# NAT so relay allocations advertise a reachable address). Honors
# REAL_EXTERNAL_IP if you need to pin it; fails the container fast if
# no DNS provider answers rather than advertising a broken address.
- --external-ip=$$(detect-external-ip)
environment:
COTURN_SECRET: ${COTURN_TURN_SECRET:-${AUTH_SECRET:?set AUTH_SECRET (openssl rand -hex 32)}}
# Optional override consumed by the image's detect-external-ip helper
# (pin the advertised public IP instead of DNS auto-detection).
REAL_EXTERNAL_IP: ${REAL_EXTERNAL_IP:-}
ports:
- "3478:3478/udp"
- "3478:3478/tcp"
# Relay window — must match min-port/max-port in turnserver.conf.
- "49160-49200:49160-49200/udp"
volumes:
- ./database/coturn:/etc/coturn:ro,z
healthcheck:
test:
[
"CMD-SHELL",
"turnutils_stunclient -p 3478 127.0.0.1 >/dev/null 2>&1 || exit 1",
]
interval: 15s
timeout: 5s
retries: 5
networks:
- vtt-net
vtt-client:
build:
context: .
dockerfile: Dockerfile.client
args:
# Vite bakes VITE_* vars at BUILD time — set these before
# `docker compose build vtt-client`. ICE servers for the WebRTC mesh
# (see client/src/render/webrtc_mesh.ts): unset/empty VITE_TURN_*
# ships public STUN only and symmetric-NAT pairs keep failing
# honestly (no fake success). When set, point at the `vtt-coturn`
# service above; credentials must be REST-API-shaped HMAC values
# minted from AUTH_SECRET / COTURN_TURN_SECRET. No hardcoded
# credentials anywhere.
VITE_PEERJS_URL: ${VITE_PEERJS_URL:-http://localhost:9000}
VITE_PEERJS_ROOM: ${VITE_PEERJS_ROOM:-aethertable-live}
VITE_YSYNC_WS_URL: ${VITE_YSYNC_WS_URL:-ws://localhost:6380}
VITE_TURN_URL: ${VITE_TURN_URL:-}
VITE_TURN_USERNAME: ${VITE_TURN_USERNAME:-}
VITE_TURN_CREDENTIAL: ${VITE_TURN_CREDENTIAL:-}
container_name: vtt-client
restart: unless-stopped
environment:
@@ -236,6 +302,8 @@ services:
condition: service_healthy
ysync-relay:
condition: service_healthy
vtt-coturn:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/ || exit 1"]
interval: 5s