improve WebRTC NAT reliability diagnostics
This commit is contained in:
1 parent
f3c735b808
commit
365415740e
5 files changed
+104
-11
No files matched your search
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user