@torrent-tv/proxy 2.5.5 → 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.5",
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": {
@@ -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
  }