diff --git a/README.md b/README.md index 2076e86..713ccb7 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ PORT=8081 npm start | `STUN_URLS` | comma-separated `stun:` urls (Google defaults) | | `TURN_URLS` | comma-separated `turn:`/`turns:` urls | | `TURN_USERNAME` / `TURN_CREDENTIAL` | static TURN creds | +| `ICE_TRANSPORT_POLICY` | WebRTC ICE policy: `all`, `relay`, or `none` | | `ICE_SERVERS_JSON` | full `{"iceServers":[...]}` override, wins over the above | | `PORT` / `HOST` | listen address (default 8081 / 0.0.0.0) | @@ -38,6 +39,10 @@ TURN_URLS='turn:turn.example.com:3478?transport=udp,turns:turn.example.com:5349' TURN_USERNAME=mats TURN_CREDENTIAL=secret npm start ``` +For deployments where clients are commonly behind separate NATs, set +`ICE_TRANSPORT_POLICY=relay` so media consistently uses the public TURN +service instead of depending on NAT hairpinning or direct candidate pairs. + ## POC shortcuts (productionize later) - No auth, no reconnection, rooms are in-memory, signaling is unencrypted ws diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index b57c4ae..bfd8686 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -36,6 +36,10 @@ spec: value: "stun:turn.k6n.net:3478,stun:stun.l.google.com:19302" - name: TURN_URLS value: "turn:turn.k6n.net:3478?transport=udp,turn:turn.k6n.net:3478?transport=tcp,turns:turn.k6n.net:5349?transport=tcp" + # Route media through the public coturn service. This avoids selecting + # direct/private candidates that fail across NATs or LAN hairpinning. + - name: ICE_TRANSPORT_POLICY + value: "relay" - name: TURN_SECRET valueFrom: secretKeyRef: @@ -54,14 +58,6 @@ spec: name: coturn-auth key: password optional: true - - name: TURN_LAN_HOST - value: "10.10.3.46" - - name: TURN_LAN_SECRET - valueFrom: - secretKeyRef: - name: coturn-lan-secret - key: static-auth-secret - optional: true - name: OTEL_INGEST_KEY valueFrom: secretKeyRef: diff --git a/public/app.js b/public/app.js index 6a452aa..e1a8efe 100644 --- a/public/app.js +++ b/public/app.js @@ -96,6 +96,41 @@ function toast(msg, kind = '') { setTimeout(() => t.remove(), 5000); } +const ICE_NOTICE_INTERVAL = 15000; +function notifyIceIssue(peer, message, extra = {}) { + const now = Date.now(); + const key = `${message}:${extra.url || ''}`; + if (!peer || peer.lastIceNotice?.key !== key || now - peer.lastIceNotice.at > ICE_NOTICE_INTERVAL) { + toast(`${peer?.name || 'Voice'}: ${message}`, 'warn'); + if (peer) peer.lastIceNotice = { key, at: now }; + } + captureRumIssue(message, 'WARNING', { + peerId: peer?.id, + peerName: peer?.name, + ...extra, + }); +} + +async function selectedIcePath(pc) { + if (!pc?.getStats) return null; + const stats = await pc.getStats(); + let pair; + stats.forEach((report) => { + if (report.type === 'candidate-pair' && (report.selected || report.nominated) && report.state === 'succeeded') { + pair = report; + } + }); + if (!pair) return null; + const local = stats.get(pair.localCandidateId); + const remote = stats.get(pair.remoteCandidateId); + return { + localType: local?.candidateType, + remoteType: remote?.candidateType, + protocol: pair.protocol, + path: local?.candidateType === 'relay' || remote?.candidateType === 'relay' ? 'TURN relay' : 'direct/STUN', + }; +} + function playRetroCue(name) { const ctx = S.audioCtx; if (!ctx) return; @@ -524,6 +559,8 @@ function makePeerRecord(p) { pendingCandidates: [], micStream: null, screenStreams: new Map(), gain: null, panner: null, analyser: null, vol: 1, speaking: false, resetAttempts: 0, + lastIceNotice: null, + icePath: null, els: {}, }; } @@ -562,17 +599,40 @@ function ensurePC(peer, initiator) { if (candidate) send({ type: 'candidate', to: peer.id, candidate }); }; pc.onicecandidateerror = (event) => { - captureRumIssue('WebRTC ICE candidate error', 'WARNING', { + notifyIceIssue(peer, `ICE server unavailable (${event.errorCode || 'unknown'})`, { peerId: peer.id, + iceConnectionState: pc.iceConnectionState, + iceGatheringState: pc.iceGatheringState, errorCode: event.errorCode, errorText: event.errorText, url: event.url, }); }; + pc.oniceconnectionstatechange = () => { + const state = pc.iceConnectionState; + if (state === 'disconnected') { + notifyIceIssue(peer, 'ICE disconnected; audio may stop', { + iceConnectionState: state, + iceGatheringState: pc.iceGatheringState, + }); + } else if (state === 'failed') { + notifyIceIssue(peer, 'ICE failed; trying TURN relay', { + iceConnectionState: state, + iceGatheringState: pc.iceGatheringState, + }); + } + }; pc.onconnectionstatechange = () => { renderTiles(); if (pc.connectionState === 'failed') { console.warn('peer connection failed, rebuilding it from scratch', peer.id); + notifyIceIssue(peer, 'connection failed; rebuilding voice path', { + connectionState: pc.connectionState, + iceConnectionState: pc.iceConnectionState, + iceGatheringState: pc.iceGatheringState, + signalingState: pc.signalingState, + resetAttempts: peer.resetAttempts, + }); captureRumIssue('WebRTC peer connection failed', 'ERROR', { peerId: peer.id, state: pc.connectionState, @@ -581,6 +641,23 @@ function ensurePC(peer, initiator) { recreatePeerConnection(peer, { announce: true }); } else if (pc.connectionState === 'connected') { peer.resetAttempts = 0; // a successful connection clears the failure count + selectedIcePath(pc).then((path) => { + if (!path) return; + peer.icePath = path; + toast(`${peer.name}: ${path.path} connection`, 'ok'); + captureRumIssue('WebRTC connection established', 'INFO', { + peerId: peer.id, + peerName: peer.name, + connectionState: pc.connectionState, + iceConnectionState: pc.iceConnectionState, + ...path, + }); + }).catch((err) => { + captureRumIssue('Could not inspect selected ICE path', 'WARNING', { + peerId: peer.id, + error: err?.message || String(err), + }); + }); } }; pc.ontrack = (ev) => onRemoteTrack(peer, ev); diff --git a/server.js b/server.js index 7c923f5..d61c6e6 100644 --- a/server.js +++ b/server.js @@ -24,6 +24,7 @@ const STATIC_DIR = fs.existsSync(path.join(__dirname, 'dist')) // STUN_URLS comma-separated stun: urls // TURN_URLS comma-separated turn:/turns: urls // TURN_USERNAME / TURN_CREDENTIAL static creds for all TURN urls +// ICE_TRANSPORT_POLICY optional WebRTC policy (all, relay, or none) // TURN_LAN_HOST / TURN_LAN_SECRET optional second, LAN-only TURN relay // (own coturn instance + secret, reachable only from // the local network via its own LoadBalancer IP with @@ -85,7 +86,12 @@ function iceConfig() { }); } } - return { iceServers }; + const config = { iceServers }; + const iceTransportPolicy = process.env.ICE_TRANSPORT_POLICY; + if (iceTransportPolicy && ['all', 'relay', 'none'].includes(iceTransportPolicy)) { + config.iceTransportPolicy = iceTransportPolicy; + } + return config; } // --------------------------------------------------------------------------- @@ -323,4 +329,3 @@ if (require.main === module) { module.exports = { server, wss, iceConfig, allRoomsSnapshot, interval, startServer }; - diff --git a/test.js b/test.js index ff05d8c..87dee0d 100644 --- a/test.js +++ b/test.js @@ -91,3 +91,13 @@ test('TURN credentials generation with TURN_SECRET', () => { delete process.env.TURN_SECRET; } }); + +test('ICE transport policy is passed through to browser configuration', () => { + const { iceConfig } = require('./server'); + process.env.ICE_TRANSPORT_POLICY = 'relay'; + try { + assert.strictEqual(iceConfig().iceTransportPolicy, 'relay'); + } finally { + delete process.env.ICE_TRANSPORT_POLICY; + } +});