@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 +8 -0
- package/bin/cli.js +23 -0
- package/package.json +1 -1
- package/services/nat-classifier.js +231 -0
- package/services/port-mapper.js +6 -3
- package/services/webrtc-manager.js +27 -1
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
|
@@ -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
|
@@ -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
|
-
|
|
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`);
|