@torrent-tv/proxy 2.9.18 → 2.9.20
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 +16 -0
- package/bin/cli.js +47 -13
- package/package.json +1 -1
- package/routes/api/sources/stats/get.js +18 -1
- package/services/nat-classifier.js +231 -0
- package/services/port-mapper.js +47 -15
- package/services/webrtc-manager.js +108 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## 2.9.22
|
|
2
|
+
|
|
3
|
+
- **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.
|
|
4
|
+
|
|
5
|
+
## 2.9.21
|
|
6
|
+
|
|
7
|
+
- **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.)
|
|
8
|
+
|
|
9
|
+
## 2.9.20
|
|
10
|
+
|
|
11
|
+
- **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.
|
|
12
|
+
|
|
13
|
+
## 2.9.19
|
|
14
|
+
|
|
15
|
+
- **Chore**: Diagnostics for verifying remote WebRTC reachability and root-causing failures. `port-mapper.js` now logs a `removed mapping for <proto> <port>` line on clean shutdown unmap (previously silent on success). `webrtc-manager.js` now logs the **full** local ICE candidate (`addr:port typ …`, so the pinned UDP port is visible), every **ICE-state** transition (`checking → connected/failed`), and — on connect — the **selected candidate pair** (`local=[…] remote=[…]` with type/address/port), the single most useful line for "did the WebRTC path connect, and over which route (LAN / public srflx v4 / v6)".
|
|
16
|
+
|
|
1
17
|
## 2.9.18
|
|
2
18
|
|
|
3
19
|
- **New**: WebRTC is now reachable behind NAT via a static UDP port mapping. All sessions are pinned to a single UDP port (same number as the HTTP port, default 9090) and multiplexed over it (`enableIceUdpMux` + `portRangeBegin`/`portRangeEnd` in `webrtc-manager.js`), and that UDP port is UPnP/NAT-PMP-mapped at startup (a second `port-mapper.js` instance, protocol UDP, removed on shutdown). Because the socket is bound to a fixed, statically-mapped port, the proxy's `srflx` ICE candidate now carries `publicIP:9090` — reachable from the browser even behind symmetric NAT for that port (previously WebRTC used an ephemeral UDP port that UPnP could not map). Verified: two PeerConnections share the one UDP port with no bind conflict; host + srflx (v4 and global v6) candidates all carry the fixed port. The UDP endpoint is not reported to the server (the browser learns it via ICE, not the TCP dial-back probe).
|
package/bin/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ import { createWebRtcManager } from "../services/webrtc-manager.js";
|
|
|
20
20
|
import { createDataChannelHandler } from "../services/data-channel-handler.js";
|
|
21
21
|
import { collectHealthMetrics } from "../services/health-collector.js";
|
|
22
22
|
import { createPortMapper } from "../services/port-mapper.js";
|
|
23
|
+
import { classifyNat } from "../services/nat-classifier.js";
|
|
23
24
|
import { logger } from "../utils/logger.js";
|
|
24
25
|
|
|
25
26
|
const require = createRequire(import.meta.url);
|
|
@@ -125,6 +126,9 @@ let portMapper = null;
|
|
|
125
126
|
/** @type {ReturnType<typeof createPortMapper> | null} UDP mapping for the WebRTC port. */
|
|
126
127
|
let udpPortMapper = null;
|
|
127
128
|
|
|
129
|
+
/** @type {ReturnType<typeof createWebRtcManager> | null} */
|
|
130
|
+
let webRtcManager = null;
|
|
131
|
+
|
|
128
132
|
|
|
129
133
|
/**
|
|
130
134
|
* Register this proxy with the registry server.
|
|
@@ -203,6 +207,11 @@ async function shutdown(signal) {
|
|
|
203
207
|
await udpPortMapper.stop();
|
|
204
208
|
udpPortMapper = null;
|
|
205
209
|
}
|
|
210
|
+
// Close WebRTC sessions and release the shared UDP mux listener socket.
|
|
211
|
+
if (webRtcManager) {
|
|
212
|
+
try { webRtcManager.dispose(); } catch { /* ignore */ }
|
|
213
|
+
webRtcManager = null;
|
|
214
|
+
}
|
|
206
215
|
if (app) {
|
|
207
216
|
await app.close();
|
|
208
217
|
}
|
|
@@ -235,6 +244,11 @@ try {
|
|
|
235
244
|
logger.info(`Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`);
|
|
236
245
|
}
|
|
237
246
|
|
|
247
|
+
// All WebRTC sessions are multiplexed onto this single UDP port (same number
|
|
248
|
+
// as the HTTP port, different protocol) via a persistent ICE UDP mux listener
|
|
249
|
+
// in the WebRTC manager. One port → one UPnP mapping → one reachable endpoint.
|
|
250
|
+
const webrtcUdpPort = actualPort;
|
|
251
|
+
|
|
238
252
|
// Try to open the local port on the home router (UPnP/NAT-PMP) so the proxy
|
|
239
253
|
// is reachable from the internet without manual port forwarding. Best-effort
|
|
240
254
|
// and fire-and-forget: failure is normal (router without UPnP) and must not
|
|
@@ -256,13 +270,11 @@ try {
|
|
|
256
270
|
logger.warn(`Port mapping failed to start: ${message}`);
|
|
257
271
|
});
|
|
258
272
|
|
|
259
|
-
// Also map the WebRTC UDP port
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
// symmetric NAT. This endpoint is NOT reported to the server: the browser
|
|
263
|
-
// discovers it via ICE (srflx) candidates, not the TCP dial-back probe.
|
|
273
|
+
// Also map the single WebRTC UDP port. Not reported to the server: the
|
|
274
|
+
// browser discovers the endpoint via ICE (srflx) candidates, not the TCP
|
|
275
|
+
// dial-back probe.
|
|
264
276
|
udpPortMapper = createPortMapper({
|
|
265
|
-
port:
|
|
277
|
+
port: webrtcUdpPort,
|
|
266
278
|
protocol: "UDP",
|
|
267
279
|
description: "torrent-tv proxy (WebRTC)"
|
|
268
280
|
});
|
|
@@ -274,15 +286,36 @@ try {
|
|
|
274
286
|
logger.info("Automatic port mapping is disabled (--no-port-mapping).");
|
|
275
287
|
}
|
|
276
288
|
|
|
289
|
+
// Classify the home NAT (diagnostic + decides whether WebRTC will need port
|
|
290
|
+
// prediction for remote viewers). Best-effort, fire-and-forget — STUN probes
|
|
291
|
+
// never block startup.
|
|
292
|
+
void classifyNat()
|
|
293
|
+
.then((nat) => {
|
|
294
|
+
if (nat.klass === "endpoint-independent") {
|
|
295
|
+
logger.info(
|
|
296
|
+
`nat: endpoint-independent (cone) — external UDP port stable across STUN servers (${nat.externalIp}); fixed-port WebRTC mapping is sufficient, no port prediction needed`
|
|
297
|
+
);
|
|
298
|
+
} else if (nat.klass === "symmetric") {
|
|
299
|
+
logger.warn(
|
|
300
|
+
`nat: SYMMETRIC — external UDP port varies per destination (delta ${nat.portDelta}); WebRTC will need port prediction to reach remote viewers`
|
|
301
|
+
);
|
|
302
|
+
} else {
|
|
303
|
+
logger.info("nat: classification inconclusive (STUN probes failed); continuing");
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
.catch((error) => {
|
|
307
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
308
|
+
logger.warn(`nat classification failed: ${message}`);
|
|
309
|
+
});
|
|
310
|
+
|
|
277
311
|
// Create tunnel + WebRTC manager.
|
|
278
312
|
// The tunnel forwards WebRTC signals between browser (via server) and this proxy.
|
|
279
313
|
// The WebRTC manager handles the actual peer connection and data channel.
|
|
280
314
|
//
|
|
281
315
|
// We use a late-binding ref so both objects can reference each other without
|
|
282
|
-
// running into the TDZ (tunnelClient is declared above; webRtcManager
|
|
283
|
-
// so the closure in createTunnelClient
|
|
284
|
-
|
|
285
|
-
let webRtcManager = null;
|
|
316
|
+
// running into the TDZ (tunnelClient is declared above; webRtcManager is the
|
|
317
|
+
// module-scoped `let` above so the closure in createTunnelClient — and the
|
|
318
|
+
// shutdown handler — can reach it after initialisation).
|
|
286
319
|
|
|
287
320
|
tunnelClient = createTunnelClient({
|
|
288
321
|
serverUrl,
|
|
@@ -319,9 +352,10 @@ try {
|
|
|
319
352
|
});
|
|
320
353
|
|
|
321
354
|
webRtcManager = createWebRtcManager({
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
355
|
+
// Single UDP port (UPnP-mapped above) shared by all sessions via a
|
|
356
|
+
// persistent ICE UDP mux listener, so the WebRTC path is reachable from the
|
|
357
|
+
// internet on one fixed port.
|
|
358
|
+
udpPort: webrtcUdpPort,
|
|
325
359
|
sendSignal(sessionId, signal) {
|
|
326
360
|
tunnelClient?.sendSignal(sessionId, signal);
|
|
327
361
|
},
|
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
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file NAT classification via STUN (minimal, dependency-free client).
|
|
3
|
+
*
|
|
4
|
+
* Determines whether the proxy's home NAT preserves its external UDP port
|
|
5
|
+
* across destinations (endpoint-independent / "cone") or assigns a new port
|
|
6
|
+
* per destination ("symmetric"). This decides whether the fixed-UDP-port +
|
|
7
|
+
* UPnP mapping (the WebRTC reachability of proxy 2.9.18) is sufficient:
|
|
8
|
+
*
|
|
9
|
+
* - endpoint-independent → a static mapping works; no port prediction needed.
|
|
10
|
+
* - symmetric → the mapped port differs per viewer; WebRTC needs
|
|
11
|
+
* port prediction (a later roadmap step).
|
|
12
|
+
*
|
|
13
|
+
* Method: from ONE local UDP socket, send a STUN Binding Request to TWO
|
|
14
|
+
* different public STUN servers (different destinations) and compare the
|
|
15
|
+
* reported external port. Same port → endpoint-independent; different →
|
|
16
|
+
* symmetric. This is the modern, reliable test — unlike RFC 3489's CHANGE-
|
|
17
|
+
* REQUEST classification, it needs no special STUN-server support (works with
|
|
18
|
+
* Google/Cloudflare STUN). The two queries MUST share one socket: a fresh
|
|
19
|
+
* socket would get its own NAT mapping and make even a cone NAT look symmetric.
|
|
20
|
+
*
|
|
21
|
+
* Strictly best-effort: never throws; returns `klass: "unknown"` if the probes
|
|
22
|
+
* fail (STUN blocked, offline). Used for diagnostics/telemetry and to gate the
|
|
23
|
+
* future WebRTC port-prediction work.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import dgram from "node:dgram";
|
|
27
|
+
import crypto from "node:crypto";
|
|
28
|
+
|
|
29
|
+
const STUN_MAGIC_COOKIE = 0x2112a442;
|
|
30
|
+
const STUN_BINDING_REQUEST = 0x0001;
|
|
31
|
+
const ATTR_XOR_MAPPED_ADDRESS = 0x0020;
|
|
32
|
+
const ATTR_MAPPED_ADDRESS = 0x0001;
|
|
33
|
+
|
|
34
|
+
// Public STUN servers from DIFFERENT operators, so the destination genuinely
|
|
35
|
+
// differs between the two queries (required for the symmetric test).
|
|
36
|
+
const DEFAULT_STUN_SERVERS = [
|
|
37
|
+
{ host: "stun.l.google.com", port: 19302 },
|
|
38
|
+
{ host: "stun.cloudflare.com", port: 3478 },
|
|
39
|
+
{ host: "stun1.l.google.com", port: 19302 }
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const QUERY_TIMEOUT_MS = 4000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {object} NatObservation
|
|
46
|
+
* @property {string} server - "host:port" that was queried.
|
|
47
|
+
* @property {string} ip - Reflexive external IP reported.
|
|
48
|
+
* @property {number} port - Reflexive external port reported.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {object} NatClassification
|
|
53
|
+
* @property {"endpoint-independent" | "symmetric" | "unknown"} klass
|
|
54
|
+
* @property {string | null} externalIp - External IP (from the first success).
|
|
55
|
+
* @property {NatObservation[]} observations
|
|
56
|
+
* @property {number | null} portDelta - port(2nd) - port(1st) when symmetric, else null.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build a 20-byte STUN Binding Request with a random 96-bit transaction id.
|
|
61
|
+
*
|
|
62
|
+
* @returns {Buffer}
|
|
63
|
+
*/
|
|
64
|
+
function buildBindingRequest() {
|
|
65
|
+
const msg = Buffer.alloc(20);
|
|
66
|
+
msg.writeUInt16BE(STUN_BINDING_REQUEST, 0);
|
|
67
|
+
msg.writeUInt16BE(0, 2); // message length (no attributes)
|
|
68
|
+
msg.writeUInt32BE(STUN_MAGIC_COOKIE, 4);
|
|
69
|
+
crypto.randomFillSync(msg, 8, 12); // transaction id
|
|
70
|
+
return msg;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse the reflexive address from a STUN response, preferring
|
|
75
|
+
* XOR-MAPPED-ADDRESS and falling back to MAPPED-ADDRESS. IPv4 only.
|
|
76
|
+
*
|
|
77
|
+
* @param {Buffer} msg
|
|
78
|
+
* @returns {{ ip: string, port: number } | null}
|
|
79
|
+
*/
|
|
80
|
+
function parseMappedAddress(msg) {
|
|
81
|
+
if (msg.length < 20) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
let offset = 20;
|
|
85
|
+
while (offset + 4 <= msg.length) {
|
|
86
|
+
const type = msg.readUInt16BE(offset);
|
|
87
|
+
const length = msg.readUInt16BE(offset + 2);
|
|
88
|
+
const valueStart = offset + 4;
|
|
89
|
+
if (valueStart + length > msg.length) {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (type === ATTR_XOR_MAPPED_ADDRESS || type === ATTR_MAPPED_ADDRESS) {
|
|
94
|
+
const family = msg.readUInt8(valueStart + 1);
|
|
95
|
+
if (family === 0x01) {
|
|
96
|
+
// IPv4
|
|
97
|
+
const xored = type === ATTR_XOR_MAPPED_ADDRESS;
|
|
98
|
+
const rawPort = msg.readUInt16BE(valueStart + 2);
|
|
99
|
+
const port = xored ? rawPort ^ (STUN_MAGIC_COOKIE >>> 16) : rawPort;
|
|
100
|
+
const addrBytes = [
|
|
101
|
+
msg.readUInt8(valueStart + 4),
|
|
102
|
+
msg.readUInt8(valueStart + 5),
|
|
103
|
+
msg.readUInt8(valueStart + 6),
|
|
104
|
+
msg.readUInt8(valueStart + 7)
|
|
105
|
+
];
|
|
106
|
+
const cookieBytes = [
|
|
107
|
+
(STUN_MAGIC_COOKIE >>> 24) & 0xff,
|
|
108
|
+
(STUN_MAGIC_COOKIE >>> 16) & 0xff,
|
|
109
|
+
(STUN_MAGIC_COOKIE >>> 8) & 0xff,
|
|
110
|
+
STUN_MAGIC_COOKIE & 0xff
|
|
111
|
+
];
|
|
112
|
+
const ipParts = addrBytes.map((b, i) => (xored ? b ^ cookieBytes[i] : b));
|
|
113
|
+
return { ip: ipParts.join("."), port };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Attributes are padded to 4-byte boundaries.
|
|
118
|
+
offset = valueStart + length + ((4 - (length % 4)) % 4);
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Send one Binding Request to `host:port` over the given socket and resolve
|
|
125
|
+
* with the reflexive address, or null on timeout / parse failure.
|
|
126
|
+
*
|
|
127
|
+
* @param {import("node:dgram").Socket} socket
|
|
128
|
+
* @param {string} host
|
|
129
|
+
* @param {number} port
|
|
130
|
+
* @returns {Promise<{ ip: string, port: number } | null>}
|
|
131
|
+
*/
|
|
132
|
+
function queryStun(socket, host, port) {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
let settled = false;
|
|
135
|
+
const onMessage = (msg) => {
|
|
136
|
+
if (settled) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const parsed = parseMappedAddress(msg);
|
|
140
|
+
if (!parsed) {
|
|
141
|
+
return; // ignore unrelated datagrams; let the timeout fire if needed
|
|
142
|
+
}
|
|
143
|
+
settled = true;
|
|
144
|
+
socket.off("message", onMessage);
|
|
145
|
+
resolve(parsed);
|
|
146
|
+
};
|
|
147
|
+
socket.on("message", onMessage);
|
|
148
|
+
socket.send(buildBindingRequest(), port, host, (err) => {
|
|
149
|
+
if (err && !settled) {
|
|
150
|
+
settled = true;
|
|
151
|
+
socket.off("message", onMessage);
|
|
152
|
+
resolve(null);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
const timer = setTimeout(() => {
|
|
156
|
+
if (!settled) {
|
|
157
|
+
settled = true;
|
|
158
|
+
socket.off("message", onMessage);
|
|
159
|
+
resolve(null);
|
|
160
|
+
}
|
|
161
|
+
}, QUERY_TIMEOUT_MS);
|
|
162
|
+
timer.unref?.();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Classify the host's NAT by comparing the external port seen by two different
|
|
168
|
+
* STUN servers over a single local socket.
|
|
169
|
+
*
|
|
170
|
+
* @param {{ servers?: Array<{ host: string, port: number }> }} [options]
|
|
171
|
+
* @returns {Promise<NatClassification>}
|
|
172
|
+
*/
|
|
173
|
+
export async function classifyNat({ servers = DEFAULT_STUN_SERVERS } = {}) {
|
|
174
|
+
/** @type {NatClassification} */
|
|
175
|
+
const unknown = { klass: "unknown", externalIp: null, observations: [], portDelta: null };
|
|
176
|
+
|
|
177
|
+
const socket = dgram.createSocket("udp4");
|
|
178
|
+
try {
|
|
179
|
+
await new Promise((resolve, reject) => {
|
|
180
|
+
socket.once("error", reject);
|
|
181
|
+
socket.bind(0, () => {
|
|
182
|
+
socket.off("error", reject);
|
|
183
|
+
resolve(undefined);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
} catch {
|
|
187
|
+
try {
|
|
188
|
+
socket.close();
|
|
189
|
+
} catch {
|
|
190
|
+
// ignore
|
|
191
|
+
}
|
|
192
|
+
return unknown;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** @type {NatObservation[]} */
|
|
196
|
+
const observations = [];
|
|
197
|
+
try {
|
|
198
|
+
for (const server of servers) {
|
|
199
|
+
const result = await queryStun(socket, server.host, server.port);
|
|
200
|
+
if (result) {
|
|
201
|
+
observations.push({ server: `${server.host}:${server.port}`, ip: result.ip, port: result.port });
|
|
202
|
+
}
|
|
203
|
+
// Stop once we have two observations from two different destinations.
|
|
204
|
+
if (observations.length >= 2) {
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
try {
|
|
210
|
+
socket.close();
|
|
211
|
+
} catch {
|
|
212
|
+
// ignore
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (observations.length < 2) {
|
|
217
|
+
return { ...unknown, observations };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const [first, second] = observations;
|
|
221
|
+
const externalIp = first.ip;
|
|
222
|
+
if (first.port === second.port) {
|
|
223
|
+
return { klass: "endpoint-independent", externalIp, observations, portDelta: null };
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
klass: "symmetric",
|
|
227
|
+
externalIp,
|
|
228
|
+
observations,
|
|
229
|
+
portDelta: second.port - first.port
|
|
230
|
+
};
|
|
231
|
+
}
|
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} */
|
|
@@ -106,12 +116,15 @@ export function createPortMapper({
|
|
|
106
116
|
* @param {InstanceType<typeof NatAPI>} instance
|
|
107
117
|
* @returns {Promise<void>}
|
|
108
118
|
*/
|
|
109
|
-
async function safeDestroy(instance) {
|
|
119
|
+
async function safeDestroy(instance, { logRemoval = false } = {}) {
|
|
110
120
|
try {
|
|
111
121
|
await withTimeout(instance.destroy(), STOP_TIMEOUT_MS, "destroy");
|
|
122
|
+
if (logRemoval) {
|
|
123
|
+
logger.info(`port-mapper: removed mapping for ${label}`);
|
|
124
|
+
}
|
|
112
125
|
} catch (error) {
|
|
113
126
|
// Lease expiry (ttl) is the backstop if we cannot unmap cleanly.
|
|
114
|
-
logger.warn(`port-mapper: failed to remove
|
|
127
|
+
logger.warn(`port-mapper: failed to remove ${label} mapping cleanly: ${describeError(error)}`);
|
|
115
128
|
}
|
|
116
129
|
}
|
|
117
130
|
|
|
@@ -130,16 +143,25 @@ export function createPortMapper({
|
|
|
130
143
|
}
|
|
131
144
|
|
|
132
145
|
let instance;
|
|
146
|
+
let mappedCount = 0;
|
|
133
147
|
try {
|
|
134
148
|
instance = new NatAPI({ ttl: ttlSeconds, autoUpdate: true, description });
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
}
|
|
140
164
|
} catch (error) {
|
|
141
|
-
// No UPnP/NAT-PMP on this router, or it declined. Normal, non-fatal: the
|
|
142
|
-
// proxy still works on LAN and wherever hole punching succeeds.
|
|
143
165
|
mappedEndpoint = null;
|
|
144
166
|
logger.warn(`port-mapper: no port mapping available (${describeError(error)}); continuing without it`);
|
|
145
167
|
if (instance) {
|
|
@@ -148,8 +170,17 @@ export function createPortMapper({
|
|
|
148
170
|
return;
|
|
149
171
|
}
|
|
150
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
|
+
|
|
151
182
|
// Mapping succeeded — keep the instance so its auto-renew timers stay alive
|
|
152
|
-
// and stop() can remove the
|
|
183
|
+
// and stop() can remove the mappings later.
|
|
153
184
|
nat = instance;
|
|
154
185
|
|
|
155
186
|
// Discover the external IP (best-effort; the mapping is valid without it).
|
|
@@ -157,17 +188,18 @@ export function createPortMapper({
|
|
|
157
188
|
try {
|
|
158
189
|
externalIp = await withTimeout(instance.externalIp(), START_TIMEOUT_MS, "externalIp");
|
|
159
190
|
} catch (error) {
|
|
160
|
-
logger.warn(`port-mapper: mapped ${
|
|
191
|
+
logger.warn(`port-mapper: mapped ${label} but could not read external IP: ${describeError(error)}`);
|
|
161
192
|
}
|
|
162
193
|
|
|
163
194
|
mappedEndpoint = { externalIp: externalIp || null, externalPort: port, protocol };
|
|
195
|
+
const counts = portList.length > 1 ? ` (${mappedCount}/${portList.length} ports)` : "";
|
|
164
196
|
if (externalIp) {
|
|
165
197
|
logger.success(
|
|
166
|
-
`port-mapper: mapped ${externalIp}
|
|
198
|
+
`port-mapper: mapped ${externalIp} → ${label}${counts} (ttl ${ttlSeconds}s, auto-renew)`
|
|
167
199
|
);
|
|
168
200
|
} else {
|
|
169
201
|
logger.info(
|
|
170
|
-
`port-mapper: mapped ${
|
|
202
|
+
`port-mapper: mapped ${label}${counts} (external IP unknown; ttl ${ttlSeconds}s, auto-renew)`
|
|
171
203
|
);
|
|
172
204
|
}
|
|
173
205
|
}
|
|
@@ -182,7 +214,7 @@ export function createPortMapper({
|
|
|
182
214
|
const instance = nat;
|
|
183
215
|
nat = null;
|
|
184
216
|
mappedEndpoint = null;
|
|
185
|
-
await safeDestroy(instance);
|
|
217
|
+
await safeDestroy(instance, { logRemoval: true });
|
|
186
218
|
}
|
|
187
219
|
|
|
188
220
|
/**
|
|
@@ -71,10 +71,19 @@ function isPrivateHostCandidate(candidate) {
|
|
|
71
71
|
* @property {(message: string) => void} [onLog]
|
|
72
72
|
* Optional log sink.
|
|
73
73
|
* @property {number} [udpPort]
|
|
74
|
-
* When set,
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* When set, all WebRTC sessions are multiplexed onto this single UDP port so
|
|
75
|
+
* it can be statically UPnP-mapped (one mapping, one reachable port). A
|
|
76
|
+
* persistent {@link import("node-datachannel").IceUdpMuxListener} is created
|
|
77
|
+
* once at startup and owns the shared socket; every PeerConnection enables
|
|
78
|
+
* `enableIceUdpMux` on the same port and demuxes over it by ICE ufrag.
|
|
79
|
+
*
|
|
80
|
+
* The persistent listener is the crucial part: per-PeerConnection
|
|
81
|
+
* `enableIceUdpMux` WITHOUT it ties the shared socket to a connection's
|
|
82
|
+
* lifetime, so a freshly-opened session fails to bind the port while a
|
|
83
|
+
* just-closed one still holds it → "Failed to gather local ICE candidates"
|
|
84
|
+
* (which crashed the proxy). The listener keeps the socket alive across
|
|
85
|
+
* sessions; verified with sequential + concurrent connections on one port.
|
|
86
|
+
* When omitted, node-datachannel uses an ephemeral UDP port.
|
|
78
87
|
*/
|
|
79
88
|
|
|
80
89
|
/**
|
|
@@ -86,6 +95,9 @@ function isPrivateHostCandidate(candidate) {
|
|
|
86
95
|
* the matching peer connection, creating it if necessary.
|
|
87
96
|
* @property {(sessionId: string) => void} closeSession
|
|
88
97
|
* Tear down and remove a peer connection by session ID.
|
|
98
|
+
* @property {() => void} dispose
|
|
99
|
+
* Close all peer connections and stop the shared UDP mux listener. Call on
|
|
100
|
+
* proxy shutdown so the listener's socket is released.
|
|
89
101
|
*/
|
|
90
102
|
|
|
91
103
|
/**
|
|
@@ -98,16 +110,6 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
|
|
|
98
110
|
/** @type {Map<string, import("node-datachannel").PeerConnection>} */
|
|
99
111
|
const peers = new Map();
|
|
100
112
|
|
|
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
113
|
/**
|
|
112
114
|
* @param {string} message
|
|
113
115
|
* @returns {void}
|
|
@@ -118,6 +120,36 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
|
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
// Base PeerConnection config shared by every session.
|
|
124
|
+
const pcConfig = { iceServers: ICE_SERVERS };
|
|
125
|
+
|
|
126
|
+
// Single-port UDP mux: create ONE persistent listener that owns the shared
|
|
127
|
+
// UDP socket for the proxy's whole lifetime, then have every PeerConnection
|
|
128
|
+
// mux over it (enableIceUdpMux + the same fixed port). The listener must
|
|
129
|
+
// outlive individual sessions — without it the socket is bound/freed per
|
|
130
|
+
// connection and a repeat session fails to gather ICE candidates.
|
|
131
|
+
/** @type {import("node-datachannel").IceUdpMuxListener | null} */
|
|
132
|
+
let udpMuxListener = null;
|
|
133
|
+
if (Number.isInteger(udpPort) && udpPort > 0 && udpPort <= 65535) {
|
|
134
|
+
try {
|
|
135
|
+
udpMuxListener = new nodeDataChannel.IceUdpMuxListener(udpPort);
|
|
136
|
+
// STUN that doesn't match an existing session (our PeerConnections are
|
|
137
|
+
// created from the SDP offer before the browser's STUN arrives, so normal
|
|
138
|
+
// traffic is "handled"). Stray/unhandled requests are simply dropped.
|
|
139
|
+
udpMuxListener.onUnhandledStunRequest(() => {});
|
|
140
|
+
pcConfig.enableIceUdpMux = true;
|
|
141
|
+
pcConfig.portRangeBegin = udpPort;
|
|
142
|
+
pcConfig.portRangeEnd = udpPort;
|
|
143
|
+
log(`[webrtc] UDP mux listener bound on port ${udpPort}; all sessions share it`);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// Could not bind the mux port — fall back to ephemeral UDP ports (no
|
|
146
|
+
// single-port reachability, but the proxy still works on LAN / via STUN).
|
|
147
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
148
|
+
log(`[webrtc] UDP mux listener failed on port ${udpPort} (${message}); using ephemeral ports`);
|
|
149
|
+
udpMuxListener = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
121
153
|
/**
|
|
122
154
|
* Retrieve an existing peer connection or create a new one for the session.
|
|
123
155
|
*
|
|
@@ -144,7 +176,11 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
|
|
|
144
176
|
// the connection proceeds via the local LAN path.
|
|
145
177
|
pc.onLocalCandidate((candidate, mid) => {
|
|
146
178
|
const isPrivate = isPrivateHostCandidate(candidate);
|
|
147
|
-
|
|
179
|
+
// Log the full candidate (addr:port typ …) so we can confirm WebRTC is
|
|
180
|
+
// pinned to the mapped UDP port and diagnose which paths are offered.
|
|
181
|
+
log(
|
|
182
|
+
`[webrtc] Session ${sessionId.slice(0, 8)}: sending ${isPrivate ? "private" : "public"} candidate: ${candidate.replace(/^a=/, "")}`
|
|
183
|
+
);
|
|
148
184
|
sendSignal(sessionId, { type: "candidate", candidate, mid });
|
|
149
185
|
});
|
|
150
186
|
|
|
@@ -160,6 +196,22 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
|
|
|
160
196
|
|
|
161
197
|
pc.onStateChange((state) => {
|
|
162
198
|
log(`[webrtc] Session ${sessionId.slice(0, 8)}: state → ${state}`);
|
|
199
|
+
// On connect, log which candidate pair actually won — this is the single
|
|
200
|
+
// most useful line for "did the open-port/WebRTC path work, and over
|
|
201
|
+
// which route (LAN / public srflx v4 / v6)".
|
|
202
|
+
if (state === "connected") {
|
|
203
|
+
try {
|
|
204
|
+
const pair = pc.getSelectedCandidatePair();
|
|
205
|
+
if (pair) {
|
|
206
|
+
const fmt = (c) => `${c.type} ${c.address}:${c.port}/${c.transportType}`;
|
|
207
|
+
log(
|
|
208
|
+
`[webrtc] Session ${sessionId.slice(0, 8)}: selected pair local=[${fmt(pair.local)}] remote=[${fmt(pair.remote)}]`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
// Diagnostics only — never let a logging call affect the connection.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
163
215
|
// "disconnected" is a transient state — ICE may recover on its own.
|
|
164
216
|
// Only tear down on terminal states: "failed" and "closed".
|
|
165
217
|
if (state === "failed" || state === "closed") {
|
|
@@ -167,6 +219,12 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
|
|
|
167
219
|
}
|
|
168
220
|
});
|
|
169
221
|
|
|
222
|
+
// Granular ICE-level transitions (checking → connected/failed) — finer than
|
|
223
|
+
// the peer state above; pinpoints where a failing connection stalls.
|
|
224
|
+
pc.onIceStateChange((state) => {
|
|
225
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: ICE → ${state}`);
|
|
226
|
+
});
|
|
227
|
+
|
|
170
228
|
// Browser creates the data channel — we receive it here.
|
|
171
229
|
pc.onDataChannel((channel) => {
|
|
172
230
|
log(`[webrtc] Session ${sessionId.slice(0, 8)}: data channel "${channel.getLabel()}" opened`);
|
|
@@ -199,9 +257,19 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
|
|
|
199
257
|
return;
|
|
200
258
|
}
|
|
201
259
|
log(`[webrtc] Session ${sessionId.slice(0, 8)}: received offer`);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
//
|
|
260
|
+
// node-datachannel can throw SYNCHRONOUSLY here (e.g. "Failed to gather
|
|
261
|
+
// local ICE candidates" when the pinned UDP port cannot be bound). A
|
|
262
|
+
// single bad session must never crash the whole proxy — that would drop
|
|
263
|
+
// every other viewer and the tunnel. Contain it: fail this session only.
|
|
264
|
+
try {
|
|
265
|
+
const pc = getOrCreatePeer(sessionId);
|
|
266
|
+
pc.setRemoteDescription(signal.sdp, "offer");
|
|
267
|
+
// `onLocalDescription` fires automatically with the SDP answer.
|
|
268
|
+
} catch (error) {
|
|
269
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
270
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: failed to handle offer: ${message}`);
|
|
271
|
+
closeSession(sessionId);
|
|
272
|
+
}
|
|
205
273
|
return;
|
|
206
274
|
}
|
|
207
275
|
|
|
@@ -211,7 +279,12 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
|
|
|
211
279
|
return;
|
|
212
280
|
}
|
|
213
281
|
if (typeof signal.candidate === "string" && typeof signal.mid === "string") {
|
|
214
|
-
|
|
282
|
+
try {
|
|
283
|
+
pc.addRemoteCandidate(signal.candidate, signal.mid);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
286
|
+
log(`[webrtc] Session ${sessionId.slice(0, 8)}: failed to add candidate: ${message}`);
|
|
287
|
+
}
|
|
215
288
|
}
|
|
216
289
|
}
|
|
217
290
|
}
|
|
@@ -232,5 +305,20 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
|
|
|
232
305
|
}
|
|
233
306
|
}
|
|
234
307
|
|
|
235
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Close all peer connections and stop the shared UDP mux listener.
|
|
310
|
+
*
|
|
311
|
+
* @returns {void}
|
|
312
|
+
*/
|
|
313
|
+
function dispose() {
|
|
314
|
+
for (const sessionId of [...peers.keys()]) {
|
|
315
|
+
closeSession(sessionId);
|
|
316
|
+
}
|
|
317
|
+
if (udpMuxListener) {
|
|
318
|
+
try { udpMuxListener.stop(); } catch { /* ignore */ }
|
|
319
|
+
udpMuxListener = null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return { handleSignal, closeSession, dispose };
|
|
236
324
|
}
|