@torrent-tv/proxy 2.100.11 → 2.100.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -18,6 +18,7 @@ import os from "node:os";
18
18
  import { spawnSync } from "node:child_process";
19
19
  import { createRequire } from "node:module";
20
20
  import ffmpegStatic from "ffmpeg-static";
21
+ import nodeDataChannel from "node-datachannel";
21
22
  import { startProxyServer } from "../server.js";
22
23
  import { registerClient } from "../services/transport/registry-api.js";
23
24
  import { createTunnelClient } from "../services/transport/tunnel-client.js";
@@ -689,6 +690,7 @@ try {
689
690
  });
690
691
 
691
692
  webRtcManager = createWebRtcManager({
693
+ rtc: nodeDataChannel,
692
694
  // Single UDP port (UPnP-mapped above) shared by all sessions via a
693
695
  // persistent ICE UDP mux listener, so the WebRTC path is reachable from the
694
696
  // internet on one fixed port.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.100.11",
3
+ "version": "2.100.13",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * @file WebRTC connection manager for the proxy (Node.js side).
3
3
  *
4
- * Maintains one `PeerConnection` per browser session, keyed by `sessionId`.
4
+ * Maintains a media connection and an optional independent control connection
5
+ * per browser session. Control signals carry a generation's `connectionId`.
5
6
  * Receives SDP offers and ICE candidates through the signalling tunnel
6
7
  * and responds with SDP answers and its own candidates through the same channel.
7
8
  *
@@ -9,8 +10,6 @@
9
10
  * via the `onDataChannel` callback — this module only owns the signalling phase.
10
11
  */
11
12
 
12
- import nodeDataChannel from "node-datachannel";
13
-
14
13
  /** @import { WebRtcSignal } from './tunnel-client.js' */
15
14
 
16
15
  // Two STUN servers from different operators, both with IPv6 (AAAA) records, so
@@ -148,6 +147,8 @@ function candidateAddrKind(candidate) {
148
147
  * Configuration for the WebRTC manager.
149
148
  *
150
149
  * @typedef {Object} WebRtcManagerOptions
150
+ * @property {typeof import("node-datachannel").default} rtc
151
+ * The peer connection implementation, supplied by the composition root.
151
152
  * @property {(sessionId: string, signal: WebRtcSignal) => void} sendSignal
152
153
  * Send a WebRTC signal (answer or ICE candidate) back to the browser via
153
154
  * the tunnel. Typically wired to `tunnelClient.sendSignal`.
@@ -196,9 +197,10 @@ function candidateAddrKind(candidate) {
196
197
  * @param {WebRtcManagerOptions} options
197
198
  * @returns {WebRtcManager}
198
199
  */
199
- export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort, getNatInfo }) {
200
+ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort, getNatInfo, rtc }) {
200
201
  /** @type {Map<string, import("node-datachannel").PeerConnection>} */
201
202
  const peers = new Map();
203
+ const controlPeers = new Map();
202
204
 
203
205
  /**
204
206
  * @param {string} message
@@ -222,7 +224,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
222
224
  let udpMuxListener = null;
223
225
  if (Number.isInteger(udpPort) && udpPort > 0 && udpPort <= 65535) {
224
226
  try {
225
- udpMuxListener = new nodeDataChannel.IceUdpMuxListener(udpPort);
227
+ udpMuxListener = new rtc.IceUdpMuxListener(udpPort);
226
228
  // STUN that doesn't match an existing session (our PeerConnections are
227
229
  // created from the SDP offer before the browser's STUN arrives, so normal
228
230
  // traffic is "handled"). Stray/unhandled requests are simply dropped.
@@ -249,13 +251,23 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
249
251
  * @param {string} sessionId
250
252
  * @returns {import("node-datachannel").PeerConnection}
251
253
  */
252
- function getOrCreatePeer(sessionId) {
253
- const existing = peers.get(sessionId);
254
+ function getOrCreatePeer(sessionId, connectionId = null) {
255
+ const peerId = connectionId === null ? sessionId : `${sessionId}/${connectionId}`;
256
+ const existing = peers.get(peerId);
254
257
  if (existing) {
255
258
  return existing;
256
259
  }
257
260
 
258
- const pc = new nodeDataChannel.PeerConnection(`proxy-${sessionId.slice(0, 8)}`, pcConfig);
261
+ if (connectionId !== null) {
262
+ const previous = controlPeers.get(sessionId);
263
+ if (previous) closePeer(previous);
264
+ }
265
+ const pc = new rtc.PeerConnection(`proxy-${peerId}`, pcConfig);
266
+ if (connectionId !== null) controlPeers.set(sessionId, peerId);
267
+ const signalBrowser = (signal) => sendSignal(sessionId, {
268
+ ...signal,
269
+ ...(connectionId === null ? {} : { connectionId })
270
+ });
259
271
 
260
272
  // Forward all ICE candidates to the browser through the tunnel.
261
273
  //
@@ -272,7 +284,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
272
284
  log(
273
285
  `[webrtc] Session ${sessionId.slice(0, 8)}: sending ${kind} candidate: ${candidate.replace(/^a=/, "")}`
274
286
  );
275
- sendSignal(sessionId, { type: "candidate", candidate, mid });
287
+ signalBrowser({ type: "candidate", candidate, mid });
276
288
 
277
289
  // Symmetric-NAT port prediction: offer extra srflx candidates at the
278
290
  // predicted external ports so the browser probes them too. No-op unless
@@ -287,7 +299,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
287
299
  `[webrtc] Session ${sessionId.slice(0, 8)}: symmetric NAT (delta=${nat.portDelta}) — injecting ${predicted.length} predicted srflx candidates: ${predicted.map((p) => p.port).join(",")}`
288
300
  );
289
301
  for (const p of predicted) {
290
- sendSignal(sessionId, { type: "candidate", candidate: p.candidate, mid });
302
+ signalBrowser({ type: "candidate", candidate: p.candidate, mid });
291
303
  }
292
304
  }
293
305
  }
@@ -304,7 +316,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
304
316
  // Forward our SDP answer to the browser through the tunnel.
305
317
  pc.onLocalDescription((sdp, type) => {
306
318
  log(`[webrtc] Session ${sessionId.slice(0, 8)}: sending ${type}`);
307
- sendSignal(sessionId, { type, sdp });
319
+ signalBrowser({ type, sdp, ...(connectionId === null ? { separateControl: 2 } : {}) });
308
320
  });
309
321
 
310
322
  pc.onStateChange((state) => {
@@ -328,7 +340,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
328
340
  // "disconnected" is a transient state — ICE may recover on its own.
329
341
  // Only tear down on terminal states: "failed" and "closed".
330
342
  if (state === "failed" || state === "closed") {
331
- closeSession(sessionId);
343
+ if (peers.get(peerId) === pc) closePeer(peerId);
332
344
  }
333
345
  });
334
346
 
@@ -340,11 +352,15 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
340
352
 
341
353
  // Browser creates the data channel — we receive it here.
342
354
  pc.onDataChannel((channel) => {
343
- log(`[webrtc] Session ${sessionId.slice(0, 8)}: data channel "${channel.getLabel()}" opened`);
344
- onDataChannel(sessionId, channel);
355
+ if (peers.get(peerId) !== pc || (connectionId !== null && channel.getLabel() !== "proxy-control")) {
356
+ channel.close();
357
+ return;
358
+ }
359
+ log(`[webrtc] Session ${sessionId.slice(0, 8)}: ${connectionId === null ? "media" : `control ${connectionId}`} channel "${channel.getLabel()}" opened`);
360
+ onDataChannel(peerId, channel);
345
361
  });
346
362
 
347
- peers.set(sessionId, pc);
363
+ peers.set(peerId, pc);
348
364
  return pc;
349
365
  }
350
366
 
@@ -364,6 +380,9 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
364
380
  if (!signal || typeof signal.type !== "string") {
365
381
  return;
366
382
  }
383
+ const connectionId = signal.connectionId ?? null;
384
+ if (connectionId !== null && (typeof connectionId !== "string" || !/^[a-zA-Z0-9-]{1,64}$/.test(connectionId))) return;
385
+ const peerId = connectionId === null ? sessionId : `${sessionId}/${connectionId}`;
367
386
 
368
387
  if (signal.type === "offer") {
369
388
  if (typeof signal.sdp !== "string") {
@@ -375,19 +394,19 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
375
394
  // single bad session must never crash the whole proxy — that would drop
376
395
  // every other viewer and the tunnel. Contain it: fail this session only.
377
396
  try {
378
- const pc = getOrCreatePeer(sessionId);
397
+ const pc = getOrCreatePeer(sessionId, connectionId);
379
398
  pc.setRemoteDescription(signal.sdp, "offer");
380
399
  // `onLocalDescription` fires automatically with the SDP answer.
381
400
  } catch (error) {
382
401
  const message = error instanceof Error ? error.message : String(error);
383
402
  log(`[webrtc] Session ${sessionId.slice(0, 8)}: failed to handle offer: ${message}`);
384
- closeSession(sessionId);
403
+ closePeer(peerId);
385
404
  }
386
405
  return;
387
406
  }
388
407
 
389
408
  if (signal.type === "candidate") {
390
- const pc = peers.get(sessionId);
409
+ const pc = peers.get(peerId);
391
410
  if (!pc) {
392
411
  return;
393
412
  }
@@ -409,15 +428,23 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
409
428
  * @param {string} sessionId
410
429
  * @returns {void}
411
430
  */
412
- function closeSession(sessionId) {
413
- const pc = peers.get(sessionId);
431
+ function closePeer(peerId) {
432
+ const pc = peers.get(peerId);
414
433
  if (pc) {
434
+ peers.delete(peerId);
435
+ const sessionId = peerId.split("/")[0];
436
+ if (controlPeers.get(sessionId) === peerId) controlPeers.delete(sessionId);
415
437
  try { pc.close(); } catch { /* ignore */ }
416
- peers.delete(sessionId);
417
- log(`[webrtc] Session ${sessionId.slice(0, 8)}: closed`);
438
+ log(`[webrtc] Session ${sessionId.slice(0, 8)}: peer ${peerId} closed`);
418
439
  }
419
440
  }
420
441
 
442
+ function closeSession(sessionId) {
443
+ const control = controlPeers.get(sessionId);
444
+ if (control) closePeer(control);
445
+ closePeer(sessionId);
446
+ }
447
+
421
448
  /**
422
449
  * Close all peer connections and stop the shared UDP mux listener.
423
450
  *
@@ -425,7 +452,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort,
425
452
  */
426
453
  function dispose() {
427
454
  for (const sessionId of [...peers.keys()]) {
428
- closeSession(sessionId);
455
+ closePeer(sessionId);
429
456
  }
430
457
  if (udpMuxListener) {
431
458
  try { udpMuxListener.stop(); } catch { /* ignore */ }