Add a stream relay (TURN) to the proxy for browsers that cannot connect directly

Vanadium forbids direct UDP for WebRTC, and mobile and company networks often
block direct connections; the only route then is a relay reached over TCP.

- server/turn.ts: STUN and TURN on one port, over UDP and TCP, with
  short-lived credentials from /api/turn, quotas and a peer filter
- The browser build fetches credentials and offers the relay automatically
- Stats for nerds says when a stream is relayed and how the relay is reached
- Tests: a TURN client over UDP and TCP, and a browser limited to the relay
  over TCP in the web E2E

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 23:30:09 +02:00
co-authored by Claude Opus 5.5
parent 98e44d7082
commit 08aa5268d0
13 changed files with 720 additions and 85 deletions
+11 -1
View File
@@ -64,7 +64,17 @@
const pair = (transport?.selectedCandidatePairId && report.get(transport.selectedCandidatePairId)) || all.find(s => s.type === 'candidate-pair' && s.nominated && s.state === 'succeeded');
if (pair) {
const local = report.get(pair.localCandidateId), remote = report.get(pair.remoteCandidateId);
rows.push(['Connection', `${KIND[local?.candidateType] ?? local?.candidateType ?? 'n/a'}, ${String(local?.protocol ?? '').toUpperCase()}`]);
// What this side had to offer. A route discovered during the checks (prflx) is still a
// relayed one when relays were all we had.
const mine = all.filter(s => s.type === 'local-candidate' && s.candidateType !== 'prflx');
const relays = mine.filter(s => s.candidateType === 'relay');
const relayed = local?.candidateType === 'relay' || (local?.candidateType === 'prflx' && relays.length > 0 && relays.length === mine.length);
const relayVia = (local?.relayProtocol ?? relays[0]?.relayProtocol) as string | undefined;
// A relayed route is UDP beyond the relay; what matters is how we reach the relay
rows.push(['Connection', relayed ? `${KIND.relay}${relayVia ? `, reached over ${relayVia.toUpperCase()}` : ''}`
: `${KIND[local?.candidateType] ?? local?.candidateType ?? 'n/a'}, ${String(local?.protocol ?? '').toUpperCase()}`]);
const count = (type: string) => mine.filter(s => s.candidateType === type).length;
rows.push(['Routes offered', `${count('host')} direct, ${count('srflx')} through STUN, ${relays.length} relayed`]);
if (remote?.candidateType) rows.push(['Other side', KIND[remote.candidateType] ?? remote.candidateType]);
rows.push(['Round trip', ms(pair.currentRoundTripTime)]);
if (pair.availableOutgoingBitrate) rows.push(['Estimated capacity', `${Math.round(pair.availableOutgoingBitrate / 1000)} kbit/s`]);