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:
+23
-8
@@ -6,7 +6,7 @@ import { SHARE, SHARE_DATA_ID, encodeShare, ShareAssembler, type ShareType } fro
|
||||
import type { User } from '../core/client.ts';
|
||||
import type { Session } from './session.svelte.ts';
|
||||
import { desktop, isWeb } from './native.ts';
|
||||
import { proxyStun } from './web.svelte.ts';
|
||||
import { proxyIce } from './web.svelte.ts';
|
||||
import { store } from './settings.svelte.ts';
|
||||
import { ui } from './ui.svelte.ts';
|
||||
import { sounds } from './audio/sounds.svelte.ts';
|
||||
@@ -55,12 +55,23 @@ class ScreenShare {
|
||||
return !!s.client && s.client.serverVersionNum >= 0x010400 && typeof RTCPeerConnection !== 'undefined';
|
||||
}
|
||||
|
||||
private iceServers(): RTCIceServer[] {
|
||||
private async iceServers(): Promise<RTCIceServer[]> {
|
||||
const stun = store.settings.stunServer.trim();
|
||||
if (stun) return [{ urls: /^stuns?:/.test(stun) ? stun : `stun:${stun}` }];
|
||||
// The browser build falls back to the proxy it is served from
|
||||
const own = isWeb ? proxyStun() : null;
|
||||
return own ? [{ urls: own }] : [];
|
||||
const chosen: RTCIceServer[] = stun ? [{ urls: /^stuns?:/.test(stun) ? stun : `stun:${stun}` }] : [];
|
||||
// The browser build also has the proxy it is served from: its STUN, and its relay for
|
||||
// networks and browsers that allow no direct connection
|
||||
return isWeb ? [...chosen, ...await proxyIce()] : chosen;
|
||||
}
|
||||
|
||||
// For testing the relay: localStorage mumh5.iceDebug = "relay" uses only relayed routes,
|
||||
// "relay-tcp" only the relay reached over TCP (what a browser without direct UDP is left with)
|
||||
private async rtcConfig(): Promise<RTCConfiguration> {
|
||||
const iceServers = await this.iceServers();
|
||||
let debug = '';
|
||||
try { debug = localStorage.getItem('mumh5.iceDebug') ?? ''; } catch { /* storage unavailable */ }
|
||||
if (!debug.startsWith('relay')) return { iceServers };
|
||||
const tcpOnly = (urls: string | string[]) => [urls].flat().filter(u => !u.startsWith('turn') || u.includes('transport=tcp'));
|
||||
return { iceTransportPolicy: 'relay', iceServers: debug === 'relay-tcp' ? iceServers.map(s => ({ ...s, urls: tcpOnly(s.urls) })) : iceServers };
|
||||
}
|
||||
|
||||
private others(s: Session): number[] {
|
||||
@@ -198,9 +209,11 @@ class ScreenShare {
|
||||
private async offerTo(s: Session, viewer: number): Promise<void> {
|
||||
const stream = this.stream;
|
||||
if (!stream || this.host !== s) return;
|
||||
const config = await this.rtcConfig();
|
||||
if (this.stream !== stream || this.host !== s) return;
|
||||
this.dropPeer(viewer);
|
||||
if (this.peers.size >= MAX_VIEWERS) return;
|
||||
const pc = new RTCPeerConnection({ iceServers: this.iceServers() });
|
||||
const pc = new RTCPeerConnection(config);
|
||||
this.peers.set(viewer, pc);
|
||||
this.viewers = this.peers.size;
|
||||
pc.addEventListener('connectionstatechange', () => {
|
||||
@@ -227,8 +240,10 @@ class ScreenShare {
|
||||
private async answerTo(s: Session, sharer: number, sdp: string): Promise<void> {
|
||||
const w = this.watching;
|
||||
if (!w || w.host !== s || w.session !== sharer) return;
|
||||
const config = await this.rtcConfig();
|
||||
if (this.watching?.host !== s || this.watching.session !== sharer) return;
|
||||
this.watchPc?.close();
|
||||
const pc = new RTCPeerConnection({ iceServers: this.iceServers() });
|
||||
const pc = new RTCPeerConnection(config);
|
||||
this.watchPc = pc;
|
||||
// The track is announced with the description, before any route exists: the stream only
|
||||
// counts as live once the connection is up, otherwise the viewer stares at a black picture
|
||||
|
||||
+24
-5
@@ -137,15 +137,18 @@ class ProxyInfo {
|
||||
any = $state(false);
|
||||
// UDP port of the proxy's STUN responder, if it runs one
|
||||
stun = $state<number | null>(null);
|
||||
// Whether it also relays streams for browsers that cannot connect directly
|
||||
turn = $state(false);
|
||||
loaded = $state(false);
|
||||
error = $state('');
|
||||
|
||||
async load(): Promise<void> {
|
||||
try {
|
||||
const c = await call<{ servers: ProxyServer[]; any: boolean; stun?: number | null }>('config');
|
||||
const c = await call<{ servers: ProxyServer[]; any: boolean; stun?: number | null; turn?: boolean }>('config');
|
||||
this.servers = c.servers;
|
||||
this.any = c.any;
|
||||
this.stun = c.stun ?? null;
|
||||
this.turn = !!c.turn;
|
||||
this.error = '';
|
||||
} catch (e) {
|
||||
this.error = (e as Error).message;
|
||||
@@ -155,8 +158,24 @@ class ProxyInfo {
|
||||
}
|
||||
export const proxyInfo = new ProxyInfo();
|
||||
|
||||
// The proxy's own STUN address: a host the user already uses, so nothing new is contacted
|
||||
export function proxyStun(): string | null {
|
||||
if (!proxyInfo.stun || !base) return null;
|
||||
try { return `stun:${new URL(base).hostname}:${proxyInfo.stun}`; } catch { return null; }
|
||||
// STUN and relay on the proxy itself: a host the user already uses, so nothing new is contacted.
|
||||
// The relay needs credentials, which the proxy hands out for a few hours at a time.
|
||||
let relay: { servers: RTCIceServer[]; until: number } | null = null;
|
||||
export async function proxyIce(): Promise<RTCIceServer[]> {
|
||||
if (!proxyInfo.loaded) await proxyInfo.load();
|
||||
if (!proxyInfo.stun || !base) return [];
|
||||
let host: string;
|
||||
try { host = new URL(base).hostname; } catch { return []; }
|
||||
const stun: RTCIceServer = { urls: `stun:${host}:${proxyInfo.stun}` };
|
||||
if (!proxyInfo.turn) return [stun];
|
||||
if (relay && relay.until > Date.now()) return relay.servers;
|
||||
try {
|
||||
const c = await call<{ username: string; credential: string; ttl: number; port: number }>('turn', {});
|
||||
// UDP where it is allowed, TCP for networks and browsers that forbid it
|
||||
const servers = [stun, { urls: [`turn:${host}:${c.port}?transport=udp`, `turn:${host}:${c.port}?transport=tcp`], username: c.username, credential: c.credential }];
|
||||
relay = { servers, until: Date.now() + Math.max(60, c.ttl - 3600) * 1000 };
|
||||
return servers;
|
||||
} catch {
|
||||
return [stun];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`]);
|
||||
|
||||
Reference in New Issue
Block a user