// TURN relay support. // // Direct and STUN-assisted pairing fails for some participants (symmetric NAT // on both ends, corporate firewalls that only permit 443), and those pairs need // a relay. Relays cost bandwidth, so this one is gated behind a token: whoever // has it types it in on the landing form, their browser exchanges it for a // short-lived ICE configuration at the Worker in `worker/`, and that // configuration is then shared with the rest of the room over the control // channels (see network.ts). Nobody else needs the token, and the token itself // never leaves the browser it was typed into. // // Everything here is optional: with no endpoint configured at build time, or no // token entered by anyone in the room, calls fall back to STUN plus the free // public relay and behave exactly as they did before. /** Where to exchange a room token for TURN credentials (build-time config). */ export const TURN_ENDPOINT = (import.meta.env.VITE_TURN_ENDPOINT ?? '').trim() /** Whether this build has a credential endpoint at all. */ export const TURN_CONFIGURED = TURN_ENDPOINT.length > 0 /** An ICE configuration together with the moment its credentials stop working. * This is the unit that gets minted, cached, shared and refreshed. */ export interface IceConfig { iceServers: RTCIceServer[] /** Epoch ms; after this the TURN username/credential pair is dead. */ expiresAt: number } /** Where our relay configuration came from, for the status indicator. */ export type RelayStatus = /** No relay credentials: STUN (and the public fallback relay) only. */ | 'off' /** Minted with a token entered in this browser. */ | 'self' /** Received from another participant over the control channel. */ | 'shared' /** Plain STUN, always used. These only reveal a public address; they never * carry media, so there is nothing to gate behind a token. */ export const STUN_SERVERS: RTCIceServer[] = [ {urls: 'stun:stun.l.google.com:19302'}, {urls: 'stun:stun1.l.google.com:19302'}, {urls: 'stun:stun.cloudflare.com:3478'} ] /** What a peer with no credentials uses: STUN plus the free openrelay relay. * That relay is shared, rate-limited and frequently unavailable — it is a * last resort, not a substitute for a token. */ export const BASE_ICE_SERVERS: RTCIceServer[] = [ ...STUN_SERVERS, { urls: [ 'turn:openrelay.metered.ca:80', 'turn:openrelay.metered.ca:443', 'turns:openrelay.metered.ca:443' ], username: 'openrelayproject', credential: 'openrelayproject' } ] const FETCH_TIMEOUT_MS = 10000 /** Exchange a room token for a short-lived ICE configuration. * * `roomTag` is an opaque per-room identifier (a prefix of the hashed room * topic, never the room name) that the Worker forwards to Cloudflare as the * credential's customIdentifier, so relay usage can be attributed per room. * * Throws with a message suitable for display. */ export async function fetchIceConfig( token: string, roomTag: string ): Promise { if (!TURN_CONFIGURED) throw new Error('No relay endpoint is configured') const controller = new AbortController() const timer = window.setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) let res: Response try { res = await fetch(TURN_ENDPOINT, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({room: roomTag}), signal: controller.signal }) } catch (err) { throw new Error( (err as {name?: string})?.name === 'AbortError' ? 'the relay service did not respond' : 'the relay service could not be reached' ) } finally { clearTimeout(timer) } if (!res.ok) { const detail = await res .json() .then(b => (typeof (b as {error?: unknown})?.error === 'string' ? (b as {error: string}).error : '')) .catch(() => '') throw new Error(detail || `the relay service returned ${res.status}`) } const body = (await res.json().catch(() => null)) as { iceServers?: unknown expiresAt?: unknown } | null const iceServers = sanitizeIceServers(body?.iceServers) if (!iceServers) throw new Error('the relay service sent an unusable response') const expiresAt = typeof body?.expiresAt === 'number' && Number.isFinite(body.expiresAt) ? body.expiresAt : Date.now() + 3600_000 return {iceServers, expiresAt} } const MAX_SERVERS = 8 const MAX_URLS = 12 const MAX_CREDENTIAL_LENGTH = 512 // Only the four ICE schemes, and only characters that legitimately appear in // their URLs. Port 53 is excluded deliberately below. const URL_RE = /^(?:stuns?|turns?):[A-Za-z0-9._~-]+(?::\d{1,5})?(?:\?transport=(?:udp|tcp))?$/ /** Validate an ICE server list that arrived over the network. * * This runs on the Worker's response AND on configurations shared by other * participants, which is the case that matters: a room peer is not trusted, so * anything it sends is treated as untrusted input and reduced to a * well-formed, bounded list of ICE URLs before it can reach * RTCPeerConnection. Returns null if the value is unusable. */ export function sanitizeIceServers(value: unknown): RTCIceServer[] | null { if (!Array.isArray(value) || value.length === 0) return null const out: RTCIceServer[] = [] for (const raw of value.slice(0, MAX_SERVERS)) { if (typeof raw !== 'object' || raw === null) continue const entry = raw as {urls?: unknown; username?: unknown; credential?: unknown} const candidates = Array.isArray(entry.urls) ? entry.urls : typeof entry.urls === 'string' ? [entry.urls] : [] const urls: string[] = [] for (const u of candidates.slice(0, MAX_URLS)) { // Browsers block port 53, so those URLs can only ever time out. if (typeof u === 'string' && URL_RE.test(u) && !/:53(?:\?|$)/.test(u)) { urls.push(u) } } if (urls.length === 0) continue const server: RTCIceServer = {urls} if ( typeof entry.username === 'string' && typeof entry.credential === 'string' && entry.username.length <= MAX_CREDENTIAL_LENGTH && entry.credential.length <= MAX_CREDENTIAL_LENGTH ) { server.username = entry.username server.credential = entry.credential } else if (urls.some(u => u.startsWith('turn'))) { continue // a relay we have no credentials for can only fail to authenticate } out.push(server) } return out.length > 0 ? out : null } /** Validate an {iceServers, expiresAt} pair shared by another participant. */ export function sanitizeIceConfig( iceServers: unknown, expiresAt: unknown ): IceConfig | null { const servers = sanitizeIceServers(iceServers) if (!servers) return null if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return null if (expiresAt <= Date.now()) return null // already dead; nothing to adopt return {iceServers: servers, expiresAt} }