openvibers/openvibe.live Commits
Review pass: private calls survive list pushes, featured box boots once, short-lived TURN credentials for the SFU too
Changes
@@@ -74,6 +74,9 @@ WEBRTC_PORT=4443TURN_URL=turn:turn.openvibe.liveTURN_USERNAME=TURN_CREDENTIAL=++# Preferred: coturn `use-auth-secret` + `static-auth-secret=<this>` — every ICE list then carries a++# credential that expires after an hour instead of the static pair above.++TURN_AUTH_SECRET=ALLOW_P2P_FALLBACK=false# PayPal (for Vibes cashout — Vibes stay Live-local)@@@ -44,6 +44,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s- **Auth middleware:** `requireAuth` from `auth.js`. Permission checks via `permissions.js`.- **DB migrations:** Idempotent `CREATE … IF NOT EXISTS`/`ADD COLUMN` may stay inline; anything that transforms data goes in [server/db/migrations.js](server/db/migrations.js) (ledger, transaction, `adopt`, `DEFER`).- **Public responses:** serialize through [server/web/serializers.js](server/web/serializers.js) — never return raw `managed_streams`/`users` rows.++- **TURN:** ICE lists come from [server/net/turn.js](server/net/turn.js); set `TURN_AUTH_SECRET` (coturn `use-auth-secret`) for short-lived credentials.- **Outbound fetches of user-chosen URLs:** [server/net/egress.js](server/net/egress.js) only. Background loops: `server/utils/jobs.js`.- **WebSocket servers:** Each has `init(server)` and `handleUpgrade(req, socket, head)` methods.- **Frontend globals:** `currentUser`, `api()`, `navigate()`, `handleLinkClick()`. Cross-component sync via `CustomEvent` (e.g., `openvibe-auth-changed`).@@@ -57,6 +57,13 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c- **Assets.** Every asset URL is content-hashed at serve time (see [deploy.md](deploy.md#assets-and-caching)).- **Cached-first rendering.** `apiSWR()` (public/js/app-home.js) paints the home page's public datafrom `localStorage` (versioned records, keyed by user id) and reconciles with the fresh response.++- **Featured live stream.** `public/js/home-featured.js` (feature `featured`) loads only when someone is++ live: `GET /api/home/featured` picks and rotates the stream; playback is FLV (RTMP) or the JSMPEG relay,++ live frames for WebRTC. Off switch remembered in `localStorage` (`ov_home_featured_off`).++- **Voice channels.** `public/js/call.js` + `voice-channels.js` (feature `voice`, chat route): full-mesh++ WebRTC, signalling over `/ws/call` (`server/streaming/call-server.js`). The newcomer offers, existing++ members answer; the server pushes the channel list to every chat socket (`voice-channels` message);++ TURN credentials come from `server/net/turn.js` (short-lived with `TURN_AUTH_SECRET`).- **Budgets.** `scripts/perf/check-budgets.js` (in `npm test`) fails if the home page's HTML, eagerJavaScript or blocking CSS grow past recorded limits, or if route code returns to the home page.@@@ -235,6 +235,7 @@if (t) { t.hidden = !(isOff() && st.hasLive); t.onclick = () => setOff(false); }st.el = document.getElementById('home-featured');if (!st.el || !st.hasLive || isOff()) { stop(); return; }++ if (st.poll) return; // already running; the live-list poll calls this every 20 swatchVisibility();load(false);clearInterval(st.poll);@@@ -43,7 +43,11 @@ async function vcFetchChannels() {}/** The list from a fetch or a server push. */function vcApplyChannels(channels) {-- vcState.channels = Array.isArray(channels) ? channels : [];++ const next = Array.isArray(channels) ? channels : [];++ // The server's push is the public list; a private call this viewer can see (they were invited++ // or created it) is only in their own fetch, so it is carried over rather than dropped.++ const keep = vcState.channels.filter((c) => c.private && !next.some((n) => n.id === c.id));++ vcState.channels = keep.length ? [...next, ...keep] : next;vcRenderChannelList();vcUpdateMiniBar();}@@@ -19,9 +19,10 @@ const multer = require('multer');const path = require('path');const fs = require('fs');const db = require('../db/database');--const { requireAuth } = require('./auth');++const { requireAuth, optionalAuth } = require('./auth');const permissions = require('./permissions');const config = require('../config');++const { turnEntries } = require('../net/turn');const router = express.Router();@@@ -802,27 +803,14 @@ router.delete('/tokens/:id', requireAuth, (req, res) => {// ── ICE / TURN server config (public — needed by unauthenticated voice chat) ─// One STUN server: with TURN present, extra STUN entries only slow candidate gathering (Chrome--// warns at five or more servers). With TURN_AUTH_SECRET set (coturn `use-auth-secret`), every--// response carries a credential that expires in an hour instead of the same static one forever.--router.get('/ice-servers', (req, res) => {++// warns at five or more servers). Credentials come from server/net/turn.js: short-lived when++// TURN_AUTH_SECRET is configured, the static pair otherwise, nothing for an unauthenticated turn: URL.++router.get('/ice-servers', optionalAuth, (req, res) => {const servers = [{ urls: 'stun:stun.l.google.com:19302' }];if (config.turn?.url) {-- const secret = String(process.env.TURN_AUTH_SECRET || '').trim();-- let username = config.turn.username, credential = config.turn.credential;-- if (secret) {-- const expires = Math.floor(Date.now() / 1000) + 3600;-- username = `${expires}:${req.user ? req.user.id : 'anon'}`;-- credential = require('crypto').createHmac('sha1', secret).update(username).digest('base64');-- res.set('Cache-Control', 'private, max-age=300');-- }-- const hasTurnAuth = username && credential;-- // A turn: URL without credentials makes RTCPeerConnection throw in the browser, so a box with-- // no TURN secret configured gets STUN only.-- if (hasTurnAuth) {-- servers.push({ urls: config.turn.url, username, credential }, { urls: `${config.turn.url}?transport=tcp`, username, credential });-- } else if (username || credential) {-- console.warn('[ICE] Incomplete TURN credentials configured; TURN left out.');-- }++ const entries = turnEntries(config.turn.url, req.user ? `u${req.user.id}` : 'anon');++ if (entries.length) { servers.push(...entries); if (process.env.TURN_AUTH_SECRET) res.set('Cache-Control', 'private, max-age=300'); }++ else if (config.turn.username || config.turn.credential) console.warn('[ICE] Incomplete TURN credentials configured; TURN left out.');}res.json({ iceServers: servers });});nnew file mode 100644@@@ -0,0 +1,37 @@++'use strict';++/**++ * TURN credentials for ICE server lists (voice channels, SFU viewers and broadcasters).++ *++ * With TURN_AUTH_SECRET set — coturn running `use-auth-secret` with the same `static-auth-secret` —++ * every list carries a credential that expires (TURN REST API: username `<expiry>:<tag>`, credential++ * HMAC-SHA1 of the username, base64). Without it the static TURN_USERNAME/TURN_CREDENTIAL pair is++ * used, and nothing is emitted for a turn: URL that has no credentials at all (the browser refuses++ * such an entry outright).++ */++const crypto = require('crypto');++const config = require('../config');++++const TTL_SECONDS = 3600;++++function turnCredentials(tag = 'anon') {++ const secret = String(process.env.TURN_AUTH_SECRET || '').trim();++ if (secret) {++ const username = `${Math.floor(Date.now() / 1000) + TTL_SECONDS}:${String(tag).replace(/[^A-Za-z0-9_-]/g, '')}`;++ const credential = crypto.createHmac('sha1', secret).update(username).digest('base64');++ return { username, credential, ephemeral: true };++ }++ const { username, credential } = config.turn || {};++ if (username && credential) return { username, credential, ephemeral: false };++ return null;++}++++/** [{ urls, username, credential }] for a TURN URL (udp + tcp), or [] when it cannot be authenticated. */++function turnEntries(url, tag) {++ if (!url) return [];++ const creds = turnCredentials(tag);++ if (!creds) return [];++ const tcp = url.includes('?') ? `${url}&transport=tcp` : `${url}?transport=tcp`;++ return [{ urls: url, username: creds.username, credential: creds.credential }, { urls: tcp, username: creds.username, credential: creds.credential }];++}++++module.exports = { turnCredentials, turnEntries, TTL_SECONDS };@@@ -20,6 +20,7 @@ const webrtcSFU = require('./webrtc-sfu');// Per-address cap on viewer sockets (a household or office behind one NAT watching several tabs fits).const MAX_VIEWER_SOCKETS_PER_IP = 16;const config = require('../config');++const { turnEntries } = require('../net/turn');const whipHandler = require('./whip-handler');const WS_HEARTBEAT_MS = 30000;@@@ -58,12 +59,6 @@ class BroadcastServer extends EventEmitter {return normalized;}-- _appendTransportParam(url, transport) {-- if (!url.includes('?')) return `${url}?transport=${transport}`;-- if (/[?&]transport=/i.test(url)) return url;-- return `${url}&transport=${transport}`;-- }--_sanitizeIceServers(servers) {if (!Array.isArray(servers)) return [];const result = [];@@@ -84,26 +79,14 @@ class BroadcastServer extends EventEmitter {}/** Build ICE servers array from config (STUN + optional TURN) */-- _getIceServers() {-- const servers = [-- { urls: 'stun:stun.l.google.com:19302' },-- { urls: 'stun:stun1.l.google.com:19302' },-- ];++ _getIceServers(tag = 'sfu') {++ const servers = [{ urls: 'stun:stun.l.google.com:19302' }];if (config.turn?.url) {const turnUrl = this._normalizeTurnUrl(config.turn.url);if (turnUrl) {-- const hasTurnAuth = config.turn.username && config.turn.credential;-- servers.push(-- hasTurnAuth-- ? { urls: turnUrl, username: config.turn.username, credential: config.turn.credential }-- : { urls: turnUrl },-- hasTurnAuth-- ? { urls: this._appendTransportParam(turnUrl, 'tcp'), username: config.turn.username, credential: config.turn.credential }-- : { urls: this._appendTransportParam(turnUrl, 'tcp') },-- );-- if (!hasTurnAuth && (config.turn.username || config.turn.credential)) {-- console.warn('[ICE] Incomplete TURN credentials configured; emitting TURN URLs without auth.');-- }++ const entries = turnEntries(turnUrl, tag);++ if (entries.length) servers.push(...entries);++ else if (config.turn.username || config.turn.credential) console.warn('[ICE] Incomplete TURN credentials configured; TURN left out.');} else {console.warn('[ICE] Skipping invalid TURN_URL; only turn: or turns: URLs are accepted:', config.turn.url);}@@@ -13,7 +13,7 @@ try {config.turn.username = 'user';config.turn.credential = 'pass';const iceServers = broadcastServer._getIceServers();-- assert.strictEqual(iceServers.length, 2, 'Invalid TURN URL should be skipped and only STUN servers kept');++ assert.strictEqual(iceServers.length, 1, 'Invalid TURN URL should be skipped and only the STUN server kept');assert.ok(iceServers.every(s => {const urls = Array.isArray(s.urls) ? s.urls : [s.urls];return urls.every((u) => /^stun:/i.test(u));@@@ -23,11 +23,11 @@ try {config.turn.username = 'user';config.turn.credential = 'pass';const iceServers2 = broadcastServer._getIceServers();-- assert.strictEqual(iceServers2.length, 4, 'Valid TURN URL should produce 2 TURN entries plus 2 STUN entries');-- assert.strictEqual(iceServers2[2].urls, 'turn:turn.example.com:3478');-- assert.strictEqual(iceServers2[2].username, 'user');-- assert.strictEqual(iceServers2[2].credential, 'pass');-- assert.strictEqual(iceServers2[3].urls, 'turn:turn.example.com:3478?transport=tcp');++ assert.strictEqual(iceServers2.length, 3, 'Valid TURN URL should produce 2 TURN entries plus the STUN entry');++ assert.strictEqual(iceServers2[1].urls, 'turn:turn.example.com:3478');++ assert.strictEqual(iceServers2[1].username, 'user');++ assert.strictEqual(iceServers2[1].credential, 'pass');++ assert.strictEqual(iceServers2[2].urls, 'turn:turn.example.com:3478?transport=tcp');const normalized = config._normalizeTurnUrl('turn://turn.example.com:3478');assert.strictEqual(normalized, 'turn:turn.example.com:3478');nnew file mode 100644@@@ -0,0 +1,32 @@++/**++ * TURN credentials: short-lived HMAC pairs with TURN_AUTH_SECRET, the static pair without it, and++ * never a turn: entry without credentials.++ * node test/turn-credentials.test.js++ */++'use strict';++const assert = require('assert');++const crypto = require('crypto');++process.env.NODE_ENV = 'test';++delete process.env.TURN_AUTH_SECRET;++const config = require('../server/config');++const turn = require('../server/net/turn');++++config.turn = config.turn || {};++config.turn.url = 'turn:turn.example.org';++config.turn.username = 'static-user'; config.turn.credential = 'static-pass';++let e = turn.turnEntries(config.turn.url, 'u7');++assert.strictEqual(e.length, 2);++assert.strictEqual(e[0].username, 'static-user');++assert.strictEqual(e[1].urls, 'turn:turn.example.org?transport=tcp');++++config.turn.username = ''; config.turn.credential = '';++assert.deepStrictEqual(turn.turnEntries(config.turn.url, 'u7'), [], 'no credentials → no turn: entry');++++process.env.TURN_AUTH_SECRET = 'top-secret';++e = turn.turnEntries(config.turn.url, 'u7');++const [expiry, tag] = e[0].username.split(':');++assert.strictEqual(tag, 'u7');++assert.ok(Number(expiry) > Date.now() / 1000 + 3000 && Number(expiry) < Date.now() / 1000 + 3700, 'expires in about an hour');++assert.strictEqual(e[0].credential, crypto.createHmac('sha1', 'top-secret').update(e[0].username).digest('base64'));++assert.strictEqual(turn.turnEntries(config.turn.url, 'bad tag/;').at(-1).username.split(':')[1], 'badtag');++console.log('turn credentials: all checks passed');
@@ -74,6 +74,9 @@ WEBRTC_PORT=4443
@@ -74,6 +74,9 @@ WEBRTC_PORT=4443
TURN_URL=turn:turn.openvibe.live
TURN_URL=turn:turn.openvibe.live
TURN_USERNAME=
TURN_USERNAME=
TURN_CREDENTIAL=
TURN_CREDENTIAL=
+# Preferred: coturn `use-auth-secret` + `static-auth-secret=<this>` — every ICE list then carries a
+# credential that expires after an hour instead of the static pair above.
+TURN_AUTH_SECRET=
ALLOW_P2P_FALLBACK=false
ALLOW_P2P_FALLBACK=false
# PayPal (for Vibes cashout — Vibes stay Live-local)
# PayPal (for Vibes cashout — Vibes stay Live-local)
@@ -44,6 +44,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
@@ -44,6 +44,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
- **Auth middleware:** `requireAuth` from `auth.js`. Permission checks via `permissions.js`.
- **Auth middleware:** `requireAuth` from `auth.js`. Permission checks via `permissions.js`.
- **DB migrations:** Idempotent `CREATE … IF NOT EXISTS`/`ADD COLUMN` may stay inline; anything that transforms data goes in [server/db/migrations.js](server/db/migrations.js) (ledger, transaction, `adopt`, `DEFER`).
- **DB migrations:** Idempotent `CREATE … IF NOT EXISTS`/`ADD COLUMN` may stay inline; anything that transforms data goes in [server/db/migrations.js](server/db/migrations.js) (ledger, transaction, `adopt`, `DEFER`).
- **Public responses:** serialize through [server/web/serializers.js](server/web/serializers.js) — never return raw `managed_streams`/`users` rows.
- **Public responses:** serialize through [server/web/serializers.js](server/web/serializers.js) — never return raw `managed_streams`/`users` rows.
+- **TURN:** ICE lists come from [server/net/turn.js](server/net/turn.js); set `TURN_AUTH_SECRET` (coturn `use-auth-secret`) for short-lived credentials.
- **Outbound fetches of user-chosen URLs:** [server/net/egress.js](server/net/egress.js) only. Background loops: `server/utils/jobs.js`.
- **Outbound fetches of user-chosen URLs:** [server/net/egress.js](server/net/egress.js) only. Background loops: `server/utils/jobs.js`.
- **WebSocket servers:** Each has `init(server)` and `handleUpgrade(req, socket, head)` methods.
- **WebSocket servers:** Each has `init(server)` and `handleUpgrade(req, socket, head)` methods.
- **Frontend globals:** `currentUser`, `api()`, `navigate()`, `handleLinkClick()`. Cross-component sync via `CustomEvent` (e.g., `openvibe-auth-changed`).
- **Frontend globals:** `currentUser`, `api()`, `navigate()`, `handleLinkClick()`. Cross-component sync via `CustomEvent` (e.g., `openvibe-auth-changed`).
@@ -57,6 +57,13 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
@@ -57,6 +57,13 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
- **Assets.** Every asset URL is content-hashed at serve time (see [deploy.md](deploy.md#assets-and-caching)).
- **Assets.** Every asset URL is content-hashed at serve time (see [deploy.md](deploy.md#assets-and-caching)).
- **Cached-first rendering.** `apiSWR()` (public/js/app-home.js) paints the home page's public data
- **Cached-first rendering.** `apiSWR()` (public/js/app-home.js) paints the home page's public data
from `localStorage` (versioned records, keyed by user id) and reconciles with the fresh response.
from `localStorage` (versioned records, keyed by user id) and reconciles with the fresh response.
+- **Featured live stream.** `public/js/home-featured.js` (feature `featured`) loads only when someone is
+ live: `GET /api/home/featured` picks and rotates the stream; playback is FLV (RTMP) or the JSMPEG relay,
+ live frames for WebRTC. Off switch remembered in `localStorage` (`ov_home_featured_off`).
+- **Voice channels.** `public/js/call.js` + `voice-channels.js` (feature `voice`, chat route): full-mesh
+ WebRTC, signalling over `/ws/call` (`server/streaming/call-server.js`). The newcomer offers, existing
+ members answer; the server pushes the channel list to every chat socket (`voice-channels` message);
+ TURN credentials come from `server/net/turn.js` (short-lived with `TURN_AUTH_SECRET`).
- **Budgets.** `scripts/perf/check-budgets.js` (in `npm test`) fails if the home page's HTML, eager
- **Budgets.** `scripts/perf/check-budgets.js` (in `npm test`) fails if the home page's HTML, eager
JavaScript or blocking CSS grow past recorded limits, or if route code returns to the home page.
JavaScript or blocking CSS grow past recorded limits, or if route code returns to the home page.
@@ -235,6 +235,7 @@
@@ -235,6 +235,7 @@
if (t) { t.hidden = !(isOff() && st.hasLive); t.onclick = () => setOff(false); }
if (t) { t.hidden = !(isOff() && st.hasLive); t.onclick = () => setOff(false); }
st.el = document.getElementById('home-featured');
st.el = document.getElementById('home-featured');
if (!st.el || !st.hasLive || isOff()) { stop(); return; }
if (!st.el || !st.hasLive || isOff()) { stop(); return; }
+ if (st.poll) return; // already running; the live-list poll calls this every 20 s
watchVisibility();
watchVisibility();
load(false);
load(false);
clearInterval(st.poll);
clearInterval(st.poll);
@@ -43,7 +43,11 @@ async function vcFetchChannels() {
@@ -43,7 +43,11 @@ async function vcFetchChannels() {
}
}
/** The list from a fetch or a server push. */
/** The list from a fetch or a server push. */
function vcApplyChannels(channels) {
function vcApplyChannels(channels) {
- vcState.channels = Array.isArray(channels) ? channels : [];
+ const next = Array.isArray(channels) ? channels : [];
+ // The server's push is the public list; a private call this viewer can see (they were invited
+ // or created it) is only in their own fetch, so it is carried over rather than dropped.
+ const keep = vcState.channels.filter((c) => c.private && !next.some((n) => n.id === c.id));
+ vcState.channels = keep.length ? [...next, ...keep] : next;
vcRenderChannelList();
vcRenderChannelList();
vcUpdateMiniBar();
vcUpdateMiniBar();
}
}
@@ -19,9 +19,10 @@ const multer = require('multer');
@@ -19,9 +19,10 @@ const multer = require('multer');
const path = require('path');
const path = require('path');
const fs = require('fs');
const fs = require('fs');
const db = require('../db/database');
const db = require('../db/database');
-const { requireAuth } = require('./auth');
+const { requireAuth, optionalAuth } = require('./auth');
const permissions = require('./permissions');
const permissions = require('./permissions');
const config = require('../config');
const config = require('../config');
+const { turnEntries } = require('../net/turn');
const router = express.Router();
const router = express.Router();
@@ -802,27 +803,14 @@ router.delete('/tokens/:id', requireAuth, (req, res) => {
@@ -802,27 +803,14 @@ router.delete('/tokens/:id', requireAuth, (req, res) => {
// ── ICE / TURN server config (public — needed by unauthenticated voice chat) ─
// ── ICE / TURN server config (public — needed by unauthenticated voice chat) ─
// One STUN server: with TURN present, extra STUN entries only slow candidate gathering (Chrome
// One STUN server: with TURN present, extra STUN entries only slow candidate gathering (Chrome
-// warns at five or more servers). With TURN_AUTH_SECRET set (coturn `use-auth-secret`), every
-// response carries a credential that expires in an hour instead of the same static one forever.
-router.get('/ice-servers', (req, res) => {
+// warns at five or more servers). Credentials come from server/net/turn.js: short-lived when
+// TURN_AUTH_SECRET is configured, the static pair otherwise, nothing for an unauthenticated turn: URL.
+router.get('/ice-servers', optionalAuth, (req, res) => {
const servers = [{ urls: 'stun:stun.l.google.com:19302' }];
const servers = [{ urls: 'stun:stun.l.google.com:19302' }];
if (config.turn?.url) {
if (config.turn?.url) {
- const secret = String(process.env.TURN_AUTH_SECRET || '').trim();
- let username = config.turn.username, credential = config.turn.credential;
- if (secret) {
- const expires = Math.floor(Date.now() / 1000) + 3600;
- username = `${expires}:${req.user ? req.user.id : 'anon'}`;
- credential = require('crypto').createHmac('sha1', secret).update(username).digest('base64');
- res.set('Cache-Control', 'private, max-age=300');
- }
- const hasTurnAuth = username && credential;
- // A turn: URL without credentials makes RTCPeerConnection throw in the browser, so a box with
- // no TURN secret configured gets STUN only.
- if (hasTurnAuth) {
- servers.push({ urls: config.turn.url, username, credential }, { urls: `${config.turn.url}?transport=tcp`, username, credential });
- } else if (username || credential) {
- console.warn('[ICE] Incomplete TURN credentials configured; TURN left out.');
- }
+ const entries = turnEntries(config.turn.url, req.user ? `u${req.user.id}` : 'anon');
+ if (entries.length) { servers.push(...entries); if (process.env.TURN_AUTH_SECRET) res.set('Cache-Control', 'private, max-age=300'); }
+ else if (config.turn.username || config.turn.credential) console.warn('[ICE] Incomplete TURN credentials configured; TURN left out.');
}
}
res.json({ iceServers: servers });
res.json({ iceServers: servers });
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+'use strict';
+/**
+ * TURN credentials for ICE server lists (voice channels, SFU viewers and broadcasters).
+ *
+ * With TURN_AUTH_SECRET set — coturn running `use-auth-secret` with the same `static-auth-secret` —
+ * every list carries a credential that expires (TURN REST API: username `<expiry>:<tag>`, credential
+ * HMAC-SHA1 of the username, base64). Without it the static TURN_USERNAME/TURN_CREDENTIAL pair is
+ * used, and nothing is emitted for a turn: URL that has no credentials at all (the browser refuses
+ * such an entry outright).
+ */
+const crypto = require('crypto');
+const config = require('../config');
+
+const TTL_SECONDS = 3600;
+
+function turnCredentials(tag = 'anon') {
+ const secret = String(process.env.TURN_AUTH_SECRET || '').trim();
+ if (secret) {
+ const username = `${Math.floor(Date.now() / 1000) + TTL_SECONDS}:${String(tag).replace(/[^A-Za-z0-9_-]/g, '')}`;
+ const credential = crypto.createHmac('sha1', secret).update(username).digest('base64');
+ return { username, credential, ephemeral: true };
+ }
+ const { username, credential } = config.turn || {};
+ if (username && credential) return { username, credential, ephemeral: false };
+ return null;
+}
+
+/** [{ urls, username, credential }] for a TURN URL (udp + tcp), or [] when it cannot be authenticated. */
+function turnEntries(url, tag) {
+ if (!url) return [];
+ const creds = turnCredentials(tag);
+ if (!creds) return [];
+ const tcp = url.includes('?') ? `${url}&transport=tcp` : `${url}?transport=tcp`;
+ return [{ urls: url, username: creds.username, credential: creds.credential }, { urls: tcp, username: creds.username, credential: creds.credential }];
+}
+
+module.exports = { turnCredentials, turnEntries, TTL_SECONDS };
@@ -20,6 +20,7 @@ const webrtcSFU = require('./webrtc-sfu');
@@ -20,6 +20,7 @@ const webrtcSFU = require('./webrtc-sfu');
// Per-address cap on viewer sockets (a household or office behind one NAT watching several tabs fits).
// Per-address cap on viewer sockets (a household or office behind one NAT watching several tabs fits).
const MAX_VIEWER_SOCKETS_PER_IP = 16;
const MAX_VIEWER_SOCKETS_PER_IP = 16;
const config = require('../config');
const config = require('../config');
+const { turnEntries } = require('../net/turn');
const whipHandler = require('./whip-handler');
const whipHandler = require('./whip-handler');
const WS_HEARTBEAT_MS = 30000;
const WS_HEARTBEAT_MS = 30000;
@@ -58,12 +59,6 @@ class BroadcastServer extends EventEmitter {
@@ -58,12 +59,6 @@ class BroadcastServer extends EventEmitter {
return normalized;
return normalized;
}
}
- _appendTransportParam(url, transport) {
- if (!url.includes('?')) return `${url}?transport=${transport}`;
- if (/[?&]transport=/i.test(url)) return url;
- return `${url}&transport=${transport}`;
- }
-
_sanitizeIceServers(servers) {
_sanitizeIceServers(servers) {
if (!Array.isArray(servers)) return [];
if (!Array.isArray(servers)) return [];
const result = [];
const result = [];
@@ -84,26 +79,14 @@ class BroadcastServer extends EventEmitter {
@@ -84,26 +79,14 @@ class BroadcastServer extends EventEmitter {
}
}
/** Build ICE servers array from config (STUN + optional TURN) */
/** Build ICE servers array from config (STUN + optional TURN) */
- _getIceServers() {
- const servers = [
- { urls: 'stun:stun.l.google.com:19302' },
- { urls: 'stun:stun1.l.google.com:19302' },
- ];
+ _getIceServers(tag = 'sfu') {
+ const servers = [{ urls: 'stun:stun.l.google.com:19302' }];
if (config.turn?.url) {
if (config.turn?.url) {
const turnUrl = this._normalizeTurnUrl(config.turn.url);
const turnUrl = this._normalizeTurnUrl(config.turn.url);
if (turnUrl) {
if (turnUrl) {
- const hasTurnAuth = config.turn.username && config.turn.credential;
- servers.push(
- hasTurnAuth
- ? { urls: turnUrl, username: config.turn.username, credential: config.turn.credential }
- : { urls: turnUrl },
- hasTurnAuth
- ? { urls: this._appendTransportParam(turnUrl, 'tcp'), username: config.turn.username, credential: config.turn.credential }
- : { urls: this._appendTransportParam(turnUrl, 'tcp') },
- );
- if (!hasTurnAuth && (config.turn.username || config.turn.credential)) {
- console.warn('[ICE] Incomplete TURN credentials configured; emitting TURN URLs without auth.');
- }
+ const entries = turnEntries(turnUrl, tag);
+ if (entries.length) servers.push(...entries);
+ else if (config.turn.username || config.turn.credential) console.warn('[ICE] Incomplete TURN credentials configured; TURN left out.');
} else {
} else {
console.warn('[ICE] Skipping invalid TURN_URL; only turn: or turns: URLs are accepted:', config.turn.url);
console.warn('[ICE] Skipping invalid TURN_URL; only turn: or turns: URLs are accepted:', config.turn.url);
}
}
@@ -13,7 +13,7 @@ try {
@@ -13,7 +13,7 @@ try {
config.turn.username = 'user';
config.turn.username = 'user';
config.turn.credential = 'pass';
config.turn.credential = 'pass';
const iceServers = broadcastServer._getIceServers();
const iceServers = broadcastServer._getIceServers();
- assert.strictEqual(iceServers.length, 2, 'Invalid TURN URL should be skipped and only STUN servers kept');
+ assert.strictEqual(iceServers.length, 1, 'Invalid TURN URL should be skipped and only the STUN server kept');
assert.ok(iceServers.every(s => {
assert.ok(iceServers.every(s => {
const urls = Array.isArray(s.urls) ? s.urls : [s.urls];
const urls = Array.isArray(s.urls) ? s.urls : [s.urls];
return urls.every((u) => /^stun:/i.test(u));
return urls.every((u) => /^stun:/i.test(u));
@@ -23,11 +23,11 @@ try {
@@ -23,11 +23,11 @@ try {
config.turn.username = 'user';
config.turn.username = 'user';
config.turn.credential = 'pass';
config.turn.credential = 'pass';
const iceServers2 = broadcastServer._getIceServers();
const iceServers2 = broadcastServer._getIceServers();
- assert.strictEqual(iceServers2.length, 4, 'Valid TURN URL should produce 2 TURN entries plus 2 STUN entries');
- assert.strictEqual(iceServers2[2].urls, 'turn:turn.example.com:3478');
- assert.strictEqual(iceServers2[2].username, 'user');
- assert.strictEqual(iceServers2[2].credential, 'pass');
- assert.strictEqual(iceServers2[3].urls, 'turn:turn.example.com:3478?transport=tcp');
+ assert.strictEqual(iceServers2.length, 3, 'Valid TURN URL should produce 2 TURN entries plus the STUN entry');
+ assert.strictEqual(iceServers2[1].urls, 'turn:turn.example.com:3478');
+ assert.strictEqual(iceServers2[1].username, 'user');
+ assert.strictEqual(iceServers2[1].credential, 'pass');
+ assert.strictEqual(iceServers2[2].urls, 'turn:turn.example.com:3478?transport=tcp');
const normalized = config._normalizeTurnUrl('turn://turn.example.com:3478');
const normalized = config._normalizeTurnUrl('turn://turn.example.com:3478');
assert.strictEqual(normalized, 'turn:turn.example.com:3478');
assert.strictEqual(normalized, 'turn:turn.example.com:3478');
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+/**
+ * TURN credentials: short-lived HMAC pairs with TURN_AUTH_SECRET, the static pair without it, and
+ * never a turn: entry without credentials.
+ * node test/turn-credentials.test.js
+ */
+'use strict';
+const assert = require('assert');
+const crypto = require('crypto');
+process.env.NODE_ENV = 'test';
+delete process.env.TURN_AUTH_SECRET;
+const config = require('../server/config');
+const turn = require('../server/net/turn');
+
+config.turn = config.turn || {};
+config.turn.url = 'turn:turn.example.org';
+config.turn.username = 'static-user'; config.turn.credential = 'static-pass';
+let e = turn.turnEntries(config.turn.url, 'u7');
+assert.strictEqual(e.length, 2);
+assert.strictEqual(e[0].username, 'static-user');
+assert.strictEqual(e[1].urls, 'turn:turn.example.org?transport=tcp');
+
+config.turn.username = ''; config.turn.credential = '';
+assert.deepStrictEqual(turn.turnEntries(config.turn.url, 'u7'), [], 'no credentials → no turn: entry');
+
+process.env.TURN_AUTH_SECRET = 'top-secret';
+e = turn.turnEntries(config.turn.url, 'u7');
+const [expiry, tag] = e[0].username.split(':');
+assert.strictEqual(tag, 'u7');
+assert.ok(Number(expiry) > Date.now() / 1000 + 3000 && Number(expiry) < Date.now() / 1000 + 3700, 'expires in about an hour');
+assert.strictEqual(e[0].credential, crypto.createHmac('sha1', 'top-secret').update(e[0].username).digest('base64'));
+assert.strictEqual(turn.turnEntries(config.turn.url, 'bad tag/;').at(-1).username.split(':')[1], 'badtag');
+console.log('turn credentials: all checks passed');