@torrent-tv/proxy 2.9.20 → 2.9.22
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 +8 -0
- package/bin/cli.js +8 -1
- package/package.json +1 -1
- package/services/webrtc-manager.js +134 -37
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.9.24
|
|
2
|
+
|
|
3
|
+
- **New**: IPv6-first support (roadmap step 5a). (1) A second STUN server (`stun.cloudflare.com:3478`, alongside Google's) is added to the ICE config — both have IPv6 (AAAA) records, so when the proxy host has a global IPv6 address it gathers a `srflx` candidate over v6 too. IPv6 has no NAT, so if both the proxy and a (v6-native, e.g. cellular) viewer have global v6, the connection can go **direct** over v6 — sidestepping the whole NAT-traversal machinery. (2) Candidate logging now classifies each candidate by address scope — `v4-private` / `v4-public` / `v6-global` / `v6-ula` / `v6-linklocal` / `v6-loopback` (replaces the old private/public host label) — so the field log shows whether a global IPv6 path is actually being offered and chosen. Audited the candidate path: the proxy already forwards ALL candidates (incl. global v6) and the browser adds them all — nothing was dropping global v6, so no filter fix was needed. NOTE: not verifiable on the dev's proxy (its ISP exposes only ULA v6 `fd…`, no global v6); needs a proxy with global v6 to confirm in the field — the new `v6-global` log tag is there to spot it.
|
|
4
|
+
|
|
5
|
+
## 2.9.23
|
|
6
|
+
|
|
7
|
+
- **New**: Symmetric-NAT port prediction for WebRTC (roadmap step 4; `webrtc-manager.js` + `nat-classifier.js` delta + `cli.js` wiring). When the startup NAT classification reports a **symmetric** NAT, for each real IPv4 `srflx` candidate the proxy also offers predicted-port candidates at `base + delta*k` (k = 1..16, `delta` = the per-destination external-port step measured at startup), each with a unique ICE foundation. The browser probes these too; if one matches the external port the NAT assigns for the proxy→browser path, ICE connects — the practical, signalling-only form of the birthday-paradox trick (no node-datachannel changes, no extra sockets). **Scope**: covers sequential/predictable symmetric NATs; a fully-random symmetric NAT (where the true 256-socket birthday would be needed) is not solved by this and is out of reach on the node-datachannel stack. No-op for cone NATs (the fixed-port mapping already suffices) and IPv6 (no NAT). Diagnostics: logs the injected predicted ports per session (`symmetric NAT (delta=D) — injecting N predicted srflx candidates: …`); combined with the existing `selected pair local=[…]` log this shows whether a predicted port won. NOTE: could not be exercised end-to-end — the dev's home NAT is cone; needs a symmetric-NAT vantage to verify in the field (the logging is there to diagnose it when it appears).
|
|
8
|
+
|
|
1
9
|
## 2.9.22
|
|
2
10
|
|
|
3
11
|
- **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.
|
package/bin/cli.js
CHANGED
|
@@ -129,6 +129,9 @@ let udpPortMapper = null;
|
|
|
129
129
|
/** @type {ReturnType<typeof createWebRtcManager> | null} */
|
|
130
130
|
let webRtcManager = null;
|
|
131
131
|
|
|
132
|
+
/** @type {import("../services/nat-classifier.js").NatClassification | null} Latest NAT classification (for WebRTC port prediction). */
|
|
133
|
+
let natInfo = null;
|
|
134
|
+
|
|
132
135
|
|
|
133
136
|
/**
|
|
134
137
|
* Register this proxy with the registry server.
|
|
@@ -291,13 +294,15 @@ try {
|
|
|
291
294
|
// never block startup.
|
|
292
295
|
void classifyNat()
|
|
293
296
|
.then((nat) => {
|
|
297
|
+
// Stored for WebRTC port prediction (webRtcManager reads it per session).
|
|
298
|
+
natInfo = nat;
|
|
294
299
|
if (nat.klass === "endpoint-independent") {
|
|
295
300
|
logger.info(
|
|
296
301
|
`nat: endpoint-independent (cone) — external UDP port stable across STUN servers (${nat.externalIp}); fixed-port WebRTC mapping is sufficient, no port prediction needed`
|
|
297
302
|
);
|
|
298
303
|
} else if (nat.klass === "symmetric") {
|
|
299
304
|
logger.warn(
|
|
300
|
-
`nat: SYMMETRIC — external UDP port varies per destination (delta ${nat.portDelta}); WebRTC
|
|
305
|
+
`nat: SYMMETRIC — external UDP port varies per destination (delta ${nat.portDelta}); WebRTC offers predicted ports (base+delta*k) — covers sequential/predictable symmetric NAT, not fully random`
|
|
301
306
|
);
|
|
302
307
|
} else {
|
|
303
308
|
logger.info("nat: classification inconclusive (STUN probes failed); continuing");
|
|
@@ -356,6 +361,8 @@ try {
|
|
|
356
361
|
// persistent ICE UDP mux listener, so the WebRTC path is reachable from the
|
|
357
362
|
// internet on one fixed port.
|
|
358
363
|
udpPort: webrtcUdpPort,
|
|
364
|
+
// Latest NAT classification — enables symmetric-NAT port prediction.
|
|
365
|
+
getNatInfo: () => natInfo,
|
|
359
366
|
sendSignal(sessionId, signal) {
|
|
360
367
|
tunnelClient?.sendSignal(sessionId, signal);
|
|
361
368
|
},
|
package/package.json
CHANGED
|
@@ -13,49 +13,119 @@ import nodeDataChannel from "node-datachannel";
|
|
|
13
13
|
|
|
14
14
|
/** @import { WebRtcSignal } from './tunnel-client.js' */
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// Two STUN servers from different operators, both with IPv6 (AAAA) records, so
|
|
17
|
+
// the proxy gathers a server-reflexive candidate over BOTH v4 and (when the
|
|
18
|
+
// host has global v6) v6 — enabling a direct IPv6 path on v6-native networks.
|
|
19
|
+
const ICE_SERVERS = ["stun:stun.l.google.com:19302", "stun:stun.cloudflare.com:3478"];
|
|
20
|
+
|
|
21
|
+
// Symmetric-NAT port prediction window. For each real srflx candidate we offer
|
|
22
|
+
// this many extra candidates at ports base + delta*k (k = 1..N), because the
|
|
23
|
+
// number of NAT mappings the router allocates between STUN gathering and the
|
|
24
|
+
// browser connectivity check is unknown — a small window covers the likely
|
|
25
|
+
// values without bloating the SDP.
|
|
26
|
+
const PORT_PREDICTION_WINDOW = 16;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build predicted srflx ICE candidates for a symmetric NAT.
|
|
30
|
+
*
|
|
31
|
+
* A symmetric NAT assigns a different external port per destination, so the
|
|
32
|
+
* STUN-learned srflx port is not the port the NAT will use toward the browser.
|
|
33
|
+
* Given the per-destination port `delta` measured at startup, we offer ports
|
|
34
|
+
* `base + delta*k` (k = 1..window) so the browser also probes them; if one
|
|
35
|
+
* matches the mapping the NAT creates for the proxy→browser path, ICE connects.
|
|
36
|
+
* This is the practical, signalling-only form of the birthday-paradox trick —
|
|
37
|
+
* it works for sequential/predictable symmetric NATs, not fully random ones.
|
|
38
|
+
*
|
|
39
|
+
* Each predicted candidate gets a unique foundation so ICE treats it as a
|
|
40
|
+
* distinct candidate. Returns [] for non-srflx, IPv6, missing/zero delta, or a
|
|
41
|
+
* candidate string we cannot parse.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} candidate - Raw candidate string (with or without `a=`).
|
|
44
|
+
* @param {number} delta - Per-destination external-port delta (from NAT classification).
|
|
45
|
+
* @param {number} [windowSize]
|
|
46
|
+
* @returns {Array<{ candidate: string, port: number }>}
|
|
47
|
+
*/
|
|
48
|
+
function buildPredictedSrflxCandidates(candidate, delta, windowSize = PORT_PREDICTION_WINDOW) {
|
|
49
|
+
if (!Number.isInteger(delta) || delta === 0) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
const raw = candidate.replace(/^a=/, "");
|
|
53
|
+
if (!/ typ srflx /.test(raw)) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
// candidate:<foundation> <component> <proto> <priority> <ip> <port> typ srflx ...
|
|
57
|
+
const parts = raw.split(" ");
|
|
58
|
+
if (parts.length < 8 || !parts[0].startsWith("candidate:")) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
const ip = parts[4];
|
|
62
|
+
if (typeof ip !== "string" || ip.includes(":")) {
|
|
63
|
+
// IPv6 has no NAT — port prediction is meaningless.
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
const basePort = Number(parts[5]);
|
|
67
|
+
if (!Number.isInteger(basePort)) {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const out = [];
|
|
72
|
+
const seen = new Set([basePort]);
|
|
73
|
+
for (let k = 1; k <= windowSize; k++) {
|
|
74
|
+
const port = basePort + delta * k;
|
|
75
|
+
if (port < 1 || port > 65535 || seen.has(port)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
seen.add(port);
|
|
79
|
+
const p = parts.slice();
|
|
80
|
+
p[0] = `candidate:pp${k}`;
|
|
81
|
+
p[5] = String(port);
|
|
82
|
+
out.push({ candidate: p.join(" "), port });
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
17
86
|
|
|
18
87
|
/**
|
|
19
|
-
*
|
|
20
|
-
* with a private (RFC 1918 / ULA / loopback) IP address.
|
|
88
|
+
* Classify an ICE candidate by its address family and scope, for diagnostics.
|
|
21
89
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* which does not trigger PNA.
|
|
90
|
+
* Returns one of: `v4-private`, `v4-public`, `v6-global`, `v6-ula`,
|
|
91
|
+
* `v6-linklocal`, `v6-loopback`, or `unknown`. This is logged for every
|
|
92
|
+
* candidate so the field log shows whether a **global IPv6** path is being
|
|
93
|
+
* offered (IPv6 has no NAT — if both sides have a global v6 address the
|
|
94
|
+
* connection can go direct, which matters for v6-native mobile networks).
|
|
28
95
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
96
|
+
* Note on PNA: a `v4-private` host candidate triggers the browser's Private
|
|
97
|
+
* Network Access permission prompt; the connection otherwise proceeds via the
|
|
98
|
+
* public srflx candidate. We forward all candidates regardless (the browser
|
|
99
|
+
* decides) — this only labels them.
|
|
33
100
|
*
|
|
34
|
-
* @param {string} candidate - Raw candidate attribute string
|
|
35
|
-
* @returns {
|
|
101
|
+
* @param {string} candidate - Raw candidate attribute string (with or without `a=`).
|
|
102
|
+
* @returns {"v4-private"|"v4-public"|"v6-global"|"v6-ula"|"v6-linklocal"|"v6-loopback"|"unknown"}
|
|
36
103
|
*/
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
104
|
+
function candidateAddrKind(candidate) {
|
|
105
|
+
const parts = candidate.replace(/^a=/, "").split(" ");
|
|
106
|
+
const ip = parts.length > 4 ? parts[4] : "";
|
|
107
|
+
if (!ip) {
|
|
108
|
+
return "unknown";
|
|
41
109
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
110
|
+
if (!ip.includes(":")) {
|
|
111
|
+
// IPv4: RFC 1918 / loopback / link-local are private.
|
|
112
|
+
if (/^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip)) {
|
|
113
|
+
return "v4-private";
|
|
114
|
+
}
|
|
115
|
+
return "v4-public";
|
|
45
116
|
}
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
return
|
|
117
|
+
const low = ip.toLowerCase();
|
|
118
|
+
if (low === "::1") {
|
|
119
|
+
return "v6-loopback";
|
|
49
120
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return true;
|
|
121
|
+
if (low.startsWith("fe80")) {
|
|
122
|
+
return "v6-linklocal";
|
|
53
123
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return
|
|
124
|
+
if (/^f[cd][0-9a-f]{2}:/.test(low)) {
|
|
125
|
+
// Unique Local Address (fc00::/7).
|
|
126
|
+
return "v6-ula";
|
|
57
127
|
}
|
|
58
|
-
return
|
|
128
|
+
return "v6-global";
|
|
59
129
|
}
|
|
60
130
|
|
|
61
131
|
/**
|
|
@@ -70,6 +140,10 @@ function isPrivateHostCandidate(candidate) {
|
|
|
70
140
|
* `node-datachannel` `DataChannel` object; hand it to `createDataChannelHandler`.
|
|
71
141
|
* @property {(message: string) => void} [onLog]
|
|
72
142
|
* Optional log sink.
|
|
143
|
+
* @property {() => ({ klass: string, portDelta: number|null } | null)} [getNatInfo]
|
|
144
|
+
* Returns the latest NAT classification (or null if not yet known). When it
|
|
145
|
+
* reports a symmetric NAT with a known port delta, each srflx candidate is
|
|
146
|
+
* accompanied by predicted-port candidates (see {@link buildPredictedSrflxCandidates}).
|
|
73
147
|
* @property {number} [udpPort]
|
|
74
148
|
* When set, all WebRTC sessions are multiplexed onto this single UDP port so
|
|
75
149
|
* it can be statically UPnP-mapped (one mapping, one reachable port). A
|
|
@@ -106,7 +180,7 @@ function isPrivateHostCandidate(candidate) {
|
|
|
106
180
|
* @param {WebRtcManagerOptions} options
|
|
107
181
|
* @returns {WebRtcManager}
|
|
108
182
|
*/
|
|
109
|
-
export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort }) {
|
|
183
|
+
export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort, getNatInfo }) {
|
|
110
184
|
/** @type {Map<string, import("node-datachannel").PeerConnection>} */
|
|
111
185
|
const peers = new Map();
|
|
112
186
|
|
|
@@ -175,13 +249,36 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
|
|
|
175
249
|
// Network Access (PNA) permission dialog once; after the user allows it
|
|
176
250
|
// the connection proceeds via the local LAN path.
|
|
177
251
|
pc.onLocalCandidate((candidate, mid) => {
|
|
178
|
-
const
|
|
179
|
-
// Log the full candidate (addr:port typ …)
|
|
180
|
-
// pinned to the mapped UDP port
|
|
252
|
+
const kind = candidateAddrKind(candidate);
|
|
253
|
+
// Log the full candidate (addr:port typ …) with its address kind so we
|
|
254
|
+
// can confirm WebRTC is pinned to the mapped UDP port, see whether a
|
|
255
|
+
// global IPv6 path is offered, and diagnose which routes are available.
|
|
181
256
|
log(
|
|
182
|
-
`[webrtc] Session ${sessionId.slice(0, 8)}: sending ${
|
|
257
|
+
`[webrtc] Session ${sessionId.slice(0, 8)}: sending ${kind} candidate: ${candidate.replace(/^a=/, "")}`
|
|
183
258
|
);
|
|
184
259
|
sendSignal(sessionId, { type: "candidate", candidate, mid });
|
|
260
|
+
|
|
261
|
+
// Symmetric-NAT port prediction: offer extra srflx candidates at the
|
|
262
|
+
// predicted external ports so the browser probes them too. No-op unless
|
|
263
|
+
// the NAT is symmetric with a known delta and this is an IPv4 srflx
|
|
264
|
+
// candidate. Best-effort — never let it break candidate forwarding.
|
|
265
|
+
try {
|
|
266
|
+
const nat = typeof getNatInfo === "function" ? getNatInfo() : null;
|
|
267
|
+
if (nat && nat.klass === "symmetric") {
|
|
268
|
+
const predicted = buildPredictedSrflxCandidates(candidate, nat.portDelta);
|
|
269
|
+
if (predicted.length > 0) {
|
|
270
|
+
log(
|
|
271
|
+
`[webrtc] Session ${sessionId.slice(0, 8)}: symmetric NAT (delta=${nat.portDelta}) — injecting ${predicted.length} predicted srflx candidates: ${predicted.map((p) => p.port).join(",")}`
|
|
272
|
+
);
|
|
273
|
+
for (const p of predicted) {
|
|
274
|
+
sendSignal(sessionId, { type: "candidate", candidate: p.candidate, mid });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} catch (error) {
|
|
279
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
280
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: port-prediction inject failed: ${message}`);
|
|
281
|
+
}
|
|
185
282
|
});
|
|
186
283
|
|
|
187
284
|
pc.onGatheringStateChange((state) => {
|