@torrent-tv/proxy 2.9.19 → 2.9.21
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 +12 -0
- package/bin/cli.js +32 -14
- package/package.json +1 -1
- package/routes/api/sources/stats/get.js +18 -1
- package/services/port-mapper.js +43 -14
- package/services/webrtc-manager.js +174 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 2.9.23
|
|
2
|
+
|
|
3
|
+
- **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).
|
|
4
|
+
|
|
5
|
+
## 2.9.22
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
|
|
9
|
+
## 2.9.21
|
|
10
|
+
|
|
11
|
+
- **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.)
|
|
12
|
+
|
|
1
13
|
## 2.9.20
|
|
2
14
|
|
|
3
15
|
- **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,12 @@ 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
|
+
|
|
132
|
+
/** @type {import("../services/nat-classifier.js").NatClassification | null} Latest NAT classification (for WebRTC port prediction). */
|
|
133
|
+
let natInfo = null;
|
|
134
|
+
|
|
129
135
|
|
|
130
136
|
/**
|
|
131
137
|
* Register this proxy with the registry server.
|
|
@@ -204,6 +210,11 @@ async function shutdown(signal) {
|
|
|
204
210
|
await udpPortMapper.stop();
|
|
205
211
|
udpPortMapper = null;
|
|
206
212
|
}
|
|
213
|
+
// Close WebRTC sessions and release the shared UDP mux listener socket.
|
|
214
|
+
if (webRtcManager) {
|
|
215
|
+
try { webRtcManager.dispose(); } catch { /* ignore */ }
|
|
216
|
+
webRtcManager = null;
|
|
217
|
+
}
|
|
207
218
|
if (app) {
|
|
208
219
|
await app.close();
|
|
209
220
|
}
|
|
@@ -236,6 +247,11 @@ try {
|
|
|
236
247
|
logger.info(`Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`);
|
|
237
248
|
}
|
|
238
249
|
|
|
250
|
+
// All WebRTC sessions are multiplexed onto this single UDP port (same number
|
|
251
|
+
// as the HTTP port, different protocol) via a persistent ICE UDP mux listener
|
|
252
|
+
// in the WebRTC manager. One port → one UPnP mapping → one reachable endpoint.
|
|
253
|
+
const webrtcUdpPort = actualPort;
|
|
254
|
+
|
|
239
255
|
// Try to open the local port on the home router (UPnP/NAT-PMP) so the proxy
|
|
240
256
|
// is reachable from the internet without manual port forwarding. Best-effort
|
|
241
257
|
// and fire-and-forget: failure is normal (router without UPnP) and must not
|
|
@@ -257,13 +273,11 @@ try {
|
|
|
257
273
|
logger.warn(`Port mapping failed to start: ${message}`);
|
|
258
274
|
});
|
|
259
275
|
|
|
260
|
-
// Also map the WebRTC UDP port
|
|
261
|
-
//
|
|
262
|
-
//
|
|
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.
|
|
276
|
+
// Also map the single WebRTC UDP port. Not reported to the server: the
|
|
277
|
+
// browser discovers the endpoint via ICE (srflx) candidates, not the TCP
|
|
278
|
+
// dial-back probe.
|
|
265
279
|
udpPortMapper = createPortMapper({
|
|
266
|
-
port:
|
|
280
|
+
port: webrtcUdpPort,
|
|
267
281
|
protocol: "UDP",
|
|
268
282
|
description: "torrent-tv proxy (WebRTC)"
|
|
269
283
|
});
|
|
@@ -280,13 +294,15 @@ try {
|
|
|
280
294
|
// never block startup.
|
|
281
295
|
void classifyNat()
|
|
282
296
|
.then((nat) => {
|
|
297
|
+
// Stored for WebRTC port prediction (webRtcManager reads it per session).
|
|
298
|
+
natInfo = nat;
|
|
283
299
|
if (nat.klass === "endpoint-independent") {
|
|
284
300
|
logger.info(
|
|
285
301
|
`nat: endpoint-independent (cone) — external UDP port stable across STUN servers (${nat.externalIp}); fixed-port WebRTC mapping is sufficient, no port prediction needed`
|
|
286
302
|
);
|
|
287
303
|
} else if (nat.klass === "symmetric") {
|
|
288
304
|
logger.warn(
|
|
289
|
-
`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`
|
|
290
306
|
);
|
|
291
307
|
} else {
|
|
292
308
|
logger.info("nat: classification inconclusive (STUN probes failed); continuing");
|
|
@@ -302,10 +318,9 @@ try {
|
|
|
302
318
|
// The WebRTC manager handles the actual peer connection and data channel.
|
|
303
319
|
//
|
|
304
320
|
// We use a late-binding ref so both objects can reference each other without
|
|
305
|
-
// running into the TDZ (tunnelClient is declared above; webRtcManager
|
|
306
|
-
// so the closure in createTunnelClient
|
|
307
|
-
|
|
308
|
-
let webRtcManager = null;
|
|
321
|
+
// running into the TDZ (tunnelClient is declared above; webRtcManager is the
|
|
322
|
+
// module-scoped `let` above so the closure in createTunnelClient — and the
|
|
323
|
+
// shutdown handler — can reach it after initialisation).
|
|
309
324
|
|
|
310
325
|
tunnelClient = createTunnelClient({
|
|
311
326
|
serverUrl,
|
|
@@ -342,9 +357,12 @@ try {
|
|
|
342
357
|
});
|
|
343
358
|
|
|
344
359
|
webRtcManager = createWebRtcManager({
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
|
|
360
|
+
// Single UDP port (UPnP-mapped above) shared by all sessions via a
|
|
361
|
+
// persistent ICE UDP mux listener, so the WebRTC path is reachable from the
|
|
362
|
+
// internet on one fixed port.
|
|
363
|
+
udpPort: webrtcUdpPort,
|
|
364
|
+
// Latest NAT classification — enables symmetric-NAT port prediction.
|
|
365
|
+
getNatInfo: () => natInfo,
|
|
348
366
|
sendSignal(sessionId, signal) {
|
|
349
367
|
tunnelClient?.sendSignal(sessionId, signal);
|
|
350
368
|
},
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/services/port-mapper.js
CHANGED
|
@@ -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 ${
|
|
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 ${
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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 ${
|
|
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}
|
|
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 ${
|
|
202
|
+
`port-mapper: mapped ${label}${counts} (external IP unknown; ttl ${ttlSeconds}s, auto-renew)`
|
|
174
203
|
);
|
|
175
204
|
}
|
|
176
205
|
}
|
|
@@ -15,6 +15,72 @@ import nodeDataChannel from "node-datachannel";
|
|
|
15
15
|
|
|
16
16
|
const ICE_SERVERS = ["stun:stun.l.google.com:19302"];
|
|
17
17
|
|
|
18
|
+
// Symmetric-NAT port prediction window. For each real srflx candidate we offer
|
|
19
|
+
// this many extra candidates at ports base + delta*k (k = 1..N), because the
|
|
20
|
+
// number of NAT mappings the router allocates between STUN gathering and the
|
|
21
|
+
// browser connectivity check is unknown — a small window covers the likely
|
|
22
|
+
// values without bloating the SDP.
|
|
23
|
+
const PORT_PREDICTION_WINDOW = 16;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build predicted srflx ICE candidates for a symmetric NAT.
|
|
27
|
+
*
|
|
28
|
+
* A symmetric NAT assigns a different external port per destination, so the
|
|
29
|
+
* STUN-learned srflx port is not the port the NAT will use toward the browser.
|
|
30
|
+
* Given the per-destination port `delta` measured at startup, we offer ports
|
|
31
|
+
* `base + delta*k` (k = 1..window) so the browser also probes them; if one
|
|
32
|
+
* matches the mapping the NAT creates for the proxy→browser path, ICE connects.
|
|
33
|
+
* This is the practical, signalling-only form of the birthday-paradox trick —
|
|
34
|
+
* it works for sequential/predictable symmetric NATs, not fully random ones.
|
|
35
|
+
*
|
|
36
|
+
* Each predicted candidate gets a unique foundation so ICE treats it as a
|
|
37
|
+
* distinct candidate. Returns [] for non-srflx, IPv6, missing/zero delta, or a
|
|
38
|
+
* candidate string we cannot parse.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} candidate - Raw candidate string (with or without `a=`).
|
|
41
|
+
* @param {number} delta - Per-destination external-port delta (from NAT classification).
|
|
42
|
+
* @param {number} [windowSize]
|
|
43
|
+
* @returns {Array<{ candidate: string, port: number }>}
|
|
44
|
+
*/
|
|
45
|
+
function buildPredictedSrflxCandidates(candidate, delta, windowSize = PORT_PREDICTION_WINDOW) {
|
|
46
|
+
if (!Number.isInteger(delta) || delta === 0) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const raw = candidate.replace(/^a=/, "");
|
|
50
|
+
if (!/ typ srflx /.test(raw)) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
// candidate:<foundation> <component> <proto> <priority> <ip> <port> typ srflx ...
|
|
54
|
+
const parts = raw.split(" ");
|
|
55
|
+
if (parts.length < 8 || !parts[0].startsWith("candidate:")) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const ip = parts[4];
|
|
59
|
+
if (typeof ip !== "string" || ip.includes(":")) {
|
|
60
|
+
// IPv6 has no NAT — port prediction is meaningless.
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
const basePort = Number(parts[5]);
|
|
64
|
+
if (!Number.isInteger(basePort)) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const out = [];
|
|
69
|
+
const seen = new Set([basePort]);
|
|
70
|
+
for (let k = 1; k <= windowSize; k++) {
|
|
71
|
+
const port = basePort + delta * k;
|
|
72
|
+
if (port < 1 || port > 65535 || seen.has(port)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
seen.add(port);
|
|
76
|
+
const p = parts.slice();
|
|
77
|
+
p[0] = `candidate:pp${k}`;
|
|
78
|
+
p[5] = String(port);
|
|
79
|
+
out.push({ candidate: p.join(" "), port });
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
18
84
|
/**
|
|
19
85
|
* Return true when the ICE candidate string describes a `typ host` candidate
|
|
20
86
|
* with a private (RFC 1918 / ULA / loopback) IP address.
|
|
@@ -70,11 +136,24 @@ function isPrivateHostCandidate(candidate) {
|
|
|
70
136
|
* `node-datachannel` `DataChannel` object; hand it to `createDataChannelHandler`.
|
|
71
137
|
* @property {(message: string) => void} [onLog]
|
|
72
138
|
* Optional log sink.
|
|
139
|
+
* @property {() => ({ klass: string, portDelta: number|null } | null)} [getNatInfo]
|
|
140
|
+
* Returns the latest NAT classification (or null if not yet known). When it
|
|
141
|
+
* reports a symmetric NAT with a known port delta, each srflx candidate is
|
|
142
|
+
* accompanied by predicted-port candidates (see {@link buildPredictedSrflxCandidates}).
|
|
73
143
|
* @property {number} [udpPort]
|
|
74
|
-
* When set,
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
144
|
+
* When set, all WebRTC sessions are multiplexed onto this single UDP port so
|
|
145
|
+
* it can be statically UPnP-mapped (one mapping, one reachable port). A
|
|
146
|
+
* persistent {@link import("node-datachannel").IceUdpMuxListener} is created
|
|
147
|
+
* once at startup and owns the shared socket; every PeerConnection enables
|
|
148
|
+
* `enableIceUdpMux` on the same port and demuxes over it by ICE ufrag.
|
|
149
|
+
*
|
|
150
|
+
* The persistent listener is the crucial part: per-PeerConnection
|
|
151
|
+
* `enableIceUdpMux` WITHOUT it ties the shared socket to a connection's
|
|
152
|
+
* lifetime, so a freshly-opened session fails to bind the port while a
|
|
153
|
+
* just-closed one still holds it → "Failed to gather local ICE candidates"
|
|
154
|
+
* (which crashed the proxy). The listener keeps the socket alive across
|
|
155
|
+
* sessions; verified with sequential + concurrent connections on one port.
|
|
156
|
+
* When omitted, node-datachannel uses an ephemeral UDP port.
|
|
78
157
|
*/
|
|
79
158
|
|
|
80
159
|
/**
|
|
@@ -86,6 +165,9 @@ function isPrivateHostCandidate(candidate) {
|
|
|
86
165
|
* the matching peer connection, creating it if necessary.
|
|
87
166
|
* @property {(sessionId: string) => void} closeSession
|
|
88
167
|
* Tear down and remove a peer connection by session ID.
|
|
168
|
+
* @property {() => void} dispose
|
|
169
|
+
* Close all peer connections and stop the shared UDP mux listener. Call on
|
|
170
|
+
* proxy shutdown so the listener's socket is released.
|
|
89
171
|
*/
|
|
90
172
|
|
|
91
173
|
/**
|
|
@@ -94,20 +176,10 @@ function isPrivateHostCandidate(candidate) {
|
|
|
94
176
|
* @param {WebRtcManagerOptions} options
|
|
95
177
|
* @returns {WebRtcManager}
|
|
96
178
|
*/
|
|
97
|
-
export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort }) {
|
|
179
|
+
export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort, getNatInfo }) {
|
|
98
180
|
/** @type {Map<string, import("node-datachannel").PeerConnection>} */
|
|
99
181
|
const peers = new Map();
|
|
100
182
|
|
|
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
183
|
/**
|
|
112
184
|
* @param {string} message
|
|
113
185
|
* @returns {void}
|
|
@@ -118,6 +190,36 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
|
|
|
118
190
|
}
|
|
119
191
|
}
|
|
120
192
|
|
|
193
|
+
// Base PeerConnection config shared by every session.
|
|
194
|
+
const pcConfig = { iceServers: ICE_SERVERS };
|
|
195
|
+
|
|
196
|
+
// Single-port UDP mux: create ONE persistent listener that owns the shared
|
|
197
|
+
// UDP socket for the proxy's whole lifetime, then have every PeerConnection
|
|
198
|
+
// mux over it (enableIceUdpMux + the same fixed port). The listener must
|
|
199
|
+
// outlive individual sessions — without it the socket is bound/freed per
|
|
200
|
+
// connection and a repeat session fails to gather ICE candidates.
|
|
201
|
+
/** @type {import("node-datachannel").IceUdpMuxListener | null} */
|
|
202
|
+
let udpMuxListener = null;
|
|
203
|
+
if (Number.isInteger(udpPort) && udpPort > 0 && udpPort <= 65535) {
|
|
204
|
+
try {
|
|
205
|
+
udpMuxListener = new nodeDataChannel.IceUdpMuxListener(udpPort);
|
|
206
|
+
// STUN that doesn't match an existing session (our PeerConnections are
|
|
207
|
+
// created from the SDP offer before the browser's STUN arrives, so normal
|
|
208
|
+
// traffic is "handled"). Stray/unhandled requests are simply dropped.
|
|
209
|
+
udpMuxListener.onUnhandledStunRequest(() => {});
|
|
210
|
+
pcConfig.enableIceUdpMux = true;
|
|
211
|
+
pcConfig.portRangeBegin = udpPort;
|
|
212
|
+
pcConfig.portRangeEnd = udpPort;
|
|
213
|
+
log(`[webrtc] UDP mux listener bound on port ${udpPort}; all sessions share it`);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// Could not bind the mux port — fall back to ephemeral UDP ports (no
|
|
216
|
+
// single-port reachability, but the proxy still works on LAN / via STUN).
|
|
217
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
218
|
+
log(`[webrtc] UDP mux listener failed on port ${udpPort} (${message}); using ephemeral ports`);
|
|
219
|
+
udpMuxListener = null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
121
223
|
/**
|
|
122
224
|
* Retrieve an existing peer connection or create a new one for the session.
|
|
123
225
|
*
|
|
@@ -150,6 +252,28 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
|
|
|
150
252
|
`[webrtc] Session ${sessionId.slice(0, 8)}: sending ${isPrivate ? "private" : "public"} candidate: ${candidate.replace(/^a=/, "")}`
|
|
151
253
|
);
|
|
152
254
|
sendSignal(sessionId, { type: "candidate", candidate, mid });
|
|
255
|
+
|
|
256
|
+
// Symmetric-NAT port prediction: offer extra srflx candidates at the
|
|
257
|
+
// predicted external ports so the browser probes them too. No-op unless
|
|
258
|
+
// the NAT is symmetric with a known delta and this is an IPv4 srflx
|
|
259
|
+
// candidate. Best-effort — never let it break candidate forwarding.
|
|
260
|
+
try {
|
|
261
|
+
const nat = typeof getNatInfo === "function" ? getNatInfo() : null;
|
|
262
|
+
if (nat && nat.klass === "symmetric") {
|
|
263
|
+
const predicted = buildPredictedSrflxCandidates(candidate, nat.portDelta);
|
|
264
|
+
if (predicted.length > 0) {
|
|
265
|
+
log(
|
|
266
|
+
`[webrtc] Session ${sessionId.slice(0, 8)}: symmetric NAT (delta=${nat.portDelta}) — injecting ${predicted.length} predicted srflx candidates: ${predicted.map((p) => p.port).join(",")}`
|
|
267
|
+
);
|
|
268
|
+
for (const p of predicted) {
|
|
269
|
+
sendSignal(sessionId, { type: "candidate", candidate: p.candidate, mid });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
275
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: port-prediction inject failed: ${message}`);
|
|
276
|
+
}
|
|
153
277
|
});
|
|
154
278
|
|
|
155
279
|
pc.onGatheringStateChange((state) => {
|
|
@@ -225,9 +349,19 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
|
|
|
225
349
|
return;
|
|
226
350
|
}
|
|
227
351
|
log(`[webrtc] Session ${sessionId.slice(0, 8)}: received offer`);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
//
|
|
352
|
+
// node-datachannel can throw SYNCHRONOUSLY here (e.g. "Failed to gather
|
|
353
|
+
// local ICE candidates" when the pinned UDP port cannot be bound). A
|
|
354
|
+
// single bad session must never crash the whole proxy — that would drop
|
|
355
|
+
// every other viewer and the tunnel. Contain it: fail this session only.
|
|
356
|
+
try {
|
|
357
|
+
const pc = getOrCreatePeer(sessionId);
|
|
358
|
+
pc.setRemoteDescription(signal.sdp, "offer");
|
|
359
|
+
// `onLocalDescription` fires automatically with the SDP answer.
|
|
360
|
+
} catch (error) {
|
|
361
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
362
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: failed to handle offer: ${message}`);
|
|
363
|
+
closeSession(sessionId);
|
|
364
|
+
}
|
|
231
365
|
return;
|
|
232
366
|
}
|
|
233
367
|
|
|
@@ -237,7 +371,12 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
|
|
|
237
371
|
return;
|
|
238
372
|
}
|
|
239
373
|
if (typeof signal.candidate === "string" && typeof signal.mid === "string") {
|
|
240
|
-
|
|
374
|
+
try {
|
|
375
|
+
pc.addRemoteCandidate(signal.candidate, signal.mid);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
378
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: failed to add candidate: ${message}`);
|
|
379
|
+
}
|
|
241
380
|
}
|
|
242
381
|
}
|
|
243
382
|
}
|
|
@@ -258,5 +397,20 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
|
|
|
258
397
|
}
|
|
259
398
|
}
|
|
260
399
|
|
|
261
|
-
|
|
400
|
+
/**
|
|
401
|
+
* Close all peer connections and stop the shared UDP mux listener.
|
|
402
|
+
*
|
|
403
|
+
* @returns {void}
|
|
404
|
+
*/
|
|
405
|
+
function dispose() {
|
|
406
|
+
for (const sessionId of [...peers.keys()]) {
|
|
407
|
+
closeSession(sessionId);
|
|
408
|
+
}
|
|
409
|
+
if (udpMuxListener) {
|
|
410
|
+
try { udpMuxListener.stop(); } catch { /* ignore */ }
|
|
411
|
+
udpMuxListener = null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return { handleSignal, closeSession, dispose };
|
|
262
416
|
}
|