mirror of
https://github.com/Heretek-AI/AetherTable.git
synced 2026-10-12 02:02:28 +00:00
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:
@@ -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:/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
Vendored
+24
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user