@torrent-tv/proxy 2.9.18 → 2.9.19

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 CHANGED
@@ -1,3 +1,11 @@
1
+ ## 2.9.20
2
+
3
+ - **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.
4
+
5
+ ## 2.9.19
6
+
7
+ - **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)".
8
+
1
9
  ## 2.9.18
2
10
 
3
11
  - **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);
@@ -274,6 +275,28 @@ try {
274
275
  logger.info("Automatic port mapping is disabled (--no-port-mapping).");
275
276
  }
276
277
 
278
+ // Classify the home NAT (diagnostic + decides whether WebRTC will need port
279
+ // prediction for remote viewers). Best-effort, fire-and-forget — STUN probes
280
+ // never block startup.
281
+ void classifyNat()
282
+ .then((nat) => {
283
+ if (nat.klass === "endpoint-independent") {
284
+ logger.info(
285
+ `nat: endpoint-independent (cone) — external UDP port stable across STUN servers (${nat.externalIp}); fixed-port WebRTC mapping is sufficient, no port prediction needed`
286
+ );
287
+ } else if (nat.klass === "symmetric") {
288
+ logger.warn(
289
+ `nat: SYMMETRIC — external UDP port varies per destination (delta ${nat.portDelta}); WebRTC will need port prediction to reach remote viewers`
290
+ );
291
+ } else {
292
+ logger.info("nat: classification inconclusive (STUN probes failed); continuing");
293
+ }
294
+ })
295
+ .catch((error) => {
296
+ const message = error instanceof Error ? error.message : String(error);
297
+ logger.warn(`nat classification failed: ${message}`);
298
+ });
299
+
277
300
  // Create tunnel + WebRTC manager.
278
301
  // The tunnel forwards WebRTC signals between browser (via server) and this proxy.
279
302
  // The WebRTC manager handles the actual peer connection and data channel.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.18",
3
+ "version": "2.9.19",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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
+ }
@@ -106,12 +106,15 @@ export function createPortMapper({
106
106
  * @param {InstanceType<typeof NatAPI>} instance
107
107
  * @returns {Promise<void>}
108
108
  */
109
- async function safeDestroy(instance) {
109
+ async function safeDestroy(instance, { logRemoval = false } = {}) {
110
110
  try {
111
111
  await withTimeout(instance.destroy(), STOP_TIMEOUT_MS, "destroy");
112
+ if (logRemoval) {
113
+ logger.info(`port-mapper: removed mapping for ${protocol} ${port}`);
114
+ }
112
115
  } catch (error) {
113
116
  // Lease expiry (ttl) is the backstop if we cannot unmap cleanly.
114
- logger.warn(`port-mapper: failed to remove port mapping cleanly: ${describeError(error)}`);
117
+ logger.warn(`port-mapper: failed to remove ${protocol} ${port} mapping cleanly: ${describeError(error)}`);
115
118
  }
116
119
  }
117
120
 
@@ -182,7 +185,7 @@ export function createPortMapper({
182
185
  const instance = nat;
183
186
  nat = null;
184
187
  mappedEndpoint = null;
185
- await safeDestroy(instance);
188
+ await safeDestroy(instance, { logRemoval: true });
186
189
  }
187
190
 
188
191
  /**
@@ -144,7 +144,11 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
144
144
  // the connection proceeds via the local LAN path.
145
145
  pc.onLocalCandidate((candidate, mid) => {
146
146
  const isPrivate = isPrivateHostCandidate(candidate);
147
- log(`[webrtc] Session ${sessionId.slice(0, 8)}: sending ${isPrivate ? "private" : "public"} candidate`);
147
+ // Log the full candidate (addr:port typ …) so we can confirm WebRTC is
148
+ // pinned to the mapped UDP port and diagnose which paths are offered.
149
+ log(
150
+ `[webrtc] Session ${sessionId.slice(0, 8)}: sending ${isPrivate ? "private" : "public"} candidate: ${candidate.replace(/^a=/, "")}`
151
+ );
148
152
  sendSignal(sessionId, { type: "candidate", candidate, mid });
149
153
  });
150
154
 
@@ -160,6 +164,22 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
160
164
 
161
165
  pc.onStateChange((state) => {
162
166
  log(`[webrtc] Session ${sessionId.slice(0, 8)}: state → ${state}`);
167
+ // On connect, log which candidate pair actually won — this is the single
168
+ // most useful line for "did the open-port/WebRTC path work, and over
169
+ // which route (LAN / public srflx v4 / v6)".
170
+ if (state === "connected") {
171
+ try {
172
+ const pair = pc.getSelectedCandidatePair();
173
+ if (pair) {
174
+ const fmt = (c) => `${c.type} ${c.address}:${c.port}/${c.transportType}`;
175
+ log(
176
+ `[webrtc] Session ${sessionId.slice(0, 8)}: selected pair local=[${fmt(pair.local)}] remote=[${fmt(pair.remote)}]`
177
+ );
178
+ }
179
+ } catch {
180
+ // Diagnostics only — never let a logging call affect the connection.
181
+ }
182
+ }
163
183
  // "disconnected" is a transient state — ICE may recover on its own.
164
184
  // Only tear down on terminal states: "failed" and "closed".
165
185
  if (state === "failed" || state === "closed") {
@@ -167,6 +187,12 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort
167
187
  }
168
188
  });
169
189
 
190
+ // Granular ICE-level transitions (checking → connected/failed) — finer than
191
+ // the peer state above; pinpoints where a failing connection stalls.
192
+ pc.onIceStateChange((state) => {
193
+ log(`[webrtc] Session ${sessionId.slice(0, 8)}: ICE → ${state}`);
194
+ });
195
+
170
196
  // Browser creates the data channel — we receive it here.
171
197
  pc.onDataChannel((channel) => {
172
198
  log(`[webrtc] Session ${sessionId.slice(0, 8)}: data channel "${channel.getLabel()}" opened`);