openvibers/openrestream Commits
Plan T4: the RFC 9725 follow-ups the independent review of PR 5 left… (#6)
Changes
@@@ -62,6 +62,58 @@ OPENRE_RTMP_PORT=1936#OPENRE_RTMP_CHUNK_SIZE=60000#OPENRE_RTMP_MAX_PUBLISHERS=64++# ── Ingest (JSMPEG, openre-jsmpeg@) ───────────────────────────++# Public port: the broadcaster's MPEG-TS HTTP POST and the WebSocket viewers on it. Live's relay++# keeps 9710/9711 until the JSMPEG cutover (README "Port plan").++#OPENRE_JSMPEG_PORT=9736++# Public listener address (default 0.0.0.0).++#OPENRE_JSMPEG_BIND=0.0.0.0++# What broadcasters/viewers are told (production default ingest.openre.stream, else 127.0.0.1).++#OPENRE_JSMPEG_PUBLIC_HOST=ingest.openre.stream++#OPENRE_JSMPEG_PUBLIC_PORT=9736++# Loopback ports a worker generation serves its raw MPEG-TS data tap from (the restream worker++# reads it); first free port in the range, so two generations can run during a drain.++#OPENRE_JSMPEG_INTERNAL_PORT_MIN=19710++#OPENRE_JSMPEG_INTERNAL_PORT_MAX=19749++#OPENRE_JSMPEG_MAX_PUBLISHERS=64++++# ── Ingest (WebRTC: WHIP + mediasoup SFU + viewers, openre-webrtc@) ──++# Public port: WHIP (POST/PATCH/DELETE /whip/…) and the /b/<key>, /w/<id> signaling WebSockets.++# Live's own WHIP lives on its app port until the WebRTC cutover.++#OPENRE_WEBRTC_PORT=9936++# Public listener address (default 0.0.0.0).++#OPENRE_WEBRTC_BIND=0.0.0.0++# What broadcasters/viewers are told (production default ingest.openre.stream, else 127.0.0.1).++#OPENRE_WEBRTC_PUBLIC_HOST=ingest.openre.stream++#OPENRE_WEBRTC_PUBLIC_PORT=9936++# Loopback ports a generation serves RTP descriptors from (restream worker, Media's RTP recorder++# and the thumbnail grabber); first free port in the range.++#OPENRE_WEBRTC_INTERNAL_PORT_MIN=19810++#OPENRE_WEBRTC_INTERNAL_PORT_MAX=19849++#OPENRE_WEBRTC_MAX_PUBLISHERS=64++# STUN/TURN servers handed to browsers, comma-separated.++#OPENRE_WEBRTC_STUN=stun:stun.l.google.com:19302++# Live thumbnails: a frame grabbed from the worker's own source every interval, uploaded to Media++# (OPENRE_THUMBNAIL_NS, grant media.object.upload ns live) and carried on the session.++#OPENRE_THUMBNAILS=on++#OPENRE_THUMBNAIL_INTERVAL_MS=30000++#OPENRE_THUMBNAIL_WIDTH=640++#OPENRE_THUMBNAIL_NS=live++++# ── mediasoup (inside openre-webrtc) ──────────────────────────++# Public address RTC candidates are advertised with. REQUIRED in production: without it remote++# WebRTC clients cannot reach the worker's transports.++#MEDIASOUP_ANNOUNCED_IP=++# Interface RTC transports bind to (either name; MEDIASOUP_LISTEN_IP wins).++#MEDIASOUP_LISTEN_IP=0.0.0.0++#OPENRE_MEDIASOUP_LISTEN_IP=0.0.0.0++# Announced address under the OPENRE_ prefix (MEDIASOUP_ANNOUNCED_IP wins when both are set).++#OPENRE_MEDIASOUP_ANNOUNCED_IP=++# UDP port range for RTC (never Live's 10000–10100) and how many mediasoup workers per process.++#OPENRE_MEDIASOUP_MIN_PORT=10200++#OPENRE_MEDIASOUP_MAX_PORT=10300++#OPENRE_MEDIASOUP_WORKERS=1++# ── Workers, leases, drain ────────────────────────────────────#OPENRE_WORKER_HEARTBEAT_MS=2000# A worker silent this long is lost: its sessions fail and its outputs move to another worker.@@@ -250,7 +250,7 @@ one slot. Status on 2026-09-23: OpenRe is deployed (`655b98a10aaa`) and DNS is d### WebRTC (worker ready)--`workers/webrtc.js` owns the whole WebRTC stack (decision 1: mediasoup is single-process, so ingest and consumption cannot be split): WHIP (RFC 9725) admission of `POST /whip/<key>` (Bearer optional) with `PATCH /whip/session/<id>` (trickle ICE is acknowledged and discarded, since the transport is ICE-lite; new ICE credentials restart ICE: `200`, the server's new credentials and a new `ETag`; `If-Match` is checked) and `DELETE` (`404` for an unknown resource; both need the POST's Bearer again if it sent one), one mediasoup Router per session on UDP 10200–10300, the `/b/<key>` broadcaster and `/w/<id>` viewer signaling endpoints Live's broadcast/watch pages speak, a loopback RTP/SDP egress the restream worker and Media's RTP recorder consume, and live thumbnails. It needs `mediasoup` and `sdp-transform` installed (they are dependencies) and, in production, `MEDIASOUP_ANNOUNCED_IP`. docs/cutover.md has the WebRTC procedure (W-A once, then W-B1–W-B6 per slot). A webrtc session's playback descriptor is `webrtc: { signaling_url, announced_ip }`.++`workers/webrtc.js` owns the whole WebRTC stack (decision 1: mediasoup is single-process, so ingest and consumption cannot be split): WHIP (RFC 9725) admission of `POST /whip/<key>` (Bearer optional) with `PATCH /whip/session/<id>` (trickle ICE is acknowledged and discarded, since the transport is ICE-lite; a PATCH must carry `If-Match` — `428` without one, `412` when it names a stale ICE session; new ICE credentials restart ICE: `200`, the server's new credentials, exactly the ICE attributes the fragment carried (`ice-options`, `ice-pacing`, `ice-lite`, `end-of-candidates`, §4.3.3) and a new `ETag`) and `DELETE` (`404` for an unknown resource; both need the POST's Bearer again if it sent one, and a `401` carries `WWW-Authenticate: Bearer`), one mediasoup Router per session on UDP 10200–10300, the `/b/<key>` broadcaster and `/w/<id>` viewer signaling endpoints Live's broadcast/watch pages speak, a loopback RTP/SDP egress the restream worker and Media's RTP recorder consume, and live thumbnails. It needs `mediasoup` and `sdp-transform` installed (they are dependencies) and, in production, `MEDIASOUP_ANNOUNCED_IP`. docs/cutover.md has the WebRTC procedure (W-A once, then W-B1–W-B6 per slot). A webrtc session's playback descriptor is `webrtc: { signaling_url, announced_ip }`.## Acceptance@@@ -16,6 +16,7 @@ const dgram = require('dgram');const http = require('http');const WebSocket = require('ws');const { createWebrtc } = require('../workers/webrtc');++const sdpTool = require('../workers/webrtc/sdp');const { runtime, freePort, waitFor, sleep, suite, silent, OWNER } = require('./helpers');let werift;@@@ -168,7 +169,7 @@ t('a WHIP offer is answered, goes live on ICE, and a PlainRTP egress receives itudp.handle = desc.handle;await waitFor(() => udp.packets > 0, { what: 'RTP on the PlainRTP egress', timeoutMs: 20000 });-- const patch = await httpReq(port, 'PATCH', `/whip/session/${pub.resourceId}`, { headers: { 'content-type': 'application/trickle-ice-sdpfrag' }, body: 'a=ice-ufrag:x\r\na=candidate:1 1 udp 1 127.0.0.1 9999 typ host\r\n' });++ const patch = await httpReq(port, 'PATCH', `/whip/session/${pub.resourceId}`, { headers: { 'content-type': 'application/trickle-ice-sdpfrag', 'if-match': pub.etag }, body: 'a=ice-ufrag:x\r\na=candidate:1 1 udp 1 127.0.0.1 9999 typ host\r\n' });assert.strictEqual(patch.status, 204, 'a candidate of an unknown ICE generation is silently discarded');});@@@ -181,10 +182,21 @@ t('WHIP resource: PATCH/DELETE preconditions and auth, and an ICE restart that kassert.strictEqual((await httpReq(port, 'DELETE', unknown)).status, 404, 'DELETE of an unknown resource is 404');assert.strictEqual((await httpReq(port, 'GET', at)).status, 405);assert.strictEqual((await httpReq(port, 'PATCH', at, { headers: { 'content-type': 'application/sdp' }, body: 'a=end-of-candidates\r\n' })).status, 415);++ // RFC 9725 §4.3.1: a PATCH names the ICE session it conditions on, so a missing If-Match is 428++ // — for a trickle fragment and for an ICE restart alike, before either touches the transport.++ const noPrecondition = await patch({}, 'a=end-of-candidates\r\n');++ assert.strictEqual(noPrecondition.status, 428, `PATCH without If-Match (${noPrecondition.status}): ${noPrecondition.body}`);++ assert.strictEqual(JSON.parse(noPrecondition.body).error_code, 'precondition_required');++ const noPreconditionRestart = await patch({}, 'a=ice-ufrag:zzzz\r\na=ice-pwd:zzzzzzzzzzzzzzzzzzzzzzzz\r\na=end-of-candidates\r\n');++ assert.strictEqual(noPreconditionRestart.status, 428, 'an ICE restart without If-Match is 428');assert.strictEqual((await patch({ 'if-match': '"stale"' }, 'a=end-of-candidates\r\n')).status, 412);-- assert.strictEqual((await patch({}, 'not an sdp fragment')).status, 400);-- assert.strictEqual((await patch({ authorization: 'Bearer ork_wrong' }, 'a=end-of-candidates\r\n')).status, 401, 'a Bearer that is not the key is refused');-- assert.strictEqual((await httpReq(port, 'DELETE', at, { headers: { authorization: 'Bearer ork_wrong' } })).status, 401);++ assert.strictEqual((await patch({ 'if-match': pub.etag }, 'not an sdp fragment')).status, 400);++ const wrongBearer = await patch({ 'if-match': pub.etag, authorization: 'Bearer ork_wrong' }, 'a=end-of-candidates\r\n');++ assert.strictEqual(wrongBearer.status, 401, 'a Bearer that is not the key is refused');++ assert.strictEqual(wrongBearer.headers['www-authenticate'], 'Bearer', 'a 401 names the Bearer scheme (RFC 9725 §4.5)');++ const deleteUnauthorized = await httpReq(port, 'DELETE', at, { headers: { authorization: 'Bearer ork_wrong' } });++ assert.strictEqual(deleteUnauthorized.status, 401);++ assert.strictEqual(deleteUnauthorized.headers['www-authenticate'], 'Bearer', 'DELETE 401 too');// ICE restart (RFC 9725 §4.3.2): new client credentials → new server credentials and a new ETag.const before = udp.packets;@@@ -193,13 +205,26 @@ t('WHIP resource: PATCH/DELETE preconditions and auth, and an ICE restart that kawait pub.pc.setLocalDescription(offer);const ufrag = /a=ice-ufrag:(\S+)/.exec(pub.pc.localDescription.sdp)[1];const pwd = /a=ice-pwd:(\S+)/.exec(pub.pc.localDescription.sdp)[1];-- const restart = await patch({ 'if-match': '*', authorization: `Bearer ${keyA}` }, `a=ice-ufrag:${ufrag}\r\na=ice-pwd:${pwd}\r\n`);++ // The fragment carries every attribute the answer must mirror (§4.3.3). A browser only sends++ // ice-options:trickle; ice-lite is here to prove the server echoes what it got, not a constant.++ const restartBody = `a=ice-ufrag:${ufrag}\r\na=ice-pwd:${pwd}\r\na=ice-options:trickle\r\na=ice-pacing:50\r\na=ice-lite\r\na=end-of-candidates\r\n`;++ const restart = await patch({ 'if-match': '*', authorization: `Bearer ${keyA}` }, restartBody);assert.strictEqual(restart.status, 200, `ICE restart (${restart.status}): ${restart.body}`);assert.match(restart.headers['content-type'], /^application\/trickle-ice-sdpfrag/);assert.notStrictEqual(restart.headers.etag, pub.etag, 'a restart starts a new ICE session');-- const serverUfrag = /a=ice-ufrag:(\S+)/.exec(restart.body)[1];-- const serverPwd = /a=ice-pwd:(\S+)/.exec(restart.body)[1];-- assert.notStrictEqual(serverUfrag, /a=ice-ufrag:(\S+)/.exec(pub.answer)[1], 'the server has new credentials');++ const request = sdpTool.parseIceFragment(restartBody);++ const server = sdpTool.parseIceFragment(restart.body);++ assert.ok(server.ufrag, 'the answer carries the server ufrag');++ assert.ok(server.pwd, 'the answer carries the server pwd');++ assert.notStrictEqual(server.ufrag, /a=ice-ufrag:(\S+)/.exec(pub.answer)[1], 'the server has new credentials');++ assert.deepStrictEqual(++ { iceOptions: server.iceOptions, icePacing: server.icePacing, iceLite: server.iceLite, endOfCandidates: server.endOfCandidates },++ { iceOptions: request.iceOptions, icePacing: request.icePacing, iceLite: request.iceLite, endOfCandidates: request.endOfCandidates },++ 'the answer mirrors the request fragment\'s ICE attributes exactly',++ );++ assert.strictEqual(server.candidates, 0, 'a restart answer carries no candidates');++ const serverUfrag = server.ufrag;++ const serverPwd = server.pwd;assert.strictEqual((await patch({ 'if-match': pub.etag }, 'a=end-of-candidates\r\n')).status, 412, 'the old ICE session\'s ETag no longer matches');assert.strictEqual((await patch({ 'if-match': restart.headers.etag }, 'a=end-of-candidates\r\n')).status, 204);pub.etag = restart.headers.etag;@@@ -67,20 +67,38 @@ t('WHIP SDP: parse, DTLS role, RTP parameters and the answer', () => {});t('WHIP PATCH sdpfrag: credentials, candidates, end-of-candidates; malformed bodies throw', () => {-- const trickle = sdp.parseIceFragment('a=ice-ufrag:EsAw\r\na=ice-pwd:P2uYro0UCOQ4zxjKXaWCBui1\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:0\r\n'++ const trickle = sdp.parseIceFragment('a=ice-ufrag:EsAw\r\na=ice-pwd:P2uYro0UCOQ4zxjKXaWCBui1\r\na=ice-options:trickle\r\na=ice-pacing:50\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:0\r\n'+ 'a=candidate:1387637174 1 udp 2122260223 192.0.2.1 61764 typ host generation 0 ufrag EsAw network-id 1\r\n'+ 'a=candidate:3471623853 1 udp 2122194687 198.51.100.2 61765 typ host\r\na=end-of-candidates\r\n');-- assert.deepStrictEqual(trickle, { ufrag: 'EsAw', pwd: 'P2uYro0UCOQ4zxjKXaWCBui1', candidates: 2, endOfCandidates: true });++ assert.deepStrictEqual(trickle, {++ ufrag: 'EsAw', pwd: 'P2uYro0UCOQ4zxjKXaWCBui1', candidates: 2, endOfCandidates: true,++ iceOptions: 'trickle', icePacing: '50', iceLite: false,++ });// Media-level credentials count too; a bare candidate list has none.assert.strictEqual(sdp.parseIceFragment('m=video 9 UDP/TLS/RTP/SAVPF 96\na=ice-ufrag:abc\n').ufrag, 'abc');-- assert.deepStrictEqual(sdp.parseIceFragment('a=candidate:1 1 tcp 5 192.0.2.1 9 typ host tcptype active\n'), { ufrag: null, pwd: null, candidates: 1, endOfCandidates: false });++ assert.deepStrictEqual(sdp.parseIceFragment('a=candidate:1 1 tcp 5 192.0.2.1 9 typ host tcptype active\n'), {++ ufrag: null, pwd: null, candidates: 1, endOfCandidates: false, iceOptions: null, icePacing: null, iceLite: false,++ });assert.strictEqual(sdp.parseIceFragment('').candidates, 0);++ assert.strictEqual(sdp.parseIceFragment('a=ice-lite\r\n').iceLite, true);for (const bad of ['hello', '{"candidate":"x"}', 'a=candidate:garbage', undefined]) {assert.throws(() => sdp.parseIceFragment(bad), (err) => err.code === 'invalid_sdpfrag', `rejects ${bad}`);}++++ // The restart answer is the server's new credentials plus exactly the attributes the request++ // fragment carried (RFC 9725 §4.3.3): nothing is assumed, ice-lite is not hard-coded.const frag = sdp.buildIceFragment({ usernameFragment: 'u1', password: 'p1' });-- assert.deepStrictEqual(sdp.parseIceFragment(frag), { ufrag: 'u1', pwd: 'p1', candidates: 0, endOfCandidates: false });-- assert.match(frag, /^a=ice-lite\r\n/, 'the restart answer says the server is ICE-lite');++ assert.deepStrictEqual(sdp.parseIceFragment(frag), {++ ufrag: 'u1', pwd: 'p1', candidates: 0, endOfCandidates: false, iceOptions: null, icePacing: null, iceLite: false,++ });++ assert.ok(!/ice-lite/.test(frag), 'the server does not claim ice-lite the client never sent');++++ const mirrored = sdp.parseIceFragment(sdp.buildIceFragment({ usernameFragment: 'u2', password: 'p2' }, trickle));++ assert.deepStrictEqual(mirrored, { ...trickle, ufrag: 'u2', pwd: 'p2', candidates: 0 });++ assert.deepStrictEqual(++ sdp.parseIceFragment(sdp.buildIceFragment({ usernameFragment: 'u3', password: 'p3' }, sdp.parseIceFragment('a=ice-lite\r\na=ice-options:trickle\r\n'))),++ { ufrag: 'u3', pwd: 'p3', candidates: 0, endOfCandidates: false, iceOptions: 'trickle', icePacing: null, iceLite: true },++ );});t('a webrtc session playback descriptor is { signaling_url, announced_ip } and carries thumbnail_url', async () => {@@@ -47,7 +47,7 @@ const WHIP_CORS = Object.freeze({'Access-Control-Allow-Origin': '*','Access-Control-Allow-Methods': 'POST, PATCH, DELETE, OPTIONS','Access-Control-Allow-Headers': 'Content-Type, Authorization, If-Match',-- 'Access-Control-Expose-Headers': 'Location, X-WHIP-ERROR, ETag',++ 'Access-Control-Expose-Headers': 'Location, X-WHIP-ERROR, ETag, WWW-Authenticate','Access-Control-Max-Age': '86400','Cross-Origin-Resource-Policy': 'cross-origin',});@@@ -200,7 +200,10 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),};function sendWhipError(res, status, code, message) {-- res.writeHead(status, { ...WHIP_CORS, 'content-type': 'application/json', 'X-WHIP-ERROR': code });++ const headers = { ...WHIP_CORS, 'content-type': 'application/json', 'X-WHIP-ERROR': code };++ // RFC 9725 §4.5: a 401 tells the client how to authenticate (the same scheme the endpoint takes).++ if (status === 401) headers['WWW-Authenticate'] = 'Bearer';++ res.writeHead(status, headers);res.end(JSON.stringify({ error: message, error_code: code }));}@@@ -326,7 +329,8 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),* trickled candidates are acknowledged and discarded: the client's connectivity checks reach* the transport's own candidates (sent in the answer) and mediasoup learns the client's address* from them. New ICE credentials in the fragment are an ICE restart: the transport gets new-- * credentials, answered 200 with them and a new ETag; the session stays up either way.++ * credentials, answered 200 with them (and the ICE attributes the client's fragment carried) and++ * a new ETag; the session stays up either way. Every PATCH must carry If-Match (428 without one).*/async function handleWhipPatch(req, res, resourceId) {const found = findWhip(resourceId);@@@ -336,8 +340,12 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),if (!/^application\/trickle-ice-sdpfrag\s*(;|$)/i.test(req.headers['content-type'] || '')) {return sendWhipError(res, 415, 'unsupported_media_type', 'PATCH body must be application/trickle-ice-sdpfrag');}++ // RFC 9725 §4.3.1: a PATCH on a WHIP resource MUST carry If-Match, so a missing one is 428++ // (not 412: the client never named an ICE session to condition on). '*' and the current++ // ETag both name the one in force.const ifMatch = (req.headers['if-match'] || '').trim();-- if (ifMatch && ifMatch !== '*' && ifMatch !== entry.etag) return sendWhipError(res, 412, 'etag_mismatch', 'If-Match does not name the current ICE session');++ if (!ifMatch) return sendWhipError(res, 428, 'precondition_required', 'If-Match is required on a WHIP resource (RFC 9725 §4.3.1)');++ if (ifMatch !== '*' && ifMatch !== entry.etag) return sendWhipError(res, 412, 'etag_mismatch', 'If-Match does not name the current ICE session');let body;try { body = await readBody(req); } catch { return sendWhipError(res, 413, 'body_too_large', 'Fragment too large'); }let frag;@@@ -354,7 +362,7 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),entry.etag = newIceEtag();log.log(`[webrtc] WHIP ICE restart for ${sess.sessionId}`);res.writeHead(200, { ...WHIP_CORS, 'content-type': 'application/trickle-ice-sdpfrag', ETag: entry.etag });-- return res.end(sdpTool.buildIceFragment(iceParameters));++ return res.end(sdpTool.buildIceFragment(iceParameters, frag));}res.writeHead(204, WHIP_CORS);res.end();@@@ -266,12 +266,14 @@ const CANDIDATE_RE = /^candidate:\S+ \d+ \S+ \d+ \S+ \d+ typ \S+/i;/*** Read a WHIP PATCH body, an RFC 8840 "application/trickle-ice-sdpfrag": the ICE credentials it-- * names (session or media level, the first wins) and how many candidates it carries. Throws-- * (code 'invalid_sdpfrag') on anything that is not an SDP fragment or on a malformed candidate.++ * names (session or media level, the first wins), how many candidates it carries, and the ICE++ * attributes the answer fragment is expected to mirror (RFC 9725 §4.3.3: ice-options, ice-pacing,++ * ice-lite, end-of-candidates). Throws (code 'invalid_sdpfrag') on anything that is not an SDP++ * fragment or on a malformed candidate.*/function parseIceFragment(text) {if (typeof text !== 'string') throw Object.assign(new Error('missing sdpfrag'), { code: 'invalid_sdpfrag' });-- const out = { ufrag: null, pwd: null, candidates: 0, endOfCandidates: false };++ const out = { ufrag: null, pwd: null, candidates: 0, endOfCandidates: false, iceOptions: null, icePacing: null, iceLite: false };for (const raw of text.split(/\r?\n/)) {const line = raw.trim();if (!line) continue;@@@ -281,6 +283,9 @@ function parseIceFragment(text) {if (attr.startsWith('ice-ufrag:')) out.ufrag = out.ufrag || attr.slice(10).trim();else if (attr.startsWith('ice-pwd:')) out.pwd = out.pwd || attr.slice(8).trim();else if (attr === 'end-of-candidates') out.endOfCandidates = true;++ else if (attr === 'ice-lite') out.iceLite = true;++ else if (attr.startsWith('ice-options:')) out.iceOptions = out.iceOptions == null ? attr.slice(12).trim() : out.iceOptions;++ else if (attr.startsWith('ice-pacing:')) out.icePacing = out.icePacing == null ? attr.slice(11).trim() : out.icePacing;else if (attr.startsWith('candidate:')) {if (!CANDIDATE_RE.test(attr)) throw Object.assign(new Error('malformed candidate'), { code: 'invalid_sdpfrag' });out.candidates++;@@@ -289,9 +294,20 @@ function parseIceFragment(text) {return out;}--/** The sdpfrag a WHIP resource answers an ICE restart with: the media server's new credentials. */--function buildIceFragment(iceParameters) {-- return ['a=ice-lite', `a=ice-ufrag:${iceParameters.usernameFragment}`, `a=ice-pwd:${iceParameters.password}`, ''].join('\r\n');++/**++ * The sdpfrag a WHIP resource answers an ICE restart with (RFC 9725 §4.3.3): the media server's new++ * credentials, plus exactly the ice-options / ice-pacing / ice-lite / end-of-candidates attributes++ * the request fragment carried — the server repeats what the client's session declared, it does not++ * assume any of them (`mirror` is a parsed request fragment; nothing is mirrored by default).++ */++function buildIceFragment(iceParameters, mirror = {}) {++ const lines = [`a=ice-ufrag:${iceParameters.usernameFragment}`, `a=ice-pwd:${iceParameters.password}`];++ if (mirror.iceOptions) lines.push(`a=ice-options:${mirror.iceOptions}`);++ if (mirror.icePacing) lines.push(`a=ice-pacing:${mirror.icePacing}`);++ if (mirror.iceLite) lines.push('a=ice-lite');++ if (mirror.endOfCandidates) lines.push('a=end-of-candidates');++ lines.push('');++ return lines.join('\r\n');}/**
@@ -62,6 +62,58 @@ OPENRE_RTMP_PORT=1936
@@ -62,6 +62,58 @@ OPENRE_RTMP_PORT=1936
#OPENRE_RTMP_CHUNK_SIZE=60000
#OPENRE_RTMP_CHUNK_SIZE=60000
#OPENRE_RTMP_MAX_PUBLISHERS=64
#OPENRE_RTMP_MAX_PUBLISHERS=64
+# ── Ingest (JSMPEG, openre-jsmpeg@) ───────────────────────────
+# Public port: the broadcaster's MPEG-TS HTTP POST and the WebSocket viewers on it. Live's relay
+# keeps 9710/9711 until the JSMPEG cutover (README "Port plan").
+#OPENRE_JSMPEG_PORT=9736
+# Public listener address (default 0.0.0.0).
+#OPENRE_JSMPEG_BIND=0.0.0.0
+# What broadcasters/viewers are told (production default ingest.openre.stream, else 127.0.0.1).
+#OPENRE_JSMPEG_PUBLIC_HOST=ingest.openre.stream
+#OPENRE_JSMPEG_PUBLIC_PORT=9736
+# Loopback ports a worker generation serves its raw MPEG-TS data tap from (the restream worker
+# reads it); first free port in the range, so two generations can run during a drain.
+#OPENRE_JSMPEG_INTERNAL_PORT_MIN=19710
+#OPENRE_JSMPEG_INTERNAL_PORT_MAX=19749
+#OPENRE_JSMPEG_MAX_PUBLISHERS=64
+
+# ── Ingest (WebRTC: WHIP + mediasoup SFU + viewers, openre-webrtc@) ──
+# Public port: WHIP (POST/PATCH/DELETE /whip/…) and the /b/<key>, /w/<id> signaling WebSockets.
+# Live's own WHIP lives on its app port until the WebRTC cutover.
+#OPENRE_WEBRTC_PORT=9936
+# Public listener address (default 0.0.0.0).
+#OPENRE_WEBRTC_BIND=0.0.0.0
+# What broadcasters/viewers are told (production default ingest.openre.stream, else 127.0.0.1).
+#OPENRE_WEBRTC_PUBLIC_HOST=ingest.openre.stream
+#OPENRE_WEBRTC_PUBLIC_PORT=9936
+# Loopback ports a generation serves RTP descriptors from (restream worker, Media's RTP recorder
+# and the thumbnail grabber); first free port in the range.
+#OPENRE_WEBRTC_INTERNAL_PORT_MIN=19810
+#OPENRE_WEBRTC_INTERNAL_PORT_MAX=19849
+#OPENRE_WEBRTC_MAX_PUBLISHERS=64
+# STUN/TURN servers handed to browsers, comma-separated.
+#OPENRE_WEBRTC_STUN=stun:stun.l.google.com:19302
+# Live thumbnails: a frame grabbed from the worker's own source every interval, uploaded to Media
+# (OPENRE_THUMBNAIL_NS, grant media.object.upload ns live) and carried on the session.
+#OPENRE_THUMBNAILS=on
+#OPENRE_THUMBNAIL_INTERVAL_MS=30000
+#OPENRE_THUMBNAIL_WIDTH=640
+#OPENRE_THUMBNAIL_NS=live
+
+# ── mediasoup (inside openre-webrtc) ──────────────────────────
+# Public address RTC candidates are advertised with. REQUIRED in production: without it remote
+# WebRTC clients cannot reach the worker's transports.
+#MEDIASOUP_ANNOUNCED_IP=
+# Interface RTC transports bind to (either name; MEDIASOUP_LISTEN_IP wins).
+#MEDIASOUP_LISTEN_IP=0.0.0.0
+#OPENRE_MEDIASOUP_LISTEN_IP=0.0.0.0
+# Announced address under the OPENRE_ prefix (MEDIASOUP_ANNOUNCED_IP wins when both are set).
+#OPENRE_MEDIASOUP_ANNOUNCED_IP=
+# UDP port range for RTC (never Live's 10000–10100) and how many mediasoup workers per process.
+#OPENRE_MEDIASOUP_MIN_PORT=10200
+#OPENRE_MEDIASOUP_MAX_PORT=10300
+#OPENRE_MEDIASOUP_WORKERS=1
+
# ── Workers, leases, drain ────────────────────────────────────
# ── Workers, leases, drain ────────────────────────────────────
#OPENRE_WORKER_HEARTBEAT_MS=2000
#OPENRE_WORKER_HEARTBEAT_MS=2000
# A worker silent this long is lost: its sessions fail and its outputs move to another worker.
# A worker silent this long is lost: its sessions fail and its outputs move to another worker.
@@ -250,7 +250,7 @@ one slot. Status on 2026-09-23: OpenRe is deployed (`655b98a10aaa`) and DNS is d
@@ -250,7 +250,7 @@ one slot. Status on 2026-09-23: OpenRe is deployed (`655b98a10aaa`) and DNS is d
### WebRTC (worker ready)
### WebRTC (worker ready)
-`workers/webrtc.js` owns the whole WebRTC stack (decision 1: mediasoup is single-process, so ingest and consumption cannot be split): WHIP (RFC 9725) admission of `POST /whip/<key>` (Bearer optional) with `PATCH /whip/session/<id>` (trickle ICE is acknowledged and discarded, since the transport is ICE-lite; new ICE credentials restart ICE: `200`, the server's new credentials and a new `ETag`; `If-Match` is checked) and `DELETE` (`404` for an unknown resource; both need the POST's Bearer again if it sent one), one mediasoup Router per session on UDP 10200–10300, the `/b/<key>` broadcaster and `/w/<id>` viewer signaling endpoints Live's broadcast/watch pages speak, a loopback RTP/SDP egress the restream worker and Media's RTP recorder consume, and live thumbnails. It needs `mediasoup` and `sdp-transform` installed (they are dependencies) and, in production, `MEDIASOUP_ANNOUNCED_IP`. docs/cutover.md has the WebRTC procedure (W-A once, then W-B1–W-B6 per slot). A webrtc session's playback descriptor is `webrtc: { signaling_url, announced_ip }`.
+`workers/webrtc.js` owns the whole WebRTC stack (decision 1: mediasoup is single-process, so ingest and consumption cannot be split): WHIP (RFC 9725) admission of `POST /whip/<key>` (Bearer optional) with `PATCH /whip/session/<id>` (trickle ICE is acknowledged and discarded, since the transport is ICE-lite; a PATCH must carry `If-Match` — `428` without one, `412` when it names a stale ICE session; new ICE credentials restart ICE: `200`, the server's new credentials, exactly the ICE attributes the fragment carried (`ice-options`, `ice-pacing`, `ice-lite`, `end-of-candidates`, §4.3.3) and a new `ETag`) and `DELETE` (`404` for an unknown resource; both need the POST's Bearer again if it sent one, and a `401` carries `WWW-Authenticate: Bearer`), one mediasoup Router per session on UDP 10200–10300, the `/b/<key>` broadcaster and `/w/<id>` viewer signaling endpoints Live's broadcast/watch pages speak, a loopback RTP/SDP egress the restream worker and Media's RTP recorder consume, and live thumbnails. It needs `mediasoup` and `sdp-transform` installed (they are dependencies) and, in production, `MEDIASOUP_ANNOUNCED_IP`. docs/cutover.md has the WebRTC procedure (W-A once, then W-B1–W-B6 per slot). A webrtc session's playback descriptor is `webrtc: { signaling_url, announced_ip }`.
## Acceptance
## Acceptance
@@ -16,6 +16,7 @@ const dgram = require('dgram');
@@ -16,6 +16,7 @@ const dgram = require('dgram');
const http = require('http');
const http = require('http');
const WebSocket = require('ws');
const WebSocket = require('ws');
const { createWebrtc } = require('../workers/webrtc');
const { createWebrtc } = require('../workers/webrtc');
+const sdpTool = require('../workers/webrtc/sdp');
const { runtime, freePort, waitFor, sleep, suite, silent, OWNER } = require('./helpers');
const { runtime, freePort, waitFor, sleep, suite, silent, OWNER } = require('./helpers');
let werift;
let werift;
@@ -168,7 +169,7 @@ t('a WHIP offer is answered, goes live on ICE, and a PlainRTP egress receives it
@@ -168,7 +169,7 @@ t('a WHIP offer is answered, goes live on ICE, and a PlainRTP egress receives it
udp.handle = desc.handle;
udp.handle = desc.handle;
await waitFor(() => udp.packets > 0, { what: 'RTP on the PlainRTP egress', timeoutMs: 20000 });
await waitFor(() => udp.packets > 0, { what: 'RTP on the PlainRTP egress', timeoutMs: 20000 });
- const patch = await httpReq(port, 'PATCH', `/whip/session/${pub.resourceId}`, { headers: { 'content-type': 'application/trickle-ice-sdpfrag' }, body: 'a=ice-ufrag:x\r\na=candidate:1 1 udp 1 127.0.0.1 9999 typ host\r\n' });
+ const patch = await httpReq(port, 'PATCH', `/whip/session/${pub.resourceId}`, { headers: { 'content-type': 'application/trickle-ice-sdpfrag', 'if-match': pub.etag }, body: 'a=ice-ufrag:x\r\na=candidate:1 1 udp 1 127.0.0.1 9999 typ host\r\n' });
assert.strictEqual(patch.status, 204, 'a candidate of an unknown ICE generation is silently discarded');
assert.strictEqual(patch.status, 204, 'a candidate of an unknown ICE generation is silently discarded');
});
});
@@ -181,10 +182,21 @@ t('WHIP resource: PATCH/DELETE preconditions and auth, and an ICE restart that k
@@ -181,10 +182,21 @@ t('WHIP resource: PATCH/DELETE preconditions and auth, and an ICE restart that k
assert.strictEqual((await httpReq(port, 'DELETE', unknown)).status, 404, 'DELETE of an unknown resource is 404');
assert.strictEqual((await httpReq(port, 'DELETE', unknown)).status, 404, 'DELETE of an unknown resource is 404');
assert.strictEqual((await httpReq(port, 'GET', at)).status, 405);
assert.strictEqual((await httpReq(port, 'GET', at)).status, 405);
assert.strictEqual((await httpReq(port, 'PATCH', at, { headers: { 'content-type': 'application/sdp' }, body: 'a=end-of-candidates\r\n' })).status, 415);
assert.strictEqual((await httpReq(port, 'PATCH', at, { headers: { 'content-type': 'application/sdp' }, body: 'a=end-of-candidates\r\n' })).status, 415);
+ // RFC 9725 §4.3.1: a PATCH names the ICE session it conditions on, so a missing If-Match is 428
+ // — for a trickle fragment and for an ICE restart alike, before either touches the transport.
+ const noPrecondition = await patch({}, 'a=end-of-candidates\r\n');
+ assert.strictEqual(noPrecondition.status, 428, `PATCH without If-Match (${noPrecondition.status}): ${noPrecondition.body}`);
+ assert.strictEqual(JSON.parse(noPrecondition.body).error_code, 'precondition_required');
+ const noPreconditionRestart = await patch({}, 'a=ice-ufrag:zzzz\r\na=ice-pwd:zzzzzzzzzzzzzzzzzzzzzzzz\r\na=end-of-candidates\r\n');
+ assert.strictEqual(noPreconditionRestart.status, 428, 'an ICE restart without If-Match is 428');
assert.strictEqual((await patch({ 'if-match': '"stale"' }, 'a=end-of-candidates\r\n')).status, 412);
assert.strictEqual((await patch({ 'if-match': '"stale"' }, 'a=end-of-candidates\r\n')).status, 412);
- assert.strictEqual((await patch({}, 'not an sdp fragment')).status, 400);
- assert.strictEqual((await patch({ authorization: 'Bearer ork_wrong' }, 'a=end-of-candidates\r\n')).status, 401, 'a Bearer that is not the key is refused');
- assert.strictEqual((await httpReq(port, 'DELETE', at, { headers: { authorization: 'Bearer ork_wrong' } })).status, 401);
+ assert.strictEqual((await patch({ 'if-match': pub.etag }, 'not an sdp fragment')).status, 400);
+ const wrongBearer = await patch({ 'if-match': pub.etag, authorization: 'Bearer ork_wrong' }, 'a=end-of-candidates\r\n');
+ assert.strictEqual(wrongBearer.status, 401, 'a Bearer that is not the key is refused');
+ assert.strictEqual(wrongBearer.headers['www-authenticate'], 'Bearer', 'a 401 names the Bearer scheme (RFC 9725 §4.5)');
+ const deleteUnauthorized = await httpReq(port, 'DELETE', at, { headers: { authorization: 'Bearer ork_wrong' } });
+ assert.strictEqual(deleteUnauthorized.status, 401);
+ assert.strictEqual(deleteUnauthorized.headers['www-authenticate'], 'Bearer', 'DELETE 401 too');
// ICE restart (RFC 9725 §4.3.2): new client credentials → new server credentials and a new ETag.
// ICE restart (RFC 9725 §4.3.2): new client credentials → new server credentials and a new ETag.
const before = udp.packets;
const before = udp.packets;
@@ -193,13 +205,26 @@ t('WHIP resource: PATCH/DELETE preconditions and auth, and an ICE restart that k
@@ -193,13 +205,26 @@ t('WHIP resource: PATCH/DELETE preconditions and auth, and an ICE restart that k
await pub.pc.setLocalDescription(offer);
await pub.pc.setLocalDescription(offer);
const ufrag = /a=ice-ufrag:(\S+)/.exec(pub.pc.localDescription.sdp)[1];
const ufrag = /a=ice-ufrag:(\S+)/.exec(pub.pc.localDescription.sdp)[1];
const pwd = /a=ice-pwd:(\S+)/.exec(pub.pc.localDescription.sdp)[1];
const pwd = /a=ice-pwd:(\S+)/.exec(pub.pc.localDescription.sdp)[1];
- const restart = await patch({ 'if-match': '*', authorization: `Bearer ${keyA}` }, `a=ice-ufrag:${ufrag}\r\na=ice-pwd:${pwd}\r\n`);
+ // The fragment carries every attribute the answer must mirror (§4.3.3). A browser only sends
+ // ice-options:trickle; ice-lite is here to prove the server echoes what it got, not a constant.
+ const restartBody = `a=ice-ufrag:${ufrag}\r\na=ice-pwd:${pwd}\r\na=ice-options:trickle\r\na=ice-pacing:50\r\na=ice-lite\r\na=end-of-candidates\r\n`;
+ const restart = await patch({ 'if-match': '*', authorization: `Bearer ${keyA}` }, restartBody);
assert.strictEqual(restart.status, 200, `ICE restart (${restart.status}): ${restart.body}`);
assert.strictEqual(restart.status, 200, `ICE restart (${restart.status}): ${restart.body}`);
assert.match(restart.headers['content-type'], /^application\/trickle-ice-sdpfrag/);
assert.match(restart.headers['content-type'], /^application\/trickle-ice-sdpfrag/);
assert.notStrictEqual(restart.headers.etag, pub.etag, 'a restart starts a new ICE session');
assert.notStrictEqual(restart.headers.etag, pub.etag, 'a restart starts a new ICE session');
- const serverUfrag = /a=ice-ufrag:(\S+)/.exec(restart.body)[1];
- const serverPwd = /a=ice-pwd:(\S+)/.exec(restart.body)[1];
- assert.notStrictEqual(serverUfrag, /a=ice-ufrag:(\S+)/.exec(pub.answer)[1], 'the server has new credentials');
+ const request = sdpTool.parseIceFragment(restartBody);
+ const server = sdpTool.parseIceFragment(restart.body);
+ assert.ok(server.ufrag, 'the answer carries the server ufrag');
+ assert.ok(server.pwd, 'the answer carries the server pwd');
+ assert.notStrictEqual(server.ufrag, /a=ice-ufrag:(\S+)/.exec(pub.answer)[1], 'the server has new credentials');
+ assert.deepStrictEqual(
+ { iceOptions: server.iceOptions, icePacing: server.icePacing, iceLite: server.iceLite, endOfCandidates: server.endOfCandidates },
+ { iceOptions: request.iceOptions, icePacing: request.icePacing, iceLite: request.iceLite, endOfCandidates: request.endOfCandidates },
+ 'the answer mirrors the request fragment\'s ICE attributes exactly',
+ );
+ assert.strictEqual(server.candidates, 0, 'a restart answer carries no candidates');
+ const serverUfrag = server.ufrag;
+ const serverPwd = server.pwd;
assert.strictEqual((await patch({ 'if-match': pub.etag }, 'a=end-of-candidates\r\n')).status, 412, 'the old ICE session\'s ETag no longer matches');
assert.strictEqual((await patch({ 'if-match': pub.etag }, 'a=end-of-candidates\r\n')).status, 412, 'the old ICE session\'s ETag no longer matches');
assert.strictEqual((await patch({ 'if-match': restart.headers.etag }, 'a=end-of-candidates\r\n')).status, 204);
assert.strictEqual((await patch({ 'if-match': restart.headers.etag }, 'a=end-of-candidates\r\n')).status, 204);
pub.etag = restart.headers.etag;
pub.etag = restart.headers.etag;
@@ -67,20 +67,38 @@ t('WHIP SDP: parse, DTLS role, RTP parameters and the answer', () => {
@@ -67,20 +67,38 @@ t('WHIP SDP: parse, DTLS role, RTP parameters and the answer', () => {
});
});
t('WHIP PATCH sdpfrag: credentials, candidates, end-of-candidates; malformed bodies throw', () => {
t('WHIP PATCH sdpfrag: credentials, candidates, end-of-candidates; malformed bodies throw', () => {
- const trickle = sdp.parseIceFragment('a=ice-ufrag:EsAw\r\na=ice-pwd:P2uYro0UCOQ4zxjKXaWCBui1\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:0\r\n'
+ const trickle = sdp.parseIceFragment('a=ice-ufrag:EsAw\r\na=ice-pwd:P2uYro0UCOQ4zxjKXaWCBui1\r\na=ice-options:trickle\r\na=ice-pacing:50\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:0\r\n'
+ 'a=candidate:1387637174 1 udp 2122260223 192.0.2.1 61764 typ host generation 0 ufrag EsAw network-id 1\r\n'
+ 'a=candidate:1387637174 1 udp 2122260223 192.0.2.1 61764 typ host generation 0 ufrag EsAw network-id 1\r\n'
+ 'a=candidate:3471623853 1 udp 2122194687 198.51.100.2 61765 typ host\r\na=end-of-candidates\r\n');
+ 'a=candidate:3471623853 1 udp 2122194687 198.51.100.2 61765 typ host\r\na=end-of-candidates\r\n');
- assert.deepStrictEqual(trickle, { ufrag: 'EsAw', pwd: 'P2uYro0UCOQ4zxjKXaWCBui1', candidates: 2, endOfCandidates: true });
+ assert.deepStrictEqual(trickle, {
+ ufrag: 'EsAw', pwd: 'P2uYro0UCOQ4zxjKXaWCBui1', candidates: 2, endOfCandidates: true,
+ iceOptions: 'trickle', icePacing: '50', iceLite: false,
+ });
// Media-level credentials count too; a bare candidate list has none.
// Media-level credentials count too; a bare candidate list has none.
assert.strictEqual(sdp.parseIceFragment('m=video 9 UDP/TLS/RTP/SAVPF 96\na=ice-ufrag:abc\n').ufrag, 'abc');
assert.strictEqual(sdp.parseIceFragment('m=video 9 UDP/TLS/RTP/SAVPF 96\na=ice-ufrag:abc\n').ufrag, 'abc');
- assert.deepStrictEqual(sdp.parseIceFragment('a=candidate:1 1 tcp 5 192.0.2.1 9 typ host tcptype active\n'), { ufrag: null, pwd: null, candidates: 1, endOfCandidates: false });
+ assert.deepStrictEqual(sdp.parseIceFragment('a=candidate:1 1 tcp 5 192.0.2.1 9 typ host tcptype active\n'), {
+ ufrag: null, pwd: null, candidates: 1, endOfCandidates: false, iceOptions: null, icePacing: null, iceLite: false,
+ });
assert.strictEqual(sdp.parseIceFragment('').candidates, 0);
assert.strictEqual(sdp.parseIceFragment('').candidates, 0);
+ assert.strictEqual(sdp.parseIceFragment('a=ice-lite\r\n').iceLite, true);
for (const bad of ['hello', '{"candidate":"x"}', 'a=candidate:garbage', undefined]) {
for (const bad of ['hello', '{"candidate":"x"}', 'a=candidate:garbage', undefined]) {
assert.throws(() => sdp.parseIceFragment(bad), (err) => err.code === 'invalid_sdpfrag', `rejects ${bad}`);
assert.throws(() => sdp.parseIceFragment(bad), (err) => err.code === 'invalid_sdpfrag', `rejects ${bad}`);
}
}
+
+ // The restart answer is the server's new credentials plus exactly the attributes the request
+ // fragment carried (RFC 9725 §4.3.3): nothing is assumed, ice-lite is not hard-coded.
const frag = sdp.buildIceFragment({ usernameFragment: 'u1', password: 'p1' });
const frag = sdp.buildIceFragment({ usernameFragment: 'u1', password: 'p1' });
- assert.deepStrictEqual(sdp.parseIceFragment(frag), { ufrag: 'u1', pwd: 'p1', candidates: 0, endOfCandidates: false });
- assert.match(frag, /^a=ice-lite\r\n/, 'the restart answer says the server is ICE-lite');
+ assert.deepStrictEqual(sdp.parseIceFragment(frag), {
+ ufrag: 'u1', pwd: 'p1', candidates: 0, endOfCandidates: false, iceOptions: null, icePacing: null, iceLite: false,
+ });
+ assert.ok(!/ice-lite/.test(frag), 'the server does not claim ice-lite the client never sent');
+
+ const mirrored = sdp.parseIceFragment(sdp.buildIceFragment({ usernameFragment: 'u2', password: 'p2' }, trickle));
+ assert.deepStrictEqual(mirrored, { ...trickle, ufrag: 'u2', pwd: 'p2', candidates: 0 });
+ assert.deepStrictEqual(
+ sdp.parseIceFragment(sdp.buildIceFragment({ usernameFragment: 'u3', password: 'p3' }, sdp.parseIceFragment('a=ice-lite\r\na=ice-options:trickle\r\n'))),
+ { ufrag: 'u3', pwd: 'p3', candidates: 0, endOfCandidates: false, iceOptions: 'trickle', icePacing: null, iceLite: true },
+ );
});
});
t('a webrtc session playback descriptor is { signaling_url, announced_ip } and carries thumbnail_url', async () => {
t('a webrtc session playback descriptor is { signaling_url, announced_ip } and carries thumbnail_url', async () => {
@@ -47,7 +47,7 @@ const WHIP_CORS = Object.freeze({
@@ -47,7 +47,7 @@ const WHIP_CORS = Object.freeze({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Methods': 'POST, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, If-Match',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, If-Match',
- 'Access-Control-Expose-Headers': 'Location, X-WHIP-ERROR, ETag',
+ 'Access-Control-Expose-Headers': 'Location, X-WHIP-ERROR, ETag, WWW-Authenticate',
'Access-Control-Max-Age': '86400',
'Access-Control-Max-Age': '86400',
'Cross-Origin-Resource-Policy': 'cross-origin',
'Cross-Origin-Resource-Policy': 'cross-origin',
});
});
@@ -200,7 +200,10 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),
@@ -200,7 +200,10 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),
};
};
function sendWhipError(res, status, code, message) {
function sendWhipError(res, status, code, message) {
- res.writeHead(status, { ...WHIP_CORS, 'content-type': 'application/json', 'X-WHIP-ERROR': code });
+ const headers = { ...WHIP_CORS, 'content-type': 'application/json', 'X-WHIP-ERROR': code };
+ // RFC 9725 §4.5: a 401 tells the client how to authenticate (the same scheme the endpoint takes).
+ if (status === 401) headers['WWW-Authenticate'] = 'Bearer';
+ res.writeHead(status, headers);
res.end(JSON.stringify({ error: message, error_code: code }));
res.end(JSON.stringify({ error: message, error_code: code }));
}
}
@@ -326,7 +329,8 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),
@@ -326,7 +329,8 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),
* trickled candidates are acknowledged and discarded: the client's connectivity checks reach
* trickled candidates are acknowledged and discarded: the client's connectivity checks reach
* the transport's own candidates (sent in the answer) and mediasoup learns the client's address
* the transport's own candidates (sent in the answer) and mediasoup learns the client's address
* from them. New ICE credentials in the fragment are an ICE restart: the transport gets new
* from them. New ICE credentials in the fragment are an ICE restart: the transport gets new
- * credentials, answered 200 with them and a new ETag; the session stays up either way.
+ * credentials, answered 200 with them (and the ICE attributes the client's fragment carried) and
+ * a new ETag; the session stays up either way. Every PATCH must carry If-Match (428 without one).
*/
*/
async function handleWhipPatch(req, res, resourceId) {
async function handleWhipPatch(req, res, resourceId) {
const found = findWhip(resourceId);
const found = findWhip(resourceId);
@@ -336,8 +340,12 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),
@@ -336,8 +340,12 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),
if (!/^application\/trickle-ice-sdpfrag\s*(;|$)/i.test(req.headers['content-type'] || '')) {
if (!/^application\/trickle-ice-sdpfrag\s*(;|$)/i.test(req.headers['content-type'] || '')) {
return sendWhipError(res, 415, 'unsupported_media_type', 'PATCH body must be application/trickle-ice-sdpfrag');
return sendWhipError(res, 415, 'unsupported_media_type', 'PATCH body must be application/trickle-ice-sdpfrag');
}
}
+ // RFC 9725 §4.3.1: a PATCH on a WHIP resource MUST carry If-Match, so a missing one is 428
+ // (not 412: the client never named an ICE session to condition on). '*' and the current
+ // ETag both name the one in force.
const ifMatch = (req.headers['if-match'] || '').trim();
const ifMatch = (req.headers['if-match'] || '').trim();
- if (ifMatch && ifMatch !== '*' && ifMatch !== entry.etag) return sendWhipError(res, 412, 'etag_mismatch', 'If-Match does not name the current ICE session');
+ if (!ifMatch) return sendWhipError(res, 428, 'precondition_required', 'If-Match is required on a WHIP resource (RFC 9725 §4.3.1)');
+ if (ifMatch !== '*' && ifMatch !== entry.etag) return sendWhipError(res, 412, 'etag_mismatch', 'If-Match does not name the current ICE session');
let body;
let body;
try { body = await readBody(req); } catch { return sendWhipError(res, 413, 'body_too_large', 'Fragment too large'); }
try { body = await readBody(req); } catch { return sendWhipError(res, 413, 'body_too_large', 'Fragment too large'); }
let frag;
let frag;
@@ -354,7 +362,7 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),
@@ -354,7 +362,7 @@ function createWebrtc({ rt, log = console, exit = (code) => process.exit(code),
entry.etag = newIceEtag();
entry.etag = newIceEtag();
log.log(`[webrtc] WHIP ICE restart for ${sess.sessionId}`);
log.log(`[webrtc] WHIP ICE restart for ${sess.sessionId}`);
res.writeHead(200, { ...WHIP_CORS, 'content-type': 'application/trickle-ice-sdpfrag', ETag: entry.etag });
res.writeHead(200, { ...WHIP_CORS, 'content-type': 'application/trickle-ice-sdpfrag', ETag: entry.etag });
- return res.end(sdpTool.buildIceFragment(iceParameters));
+ return res.end(sdpTool.buildIceFragment(iceParameters, frag));
}
}
res.writeHead(204, WHIP_CORS);
res.writeHead(204, WHIP_CORS);
res.end();
res.end();
@@ -266,12 +266,14 @@ const CANDIDATE_RE = /^candidate:\S+ \d+ \S+ \d+ \S+ \d+ typ \S+/i;
@@ -266,12 +266,14 @@ const CANDIDATE_RE = /^candidate:\S+ \d+ \S+ \d+ \S+ \d+ typ \S+/i;
/**
/**
* Read a WHIP PATCH body, an RFC 8840 "application/trickle-ice-sdpfrag": the ICE credentials it
* Read a WHIP PATCH body, an RFC 8840 "application/trickle-ice-sdpfrag": the ICE credentials it
- * names (session or media level, the first wins) and how many candidates it carries. Throws
- * (code 'invalid_sdpfrag') on anything that is not an SDP fragment or on a malformed candidate.
+ * names (session or media level, the first wins), how many candidates it carries, and the ICE
+ * attributes the answer fragment is expected to mirror (RFC 9725 §4.3.3: ice-options, ice-pacing,
+ * ice-lite, end-of-candidates). Throws (code 'invalid_sdpfrag') on anything that is not an SDP
+ * fragment or on a malformed candidate.
*/
*/
function parseIceFragment(text) {
function parseIceFragment(text) {
if (typeof text !== 'string') throw Object.assign(new Error('missing sdpfrag'), { code: 'invalid_sdpfrag' });
if (typeof text !== 'string') throw Object.assign(new Error('missing sdpfrag'), { code: 'invalid_sdpfrag' });
- const out = { ufrag: null, pwd: null, candidates: 0, endOfCandidates: false };
+ const out = { ufrag: null, pwd: null, candidates: 0, endOfCandidates: false, iceOptions: null, icePacing: null, iceLite: false };
for (const raw of text.split(/\r?\n/)) {
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
const line = raw.trim();
if (!line) continue;
if (!line) continue;
@@ -281,6 +283,9 @@ function parseIceFragment(text) {
@@ -281,6 +283,9 @@ function parseIceFragment(text) {
if (attr.startsWith('ice-ufrag:')) out.ufrag = out.ufrag || attr.slice(10).trim();
if (attr.startsWith('ice-ufrag:')) out.ufrag = out.ufrag || attr.slice(10).trim();
else if (attr.startsWith('ice-pwd:')) out.pwd = out.pwd || attr.slice(8).trim();
else if (attr.startsWith('ice-pwd:')) out.pwd = out.pwd || attr.slice(8).trim();
else if (attr === 'end-of-candidates') out.endOfCandidates = true;
else if (attr === 'end-of-candidates') out.endOfCandidates = true;
+ else if (attr === 'ice-lite') out.iceLite = true;
+ else if (attr.startsWith('ice-options:')) out.iceOptions = out.iceOptions == null ? attr.slice(12).trim() : out.iceOptions;
+ else if (attr.startsWith('ice-pacing:')) out.icePacing = out.icePacing == null ? attr.slice(11).trim() : out.icePacing;
else if (attr.startsWith('candidate:')) {
else if (attr.startsWith('candidate:')) {
if (!CANDIDATE_RE.test(attr)) throw Object.assign(new Error('malformed candidate'), { code: 'invalid_sdpfrag' });
if (!CANDIDATE_RE.test(attr)) throw Object.assign(new Error('malformed candidate'), { code: 'invalid_sdpfrag' });
out.candidates++;
out.candidates++;
@@ -289,9 +294,20 @@ function parseIceFragment(text) {
@@ -289,9 +294,20 @@ function parseIceFragment(text) {
return out;
return out;
}
}
-/** The sdpfrag a WHIP resource answers an ICE restart with: the media server's new credentials. */
-function buildIceFragment(iceParameters) {
- return ['a=ice-lite', `a=ice-ufrag:${iceParameters.usernameFragment}`, `a=ice-pwd:${iceParameters.password}`, ''].join('\r\n');
+/**
+ * The sdpfrag a WHIP resource answers an ICE restart with (RFC 9725 §4.3.3): the media server's new
+ * credentials, plus exactly the ice-options / ice-pacing / ice-lite / end-of-candidates attributes
+ * the request fragment carried — the server repeats what the client's session declared, it does not
+ * assume any of them (`mirror` is a parsed request fragment; nothing is mirrored by default).
+ */
+function buildIceFragment(iceParameters, mirror = {}) {
+ const lines = [`a=ice-ufrag:${iceParameters.usernameFragment}`, `a=ice-pwd:${iceParameters.password}`];
+ if (mirror.iceOptions) lines.push(`a=ice-options:${mirror.iceOptions}`);
+ if (mirror.icePacing) lines.push(`a=ice-pacing:${mirror.icePacing}`);
+ if (mirror.iceLite) lines.push('a=ice-lite');
+ if (mirror.endOfCandidates) lines.push('a=end-of-candidates');
+ lines.push('');
+ return lines.join('\r\n');
}
}
/**
/**