@torrent-tv/proxy 2.9.17 → 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 +12 -0
- package/bin/cli.js +49 -1
- package/package.json +1 -1
- package/services/nat-classifier.js +231 -0
- package/services/port-mapper.js +6 -3
- package/services/webrtc-manager.js +44 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
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
|
+
|
|
9
|
+
## 2.9.18
|
|
10
|
+
|
|
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).
|
|
12
|
+
|
|
1
13
|
## 2.9.17
|
|
2
14
|
|
|
3
15
|
- **New**: The proxy reports its UPnP-mapped external endpoint to the server over the tunnel (new `proxy-endpoint` message: `{ externalIp, externalPort, protocol }` from `port-mapper.getMappedEndpoint()`). Sent when the mapping completes and re-sent on every tunnel (re)connect, so the server can dial back and verify the proxy is reachable from the internet (server 0.8.22). No effect if port mapping is disabled or failed.
|
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);
|
|
@@ -122,6 +123,9 @@ let tunnelClient = null;
|
|
|
122
123
|
/** @type {ReturnType<typeof createPortMapper> | null} */
|
|
123
124
|
let portMapper = null;
|
|
124
125
|
|
|
126
|
+
/** @type {ReturnType<typeof createPortMapper> | null} UDP mapping for the WebRTC port. */
|
|
127
|
+
let udpPortMapper = null;
|
|
128
|
+
|
|
125
129
|
|
|
126
130
|
/**
|
|
127
131
|
* Register this proxy with the registry server.
|
|
@@ -190,12 +194,16 @@ async function shutdown(signal) {
|
|
|
190
194
|
}
|
|
191
195
|
logger.warn(`Received ${signal}, shutting down...`);
|
|
192
196
|
try {
|
|
193
|
-
// Remove the router port
|
|
197
|
+
// Remove the router port mappings before exiting (lease expiry is the
|
|
194
198
|
// backstop if this is skipped on a hard kill).
|
|
195
199
|
if (portMapper) {
|
|
196
200
|
await portMapper.stop();
|
|
197
201
|
portMapper = null;
|
|
198
202
|
}
|
|
203
|
+
if (udpPortMapper) {
|
|
204
|
+
await udpPortMapper.stop();
|
|
205
|
+
udpPortMapper = null;
|
|
206
|
+
}
|
|
199
207
|
if (app) {
|
|
200
208
|
await app.close();
|
|
201
209
|
}
|
|
@@ -248,10 +256,47 @@ try {
|
|
|
248
256
|
const message = error instanceof Error ? error.message : String(error);
|
|
249
257
|
logger.warn(`Port mapping failed to start: ${message}`);
|
|
250
258
|
});
|
|
259
|
+
|
|
260
|
+
// Also map the WebRTC UDP port (same number, different protocol). All
|
|
261
|
+
// WebRTC sessions are multiplexed onto this single UDP port (ICE UDP mux),
|
|
262
|
+
// so a static mapping makes the proxy's WebRTC path reachable even behind
|
|
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.
|
|
265
|
+
udpPortMapper = createPortMapper({
|
|
266
|
+
port: actualPort,
|
|
267
|
+
protocol: "UDP",
|
|
268
|
+
description: "torrent-tv proxy (WebRTC)"
|
|
269
|
+
});
|
|
270
|
+
void udpPortMapper.start().catch((error) => {
|
|
271
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
272
|
+
logger.warn(`UDP port mapping failed to start: ${message}`);
|
|
273
|
+
});
|
|
251
274
|
} else {
|
|
252
275
|
logger.info("Automatic port mapping is disabled (--no-port-mapping).");
|
|
253
276
|
}
|
|
254
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
|
+
|
|
255
300
|
// Create tunnel + WebRTC manager.
|
|
256
301
|
// The tunnel forwards WebRTC signals between browser (via server) and this proxy.
|
|
257
302
|
// The WebRTC manager handles the actual peer connection and data channel.
|
|
@@ -297,6 +342,9 @@ try {
|
|
|
297
342
|
});
|
|
298
343
|
|
|
299
344
|
webRtcManager = createWebRtcManager({
|
|
345
|
+
// Pin all WebRTC sessions to this single UDP port (multiplexed via ICE UDP
|
|
346
|
+
// mux) so the UPnP UDP mapping above makes the WebRTC path reachable.
|
|
347
|
+
udpPort: actualPort,
|
|
300
348
|
sendSignal(sessionId, signal) {
|
|
301
349
|
tunnelClient?.sendSignal(sessionId, signal);
|
|
302
350
|
},
|
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
|
/**
|
|
@@ -70,6 +70,11 @@ function isPrivateHostCandidate(candidate) {
|
|
|
70
70
|
* `node-datachannel` `DataChannel` object; hand it to `createDataChannelHandler`.
|
|
71
71
|
* @property {(message: string) => void} [onLog]
|
|
72
72
|
* Optional log sink.
|
|
73
|
+
* @property {number} [udpPort]
|
|
74
|
+
* When set, every PeerConnection is pinned to this single UDP port and ICE
|
|
75
|
+
* UDP multiplexing is enabled, so all sessions share one port that can be
|
|
76
|
+
* statically UPnP-mapped (makes the WebRTC path reachable behind NAT). When
|
|
77
|
+
* omitted, node-datachannel uses an ephemeral UDP port (previous behaviour).
|
|
73
78
|
*/
|
|
74
79
|
|
|
75
80
|
/**
|
|
@@ -89,10 +94,20 @@ function isPrivateHostCandidate(candidate) {
|
|
|
89
94
|
* @param {WebRtcManagerOptions} options
|
|
90
95
|
* @returns {WebRtcManager}
|
|
91
96
|
*/
|
|
92
|
-
export function createWebRtcManager({ sendSignal, onDataChannel, onLog }) {
|
|
97
|
+
export function createWebRtcManager({ sendSignal, onDataChannel, onLog, udpPort }) {
|
|
93
98
|
/** @type {Map<string, import("node-datachannel").PeerConnection>} */
|
|
94
99
|
const peers = new Map();
|
|
95
100
|
|
|
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
|
+
|
|
96
111
|
/**
|
|
97
112
|
* @param {string} message
|
|
98
113
|
* @returns {void}
|
|
@@ -118,9 +133,7 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog }) {
|
|
|
118
133
|
return existing;
|
|
119
134
|
}
|
|
120
135
|
|
|
121
|
-
const pc = new nodeDataChannel.PeerConnection(`proxy-${sessionId.slice(0, 8)}`,
|
|
122
|
-
iceServers: ICE_SERVERS
|
|
123
|
-
});
|
|
136
|
+
const pc = new nodeDataChannel.PeerConnection(`proxy-${sessionId.slice(0, 8)}`, pcConfig);
|
|
124
137
|
|
|
125
138
|
// Forward all ICE candidates to the browser through the tunnel.
|
|
126
139
|
//
|
|
@@ -131,7 +144,11 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog }) {
|
|
|
131
144
|
// the connection proceeds via the local LAN path.
|
|
132
145
|
pc.onLocalCandidate((candidate, mid) => {
|
|
133
146
|
const isPrivate = isPrivateHostCandidate(candidate);
|
|
134
|
-
|
|
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
|
+
);
|
|
135
152
|
sendSignal(sessionId, { type: "candidate", candidate, mid });
|
|
136
153
|
});
|
|
137
154
|
|
|
@@ -147,6 +164,22 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog }) {
|
|
|
147
164
|
|
|
148
165
|
pc.onStateChange((state) => {
|
|
149
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
|
+
}
|
|
150
183
|
// "disconnected" is a transient state — ICE may recover on its own.
|
|
151
184
|
// Only tear down on terminal states: "failed" and "closed".
|
|
152
185
|
if (state === "failed" || state === "closed") {
|
|
@@ -154,6 +187,12 @@ export function createWebRtcManager({ sendSignal, onDataChannel, onLog }) {
|
|
|
154
187
|
}
|
|
155
188
|
});
|
|
156
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
|
+
|
|
157
196
|
// Browser creates the data channel — we receive it here.
|
|
158
197
|
pc.onDataChannel((channel) => {
|
|
159
198
|
log(`[webrtc] Session ${sessionId.slice(0, 8)}: data channel "${channel.getLabel()}" opened`);
|