@torrent-tv/proxy 2.5.2 → 2.5.7

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 ADDED
@@ -0,0 +1,17 @@
1
+ ## 2.5.6
2
+
3
+ - **Fix**: ICE candidate filtering — private host candidates (RFC 1918, Docker bridge IPs, IPv6 ULA/loopback) are now buffered and suppressed when a public srflx candidate is available. This eliminates the Chrome/Brave Private Network Access permission dialog when connecting from a page served over HTTPS. Falls back to private candidates if no public srflx candidate is gathered (e.g. STUN unreachable), so connectivity is preserved at the cost of the PNA dialog.
4
+
5
+ ## 2.5.5
6
+
7
+ - **Fix**: Tunnel keepalive — proxy now sends a WebSocket ping to the server every 30 s to prevent Cloudflare's ~100 s idle-connection timeout from dropping the tunnel.
8
+
9
+ ## 2.5.3
10
+
11
+ - Internal: improved tunnel reconnect logic and error logging.
12
+
13
+ ## 2.0.0
14
+
15
+ - **New**: WebRTC P2P tunnel architecture — replaced direct HTTP streaming with a persistent WebSocket tunnel to the server. Video is delivered from the proxy to the browser over a WebRTC data channel; the server acts only as a signalling relay.
16
+ - **New**: `node-datachannel` dependency for server-side WebRTC.
17
+ - **Removed**: `public_base_url` config — no longer needed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.5.2",
3
+ "version": "2.5.7",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -75,6 +75,8 @@ import { WebSocket } from "ws";
75
75
  */
76
76
 
77
77
  const RECONNECT_DELAY_MS = 5_000;
78
+ /** Send a keepalive ping every 30 s to prevent Cloudflare's idle WebSocket timeout (~100 s). */
79
+ const KEEPALIVE_INTERVAL_MS = 30_000;
78
80
 
79
81
  /**
80
82
  * Create and manage the outbound WebSocket tunnel to the registry server.
@@ -89,6 +91,8 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onSig
89
91
  let socket = null;
90
92
  /** @type {ReturnType<typeof setTimeout> | null} */
91
93
  let reconnectTimer = null;
94
+ /** @type {ReturnType<typeof setInterval> | null} */
95
+ let keepaliveTimer = null;
92
96
  let stopped = false;
93
97
 
94
98
  /**
@@ -118,12 +122,19 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onSig
118
122
  socket = new WebSocket(wsUrl, {
119
123
  headers: {
120
124
  "x-proxy-id": proxyId,
121
- "x-proxy-token": token
125
+ "x-proxy-token": token,
126
+ "user-agent": "torrent-tv-proxy/1.0"
122
127
  }
123
128
  });
124
129
 
125
130
  socket.addEventListener("open", () => {
126
131
  log("Tunnel connected.");
132
+ // Start keepalive pings to prevent Cloudflare's idle WebSocket timeout.
133
+ keepaliveTimer = setInterval(() => {
134
+ if (socket && socket.readyState === WebSocket.OPEN) {
135
+ send({ type: "ping" });
136
+ }
137
+ }, KEEPALIVE_INTERVAL_MS);
127
138
  if (typeof onConnect === "function") {
128
139
  onConnect();
129
140
  }
@@ -163,6 +174,10 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onSig
163
174
  socket.addEventListener("close", (event) => {
164
175
  log(`Tunnel disconnected (code=${event.code}). Reconnecting in ${RECONNECT_DELAY_MS}ms...`);
165
176
  socket = null;
177
+ if (keepaliveTimer !== null) {
178
+ clearInterval(keepaliveTimer);
179
+ keepaliveTimer = null;
180
+ }
166
181
  if (!stopped) {
167
182
  reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
168
183
  }
@@ -273,6 +288,10 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onSig
273
288
  */
274
289
  disconnect() {
275
290
  stopped = true;
291
+ if (keepaliveTimer !== null) {
292
+ clearInterval(keepaliveTimer);
293
+ keepaliveTimer = null;
294
+ }
276
295
  if (reconnectTimer != null) {
277
296
  clearTimeout(reconnectTimer);
278
297
  reconnectTimer = null;
@@ -15,6 +15,49 @@ import nodeDataChannel from "node-datachannel";
15
15
 
16
16
  const ICE_SERVERS = ["stun:stun.l.google.com:19302"];
17
17
 
18
+ /**
19
+ * Return true when the ICE candidate string describes a `typ host` candidate
20
+ * with a private (RFC 1918 / ULA / loopback) IP address.
21
+ *
22
+ * Browsers enforce Private Network Access (PNA) and show a permission dialog
23
+ * when a page served from a public origin (e.g. webauth.courses) attempts a
24
+ * WebRTC connection to a private-network address. Filtering these candidates
25
+ * out before forwarding them to the browser lets the connection proceed via the
26
+ * server-reflexive (srflx) candidate — the proxy's public IP as seen by STUN —
27
+ * which does not trigger PNA.
28
+ *
29
+ * Edge case: if no srflx candidate is available (STUN unreachable, symmetric
30
+ * NAT that maps differently per destination, etc.) all host candidates are
31
+ * suppressed and the connection will fail. We accept this trade-off; STUN is
32
+ * a hard dependency of the WebRTC path in any case.
33
+ *
34
+ * @param {string} candidate - Raw candidate attribute string from node-datachannel.
35
+ * @returns {boolean}
36
+ */
37
+ function isPrivateHostCandidate(candidate) {
38
+ // Only care about "typ host" — srflx and relay are already public/relay.
39
+ if (!candidate.includes("typ host")) {
40
+ return false;
41
+ }
42
+ // RFC 1918 IPv4 private ranges.
43
+ if (/\b(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/.test(candidate)) {
44
+ return true;
45
+ }
46
+ // Docker / typical private subnets not covered above (100.64–127 are special).
47
+ if (/\b127\./.test(candidate)) {
48
+ return true;
49
+ }
50
+ // IPv6 loopback.
51
+ if (/\s::1\s/.test(candidate)) {
52
+ return true;
53
+ }
54
+ // IPv6 Unique Local Addresses (ULA): fc00::/7 — starts with fc or fd.
55
+ if (/\s(?:fc|fd)[0-9a-f]{2}:/i.test(candidate)) {
56
+ return true;
57
+ }
58
+ return false;
59
+ }
60
+
18
61
  /**
19
62
  * Configuration for the WebRTC manager.
20
63
  *
@@ -50,6 +93,15 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog }) {
50
93
  /** @type {Map<string, import("node-datachannel").PeerConnection>} */
51
94
  const peers = new Map();
52
95
 
96
+ /**
97
+ * Per-session ICE candidate state.
98
+ * Tracks buffered private candidates and whether a public (srflx/relay)
99
+ * candidate has already been forwarded to the browser.
100
+ *
101
+ * @type {Map<string, { privateCandidates: Array<{candidate: string, mid: string}>, sentPublic: boolean }>}
102
+ */
103
+ const iceState = new Map();
104
+
53
105
  /**
54
106
  * @param {string} message
55
107
  * @returns {void}
@@ -79,11 +131,60 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog }) {
79
131
  iceServers: ICE_SERVERS
80
132
  });
81
133
 
134
+ // Per-session ICE state for the public-first / private-fallback strategy.
135
+ const ice = { privateCandidates: [], sentPublic: false };
136
+ iceState.set(sessionId, ice);
137
+
138
+ /**
139
+ * Release any buffered private candidates to the browser.
140
+ * Called as a last resort when ICE gathering completes without producing
141
+ * a public (srflx/relay) candidate — e.g. when STUN is unreachable or
142
+ * the NAT is symmetric. The browser will show a PNA permission dialog,
143
+ * but at least connectivity is possible.
144
+ */
145
+ function flushPrivateCandidates() {
146
+ if (ice.sentPublic || ice.privateCandidates.length === 0) {
147
+ return;
148
+ }
149
+ log(`[webrtc] Session ${sessionId.slice(0, 8)}: no public ICE candidate available — falling back to private (PNA dialog will appear)`);
150
+ for (const { candidate, mid } of ice.privateCandidates) {
151
+ sendSignal(sessionId, { type: "candidate", candidate, mid });
152
+ }
153
+ ice.privateCandidates = [];
154
+ }
155
+
82
156
  // Forward our ICE candidates to the browser through the tunnel.
157
+ //
158
+ // Strategy: send public (srflx / relay) candidates immediately so the
159
+ // browser can connect via the proxy's public IP — this never triggers
160
+ // Chrome's Private Network Access (PNA) permission dialog.
161
+ // Private host candidates (RFC 1918, Docker bridge IPs, ULA IPv6) are
162
+ // buffered. If ICE gathering completes without a public candidate the
163
+ // buffer is flushed as a fallback so the user still gets a PNA dialog
164
+ // rather than a silent failure.
83
165
  pc.onLocalCandidate((candidate, mid) => {
166
+ if (isPrivateHostCandidate(candidate)) {
167
+ log(`[webrtc] Session ${sessionId.slice(0, 8)}: buffered private host candidate`);
168
+ ice.privateCandidates.push({ candidate, mid });
169
+ return;
170
+ }
171
+ // Public candidate — send immediately and discard the private buffer.
172
+ if (!ice.sentPublic) {
173
+ ice.sentPublic = true;
174
+ ice.privateCandidates = [];
175
+ log(`[webrtc] Session ${sessionId.slice(0, 8)}: sent public ICE candidate (PNA-free path)`);
176
+ }
84
177
  sendSignal(sessionId, { type: "candidate", candidate, mid });
85
178
  });
86
179
 
180
+ // When gathering finishes, flush private candidates if no public one arrived.
181
+ pc.onGatheringStateChange((state) => {
182
+ log(`[webrtc] Session ${sessionId.slice(0, 8)}: gathering → ${state}`);
183
+ if (state === "complete") {
184
+ flushPrivateCandidates();
185
+ }
186
+ });
187
+
87
188
  // Forward our SDP answer to the browser through the tunnel.
88
189
  pc.onLocalDescription((sdp, type) => {
89
190
  log(`[webrtc] Session ${sessionId.slice(0, 8)}: sending ${type}`);
@@ -158,6 +259,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog }) {
158
259
  if (pc) {
159
260
  try { pc.close(); } catch { /* ignore */ }
160
261
  peers.delete(sessionId);
262
+ iceState.delete(sessionId);
161
263
  log(`[webrtc] Session ${sessionId.slice(0, 8)}: closed`);
162
264
  }
163
265
  }