@torrent-tv/proxy 2.9.19 → 2.9.20

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/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 2.9.22
2
+
3
+ - **Fix**: The proxy no longer crashes on a repeat/remote WebRTC session (regression from 2.9.18). Two causes, both fixed: (1) `webrtc-manager.handleSignal` called `setRemoteDescription`/`addRemoteCandidate` with **no try/catch**, so when node-datachannel threw synchronously (`Failed to gather local ICE candidates`) the whole process died — killing every viewer and the tunnel — and was restarted by s6. It now contains the error per session (logs + closes only that session, never throws out of the handler). (2) Root cause of the gather failure: 2.9.18 set `enableIceUdpMux` per-PeerConnection but with **no persistent mux owner**, so the shared UDP socket was bound/freed with each connection — a session opened while a just-closed one still held the fixed port could not bind it and failed to gather. Fixed by creating ONE persistent `IceUdpMuxListener` on the fixed UDP port once at startup (owned by the WebRTC manager for the proxy's whole lifetime, released on shutdown via `dispose()`); every session keeps `enableIceUdpMux` + the same port and demuxes over the shared socket by ICE ufrag. This keeps the clean single-port model (one UDP port, one UPnP mapping, one reachable endpoint) while surviving session churn. Verified against libdatachannel issue #861 and locally: 5 sequential + 2 concurrent PeerConnections all gather on the one port with no error, and the srflx candidate carries the fixed port.
4
+
5
+ ## 2.9.21
6
+
7
+ - **Chore**: Diagnostic — the `/api/sources/:key/stats` route now logs the real swarm state on every poll: `[stats] <key> peers=N down=NKB/s file=N% header=down/totalB`. This surfaces a cold-start download stall (0 peers / header not advancing), which is what makes `POST /api/playback-plan` block on the codec probe until the browser's data-channel request times out. (Diagnosis: on the first/cold attempt the file header has not downloaded within ~60 s — likely worsened by `uTP not supported` on arm64/musl limiting peers — so the blocking probe times out; a warm attempt minutes later, with the header already cached, probes in ~25 ms and plays. Verified by the same torrent failing cold on cellular and playing warm on desktop.)
8
+
1
9
  ## 2.9.20
2
10
 
3
11
  - **New**: Startup NAT classification (`services/nat-classifier.js`, dependency-free — `node:dgram` + `node:crypto`). From a single local UDP socket the proxy sends a STUN Binding Request to two different public STUN servers (Google + Cloudflare) and compares the reflexive external port: same → **endpoint-independent (cone)** NAT (the fixed-port WebRTC mapping from 2.9.18 is sufficient, no port prediction needed); different → **symmetric** NAT (the mapped port varies per viewer, so WebRTC will need port prediction — a later roadmap step). The class is logged at startup. Best-effort: STUN probes are time-bounded and never block startup; an inconclusive probe is logged and ignored. Uses the modern dual-server, single-socket test (no RFC 3489 CHANGE-REQUEST, which public STUN servers like Google's do not support). Evaluated `@xmcl/stun-client`/`stun` (both MIT) but their public APIs create a fresh socket per query and/or rely on CHANGE-REQUEST, which is wrong for this test — hence the minimal in-house client.
package/bin/cli.js CHANGED
@@ -126,6 +126,9 @@ let portMapper = null;
126
126
  /** @type {ReturnType<typeof createPortMapper> | null} UDP mapping for the WebRTC port. */
127
127
  let udpPortMapper = null;
128
128
 
129
+ /** @type {ReturnType<typeof createWebRtcManager> | null} */
130
+ let webRtcManager = null;
131
+
129
132
 
130
133
  /**
131
134
  * Register this proxy with the registry server.
@@ -204,6 +207,11 @@ async function shutdown(signal) {
204
207
  await udpPortMapper.stop();
205
208
  udpPortMapper = null;
206
209
  }
210
+ // Close WebRTC sessions and release the shared UDP mux listener socket.
211
+ if (webRtcManager) {
212
+ try { webRtcManager.dispose(); } catch { /* ignore */ }
213
+ webRtcManager = null;
214
+ }
207
215
  if (app) {
208
216
  await app.close();
209
217
  }
@@ -236,6 +244,11 @@ try {
236
244
  logger.info(`Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`);
237
245
  }
238
246
 
247
+ // All WebRTC sessions are multiplexed onto this single UDP port (same number
248
+ // as the HTTP port, different protocol) via a persistent ICE UDP mux listener
249
+ // in the WebRTC manager. One port → one UPnP mapping → one reachable endpoint.
250
+ const webrtcUdpPort = actualPort;
251
+
239
252
  // Try to open the local port on the home router (UPnP/NAT-PMP) so the proxy
240
253
  // is reachable from the internet without manual port forwarding. Best-effort
241
254
  // and fire-and-forget: failure is normal (router without UPnP) and must not
@@ -257,13 +270,11 @@ try {
257
270
  logger.warn(`Port mapping failed to start: ${message}`);
258
271
  });
259
272
 
260
- // Also map the WebRTC UDP port (same number, different protocol). All
261
- // WebRTC sessions are multiplexed onto this single UDP port (ICE UDP mux),
262
- // so a static mapping makes the proxy's WebRTC path reachable even behind
263
- // symmetric NAT. This endpoint is NOT reported to the server: the browser
264
- // discovers it via ICE (srflx) candidates, not the TCP dial-back probe.
273
+ // Also map the single WebRTC UDP port. Not reported to the server: the
274
+ // browser discovers the endpoint via ICE (srflx) candidates, not the TCP
275
+ // dial-back probe.
265
276
  udpPortMapper = createPortMapper({
266
- port: actualPort,
277
+ port: webrtcUdpPort,
267
278
  protocol: "UDP",
268
279
  description: "torrent-tv proxy (WebRTC)"
269
280
  });
@@ -302,10 +313,9 @@ try {
302
313
  // The WebRTC manager handles the actual peer connection and data channel.
303
314
  //
304
315
  // We use a late-binding ref so both objects can reference each other without
305
- // running into the TDZ (tunnelClient is declared above; webRtcManager uses let
306
- // so the closure in createTunnelClient can call it after both are initialised).
307
- /** @type {ReturnType<typeof createWebRtcManager> | null} */
308
- let webRtcManager = null;
316
+ // running into the TDZ (tunnelClient is declared above; webRtcManager is the
317
+ // module-scoped `let` above so the closure in createTunnelClient and the
318
+ // shutdown handler can reach it after initialisation).
309
319
 
310
320
  tunnelClient = createTunnelClient({
311
321
  serverUrl,
@@ -342,9 +352,10 @@ try {
342
352
  });
343
353
 
344
354
  webRtcManager = createWebRtcManager({
345
- // Pin all WebRTC sessions to this single UDP port (multiplexed via ICE UDP
346
- // mux) so the UPnP UDP mapping above makes the WebRTC path reachable.
347
- udpPort: actualPort,
355
+ // Single UDP port (UPnP-mapped above) shared by all sessions via a
356
+ // persistent ICE UDP mux listener, so the WebRTC path is reachable from the
357
+ // internet on one fixed port.
358
+ udpPort: webrtcUdpPort,
348
359
  sendSignal(sessionId, signal) {
349
360
  tunnelClient?.sendSignal(sessionId, signal);
350
361
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.19",
3
+ "version": "2.9.20",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1,3 +1,5 @@
1
+ import { logger } from "../../../../utils/logger.js";
2
+
1
3
  /**
2
4
  * Return download statistics for a registered torrent source.
3
5
  *
@@ -38,5 +40,20 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
38
40
  const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
39
41
  const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
40
42
 
41
- return reply.send(torrentPool.getFileStats(torrent, fileIndex));
43
+ const stats = torrentPool.getFileStats(torrent, fileIndex);
44
+
45
+ // Diagnostic: surface the real swarm state per poll so a cold-start download
46
+ // stall (0 peers / header not advancing → playback-plan blocks on the codec
47
+ // probe → browser timeout) is visible in the proxy log.
48
+ const downKbps = (stats.downloadSpeed / 1024).toFixed(0);
49
+ const filePct = stats.fileProgress != null ? `${(stats.fileProgress * 100).toFixed(1)}%` : "n/a";
50
+ const header =
51
+ stats.headerBytes != null
52
+ ? `${stats.headerDownloadedBytes}/${stats.headerBytes}B`
53
+ : "n/a";
54
+ logger.info(
55
+ `[stats] ${sourceKey.slice(0, 8)} peers=${stats.numPeers} down=${downKbps}KB/s file=${filePct} header=${header}`
56
+ );
57
+
58
+ return reply.send(stats);
42
59
  }
@@ -85,14 +85,24 @@ function withTimeout(promise, ms, label) {
85
85
  * @param {"TCP" | "UDP"} [opts.protocol] - Protocol to map. Defaults to "TCP" (the HTTP/stream port).
86
86
  * @param {string} [opts.description] - Human-readable label shown in the router's port-mapping table.
87
87
  * @param {number} [opts.ttlSeconds] - Lease time in seconds. Defaults to {@link DEFAULT_TTL_SECONDS}.
88
+ * @param {number} [opts.portRangeEnd] - When set and > `port`, map the whole
89
+ * contiguous range `[port..portRangeEnd]` (used for the WebRTC UDP range, so
90
+ * whichever port a session binds is reachable). Single port otherwise.
88
91
  * @returns {PortMapper}
89
92
  */
90
93
  export function createPortMapper({
91
94
  port,
92
95
  protocol = "TCP",
93
96
  description = "torrent-tv proxy",
94
- ttlSeconds = DEFAULT_TTL_SECONDS
97
+ ttlSeconds = DEFAULT_TTL_SECONDS,
98
+ portRangeEnd
95
99
  } = {}) {
100
+ const rangeEnd = Number.isInteger(portRangeEnd) && portRangeEnd > port ? portRangeEnd : null;
101
+ const portList = rangeEnd
102
+ ? Array.from({ length: rangeEnd - port + 1 }, (_, i) => port + i)
103
+ : [port];
104
+ const label = rangeEnd ? `${protocol} ${port}-${rangeEnd}` : `${protocol} ${port}`;
105
+
96
106
  /** @type {InstanceType<typeof NatAPI> | null} */
97
107
  let nat = null;
98
108
  /** @type {MappedEndpoint | null} */
@@ -110,11 +120,11 @@ export function createPortMapper({
110
120
  try {
111
121
  await withTimeout(instance.destroy(), STOP_TIMEOUT_MS, "destroy");
112
122
  if (logRemoval) {
113
- logger.info(`port-mapper: removed mapping for ${protocol} ${port}`);
123
+ logger.info(`port-mapper: removed mapping for ${label}`);
114
124
  }
115
125
  } catch (error) {
116
126
  // Lease expiry (ttl) is the backstop if we cannot unmap cleanly.
117
- logger.warn(`port-mapper: failed to remove ${protocol} ${port} mapping cleanly: ${describeError(error)}`);
127
+ logger.warn(`port-mapper: failed to remove ${label} mapping cleanly: ${describeError(error)}`);
118
128
  }
119
129
  }
120
130
 
@@ -133,16 +143,25 @@ export function createPortMapper({
133
143
  }
134
144
 
135
145
  let instance;
146
+ let mappedCount = 0;
136
147
  try {
137
148
  instance = new NatAPI({ ttl: ttlSeconds, autoUpdate: true, description });
138
- await withTimeout(
139
- instance.map({ publicPort: port, privatePort: port, protocol, description, ttl: ttlSeconds }),
140
- START_TIMEOUT_MS,
141
- "map"
142
- );
149
+ // Map every port in the range, best-effort per port (one port failing
150
+ // must not abort the rest). All share the one NatAPI instance, so its
151
+ // auto-renew covers them and a single destroy() unmaps all.
152
+ for (const p of portList) {
153
+ try {
154
+ await withTimeout(
155
+ instance.map({ publicPort: p, privatePort: p, protocol, description, ttl: ttlSeconds }),
156
+ START_TIMEOUT_MS,
157
+ `map ${p}`
158
+ );
159
+ mappedCount++;
160
+ } catch (error) {
161
+ logger.warn(`port-mapper: failed to map ${protocol} ${p}: ${describeError(error)}`);
162
+ }
163
+ }
143
164
  } catch (error) {
144
- // No UPnP/NAT-PMP on this router, or it declined. Normal, non-fatal: the
145
- // proxy still works on LAN and wherever hole punching succeeds.
146
165
  mappedEndpoint = null;
147
166
  logger.warn(`port-mapper: no port mapping available (${describeError(error)}); continuing without it`);
148
167
  if (instance) {
@@ -151,8 +170,17 @@ export function createPortMapper({
151
170
  return;
152
171
  }
153
172
 
173
+ if (mappedCount === 0) {
174
+ // No UPnP/NAT-PMP on this router, or it declined. Normal, non-fatal: the
175
+ // proxy still works on LAN and wherever hole punching succeeds.
176
+ mappedEndpoint = null;
177
+ logger.warn(`port-mapper: no ports mapped for ${label}; continuing without it`);
178
+ await safeDestroy(instance);
179
+ return;
180
+ }
181
+
154
182
  // Mapping succeeded — keep the instance so its auto-renew timers stay alive
155
- // and stop() can remove the mapping later.
183
+ // and stop() can remove the mappings later.
156
184
  nat = instance;
157
185
 
158
186
  // Discover the external IP (best-effort; the mapping is valid without it).
@@ -160,17 +188,18 @@ export function createPortMapper({
160
188
  try {
161
189
  externalIp = await withTimeout(instance.externalIp(), START_TIMEOUT_MS, "externalIp");
162
190
  } catch (error) {
163
- logger.warn(`port-mapper: mapped ${protocol} ${port} but could not read external IP: ${describeError(error)}`);
191
+ logger.warn(`port-mapper: mapped ${label} but could not read external IP: ${describeError(error)}`);
164
192
  }
165
193
 
166
194
  mappedEndpoint = { externalIp: externalIp || null, externalPort: port, protocol };
195
+ const counts = portList.length > 1 ? ` (${mappedCount}/${portList.length} ports)` : "";
167
196
  if (externalIp) {
168
197
  logger.success(
169
- `port-mapper: mapped ${externalIp}:${port} → ${protocol} ${port} (ttl ${ttlSeconds}s, auto-renew)`
198
+ `port-mapper: mapped ${externalIp} → ${label}${counts} (ttl ${ttlSeconds}s, auto-renew)`
170
199
  );
171
200
  } else {
172
201
  logger.info(
173
- `port-mapper: mapped ${protocol} ${port} (external IP unknown; ttl ${ttlSeconds}s, auto-renew)`
202
+ `port-mapper: mapped ${label}${counts} (external IP unknown; ttl ${ttlSeconds}s, auto-renew)`
174
203
  );
175
204
  }
176
205
  }
@@ -71,10 +71,19 @@ function isPrivateHostCandidate(candidate) {
71
71
  * @property {(message: string) => void} [onLog]
72
72
  * Optional log sink.
73
73
  * @property {number} [udpPort]
74
- * When set, every PeerConnection is pinned to this single UDP port and ICE
75
- * UDP multiplexing is enabled, so all sessions share one port that can be
76
- * statically UPnP-mapped (makes the WebRTC path reachable behind NAT). When
77
- * omitted, node-datachannel uses an ephemeral UDP port (previous behaviour).
74
+ * When set, all WebRTC sessions are multiplexed onto this single UDP port so
75
+ * it can be statically UPnP-mapped (one mapping, one reachable port). A
76
+ * persistent {@link import("node-datachannel").IceUdpMuxListener} is created
77
+ * once at startup and owns the shared socket; every PeerConnection enables
78
+ * `enableIceUdpMux` on the same port and demuxes over it by ICE ufrag.
79
+ *
80
+ * The persistent listener is the crucial part: per-PeerConnection
81
+ * `enableIceUdpMux` WITHOUT it ties the shared socket to a connection's
82
+ * lifetime, so a freshly-opened session fails to bind the port while a
83
+ * just-closed one still holds it → "Failed to gather local ICE candidates"
84
+ * (which crashed the proxy). The listener keeps the socket alive across
85
+ * sessions; verified with sequential + concurrent connections on one port.
86
+ * When omitted, node-datachannel uses an ephemeral UDP port.
78
87
  */
79
88
 
80
89
  /**
@@ -86,6 +95,9 @@ function isPrivateHostCandidate(candidate) {
86
95
  * the matching peer connection, creating it if necessary.
87
96
  * @property {(sessionId: string) => void} closeSession
88
97
  * Tear down and remove a peer connection by session ID.
98
+ * @property {() => void} dispose
99
+ * Close all peer connections and stop the shared UDP mux listener. Call on
100
+ * proxy shutdown so the listener's socket is released.
89
101
  */
90
102
 
91
103
  /**
@@ -98,16 +110,6 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
98
110
  /** @type {Map<string, import("node-datachannel").PeerConnection>} */
99
111
  const peers = new Map();
100
112
 
101
- // Base PeerConnection config shared by every session. When a UDP port is
102
- // configured, pin all sessions to it and enable ICE UDP mux so they share the
103
- // single (UPnP-mapped) port; otherwise fall back to an ephemeral UDP port.
104
- const pcConfig = { iceServers: ICE_SERVERS };
105
- if (Number.isInteger(udpPort) && udpPort > 0 && udpPort <= 65535) {
106
- pcConfig.enableIceUdpMux = true;
107
- pcConfig.portRangeBegin = udpPort;
108
- pcConfig.portRangeEnd = udpPort;
109
- }
110
-
111
113
  /**
112
114
  * @param {string} message
113
115
  * @returns {void}
@@ -118,6 +120,36 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
118
120
  }
119
121
  }
120
122
 
123
+ // Base PeerConnection config shared by every session.
124
+ const pcConfig = { iceServers: ICE_SERVERS };
125
+
126
+ // Single-port UDP mux: create ONE persistent listener that owns the shared
127
+ // UDP socket for the proxy's whole lifetime, then have every PeerConnection
128
+ // mux over it (enableIceUdpMux + the same fixed port). The listener must
129
+ // outlive individual sessions — without it the socket is bound/freed per
130
+ // connection and a repeat session fails to gather ICE candidates.
131
+ /** @type {import("node-datachannel").IceUdpMuxListener | null} */
132
+ let udpMuxListener = null;
133
+ if (Number.isInteger(udpPort) && udpPort > 0 && udpPort <= 65535) {
134
+ try {
135
+ udpMuxListener = new nodeDataChannel.IceUdpMuxListener(udpPort);
136
+ // STUN that doesn't match an existing session (our PeerConnections are
137
+ // created from the SDP offer before the browser's STUN arrives, so normal
138
+ // traffic is "handled"). Stray/unhandled requests are simply dropped.
139
+ udpMuxListener.onUnhandledStunRequest(() => {});
140
+ pcConfig.enableIceUdpMux = true;
141
+ pcConfig.portRangeBegin = udpPort;
142
+ pcConfig.portRangeEnd = udpPort;
143
+ log(`[webrtc] UDP mux listener bound on port ${udpPort}; all sessions share it`);
144
+ } catch (error) {
145
+ // Could not bind the mux port — fall back to ephemeral UDP ports (no
146
+ // single-port reachability, but the proxy still works on LAN / via STUN).
147
+ const message = error instanceof Error ? error.message : String(error);
148
+ log(`[webrtc] UDP mux listener failed on port ${udpPort} (${message}); using ephemeral ports`);
149
+ udpMuxListener = null;
150
+ }
151
+ }
152
+
121
153
  /**
122
154
  * Retrieve an existing peer connection or create a new one for the session.
123
155
  *
@@ -225,9 +257,19 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
225
257
  return;
226
258
  }
227
259
  log(`[webrtc] Session ${sessionId.slice(0, 8)}: received offer`);
228
- const pc = getOrCreatePeer(sessionId);
229
- pc.setRemoteDescription(signal.sdp, "offer");
230
- // `onLocalDescription` fires automatically with the SDP answer.
260
+ // node-datachannel can throw SYNCHRONOUSLY here (e.g. "Failed to gather
261
+ // local ICE candidates" when the pinned UDP port cannot be bound). A
262
+ // single bad session must never crash the whole proxy — that would drop
263
+ // every other viewer and the tunnel. Contain it: fail this session only.
264
+ try {
265
+ const pc = getOrCreatePeer(sessionId);
266
+ pc.setRemoteDescription(signal.sdp, "offer");
267
+ // `onLocalDescription` fires automatically with the SDP answer.
268
+ } catch (error) {
269
+ const message = error instanceof Error ? error.message : String(error);
270
+ log(`[webrtc] Session ${sessionId.slice(0, 8)}: failed to handle offer: ${message}`);
271
+ closeSession(sessionId);
272
+ }
231
273
  return;
232
274
  }
233
275
 
@@ -237,7 +279,12 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
237
279
  return;
238
280
  }
239
281
  if (typeof signal.candidate === "string" && typeof signal.mid === "string") {
240
- pc.addRemoteCandidate(signal.candidate, signal.mid);
282
+ try {
283
+ pc.addRemoteCandidate(signal.candidate, signal.mid);
284
+ } catch (error) {
285
+ const message = error instanceof Error ? error.message : String(error);
286
+ log(`[webrtc] Session ${sessionId.slice(0, 8)}: failed to add candidate: ${message}`);
287
+ }
241
288
  }
242
289
  }
243
290
  }
@@ -258,5 +305,20 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
258
305
  }
259
306
  }
260
307
 
261
- return { handleSignal, closeSession };
308
+ /**
309
+ * Close all peer connections and stop the shared UDP mux listener.
310
+ *
311
+ * @returns {void}
312
+ */
313
+ function dispose() {
314
+ for (const sessionId of [...peers.keys()]) {
315
+ closeSession(sessionId);
316
+ }
317
+ if (udpMuxListener) {
318
+ try { udpMuxListener.stop(); } catch { /* ignore */ }
319
+ udpMuxListener = null;
320
+ }
321
+ }
322
+
323
+ return { handleSignal, closeSession, dispose };
262
324
  }