improve WebRTC NAT reliability diagnostics
Build and Publish / Metadata (push) Successful in 3s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 29s

This commit is contained in:
mats committed 2026-09-22 16:31:21 +02:00
1 parent f3c735b808
commit 365415740e
5 files changed
+104 -11

No files matched your search

+5
View File
@@ -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
+4 -8
View File
@@ -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:
+78 -1
View File
@@ -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);
+7 -2
View File
@@ -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 };
+10
View File
@@ -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;
}
});