@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 +17 -0
- package/package.json +1 -1
- package/services/tunnel-client.js +20 -1
- package/services/webrtc-manager.js +102 -0
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
|
@@ -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
|
}
|