@torrent-tv/proxy 2.9.16 → 2.9.18
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/CLAUDE.md +27 -23
- package/bin/cli.js +45 -3
- package/package.json +1 -1
- package/services/tunnel-client.js +15 -0
- package/services/webrtc-manager.js +17 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.9.18
|
|
2
|
+
|
|
3
|
+
- **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).
|
|
4
|
+
|
|
5
|
+
## 2.9.17
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
|
|
1
9
|
## 2.9.16
|
|
2
10
|
|
|
3
11
|
- **New**: Automatic port mapping (`services/port-mapper.js`). At startup the proxy asks the home router to open its local port (default TCP 9090) via UPnP IGD / NAT-PMP using `@silentbot1/nat-api` (the same library WebTorrent already uses for the torrent port — no new host dependency). The mapping uses a 2 h lease auto-renewed while running and is removed on graceful shutdown (wired into the `cli.js` shutdown path; lease expiry is the backstop on a hard kill). Strictly best-effort: a router without UPnP/NAT-PMP is a normal case — it is logged and the proxy continues. Bounded by start/stop timeouts so a non-responding gateway never delays startup or hangs shutdown. Disable with `--no-port-mapping`. The discovered external endpoint is exposed via `getMappedEndpoint()` for the upcoming server-side reachability probe (not yet reported). `@silentbot1/nat-api` is now a direct dependency (was transitive via WebTorrent).
|
package/CLAUDE.md
CHANGED
|
@@ -56,24 +56,29 @@ Decided direction — full plan in the parent `../CLAUDE.md`. Proxy-side pieces:
|
|
|
56
56
|
same lib WebTorrent uses for the torrent port). Maps TCP 9090 with a 2 h
|
|
57
57
|
auto-renewed lease, removed on shutdown (lease expiry covers hard kills).
|
|
58
58
|
Best-effort + start/stop timeouts; `--no-port-mapping` opts out;
|
|
59
|
-
`getMappedEndpoint()` exposes the external endpoint
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
`getMappedEndpoint()` exposes the external endpoint. NOT yet done: mapping the
|
|
60
|
+
**UDP** port WebRTC actually uses (it binds ephemeral UDP ports, so this TCP
|
|
61
|
+
mapping does not yet help WebRTC — roadmap step 3 in the parent CLAUDE.md).
|
|
62
|
+
Also pending (next iteration): a success log line in `port-mapper.js` `stop()`
|
|
63
|
+
(`removed mapping for TCP <port>`) — today stop() only logs on failure, so a
|
|
64
|
+
clean unmap on shutdown is silent.
|
|
65
|
+
- **Report endpoint to server** — ✅ DONE (proxy 2.9.17). The mapped endpoint
|
|
66
|
+
is sent over the tunnel (`tunnel-client.sendEndpoint` → `proxy-endpoint`) on
|
|
67
|
+
mapping success and on every tunnel (re)connect; the server dial-back-verifies
|
|
68
|
+
reachability (server 0.8.22, roadmap step 2).
|
|
66
69
|
- **HTTPS listener**: serve the existing routes over TLS with a per-proxy
|
|
67
70
|
certificate delivered by the server through the tunnel (persist cert+key
|
|
68
71
|
locally; ~90-day renewals are pushed the same way). Add CORS headers for the
|
|
69
72
|
web-app origin so hls.js / `<video>` can fetch cross-origin.
|
|
70
73
|
- Plain HTTPS becomes the preferred video transport; WebRTC data channel stays
|
|
71
74
|
as fallback for hosts where no port could be opened.
|
|
72
|
-
- **
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
- **Later roadmap steps** (single staged roadmap in parent `../CLAUDE.md`,
|
|
76
|
+
WebRTC-first ordering): step 3 map the WebRTC UDP port (fixed
|
|
77
|
+
`portRangeBegin`/`End` + UPnP-map UDP); step 4 birthday-paradox port
|
|
78
|
+
prediction (open ~256 UDP sockets, inject predicted-port ICE candidates) for
|
|
79
|
+
symmetric NAT; step 5 IPv6-first (audit the candidate filter — do not drop
|
|
80
|
+
*global* v6) + STUN NAT pre-classification reported to the registry; then
|
|
81
|
+
the DNS+TLS path (steps 6–7); step 8 relay-then-upgrade (deferred).
|
|
77
82
|
- Future: ed25519 proxy identity (sign announcements), BEP 44 endpoint
|
|
78
83
|
announcements via the `bittorrent-dht` already bundled with WebTorrent.
|
|
79
84
|
|
|
@@ -82,17 +87,16 @@ All of this must stay deployment-agnostic (HA addon, bare npm, Docker).
|
|
|
82
87
|
## Disk hygiene (open item — torrent data is NOT cleaned up today)
|
|
83
88
|
|
|
84
89
|
HLS segments are handled (`hls-session-manager.js`: idle TTL, `disposeSession`,
|
|
85
|
-
`disposeAll`). Torrent data is
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
(not only startup); global disk cap with LRU eviction of whole torrents.
|
|
90
|
+
`disposeAll`). Torrent data is **partially** handled: shutdown cleanup is done
|
|
91
|
+
(`TorrentPool.destroyAll()` with `destroyStore: true`, wired into the `onClose`
|
|
92
|
+
hook — proxy 2.9.15), but `deselect()` only stops further download and nothing
|
|
93
|
+
removes a torrent's data **while the proxy keeps running**, nor sweeps orphans
|
|
94
|
+
left by a previous hard kill at startup.
|
|
95
|
+
|
|
96
|
+
Level 1 — remaining: `client.remove(torrent, { destroyStore: true })` on last-
|
|
97
|
+
file refcount 0 + idle TTL (mirror the HLS session model); startup sweep of
|
|
98
|
+
orphaned store dirs under `os.tmpdir()`; global disk cap with LRU eviction of
|
|
99
|
+
whole torrents. (Shutdown teardown ✅ done.)
|
|
96
100
|
Level 2 (research): sliding-window chunk store. Full rationale in the parent
|
|
97
101
|
`../CLAUDE.md` "Disk hygiene" section.
|
|
98
102
|
|
package/bin/cli.js
CHANGED
|
@@ -122,6 +122,9 @@ let tunnelClient = null;
|
|
|
122
122
|
/** @type {ReturnType<typeof createPortMapper> | null} */
|
|
123
123
|
let portMapper = null;
|
|
124
124
|
|
|
125
|
+
/** @type {ReturnType<typeof createPortMapper> | null} UDP mapping for the WebRTC port. */
|
|
126
|
+
let udpPortMapper = null;
|
|
127
|
+
|
|
125
128
|
|
|
126
129
|
/**
|
|
127
130
|
* Register this proxy with the registry server.
|
|
@@ -190,12 +193,16 @@ async function shutdown(signal) {
|
|
|
190
193
|
}
|
|
191
194
|
logger.warn(`Received ${signal}, shutting down...`);
|
|
192
195
|
try {
|
|
193
|
-
// Remove the router port
|
|
196
|
+
// Remove the router port mappings before exiting (lease expiry is the
|
|
194
197
|
// backstop if this is skipped on a hard kill).
|
|
195
198
|
if (portMapper) {
|
|
196
199
|
await portMapper.stop();
|
|
197
200
|
portMapper = null;
|
|
198
201
|
}
|
|
202
|
+
if (udpPortMapper) {
|
|
203
|
+
await udpPortMapper.stop();
|
|
204
|
+
udpPortMapper = null;
|
|
205
|
+
}
|
|
199
206
|
if (app) {
|
|
200
207
|
await app.close();
|
|
201
208
|
}
|
|
@@ -234,9 +241,34 @@ try {
|
|
|
234
241
|
// delay tunnel connect / registration, so we do not await it.
|
|
235
242
|
if (portMappingEnabled) {
|
|
236
243
|
portMapper = createPortMapper({ port: actualPort, protocol: "TCP" });
|
|
237
|
-
void portMapper
|
|
244
|
+
void portMapper
|
|
245
|
+
.start()
|
|
246
|
+
.then(() => {
|
|
247
|
+
// Mapping may finish after the tunnel is already connected; report the
|
|
248
|
+
// endpoint now. If the tunnel is not open yet, onConnect re-sends it.
|
|
249
|
+
const endpoint = portMapper?.getMappedEndpoint();
|
|
250
|
+
if (endpoint) {
|
|
251
|
+
tunnelClient?.sendEndpoint(endpoint);
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
.catch((error) => {
|
|
255
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
256
|
+
logger.warn(`Port mapping failed to start: ${message}`);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Also map the WebRTC UDP port (same number, different protocol). All
|
|
260
|
+
// WebRTC sessions are multiplexed onto this single UDP port (ICE UDP mux),
|
|
261
|
+
// so a static mapping makes the proxy's WebRTC path reachable even behind
|
|
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.
|
|
264
|
+
udpPortMapper = createPortMapper({
|
|
265
|
+
port: actualPort,
|
|
266
|
+
protocol: "UDP",
|
|
267
|
+
description: "torrent-tv proxy (WebRTC)"
|
|
268
|
+
});
|
|
269
|
+
void udpPortMapper.start().catch((error) => {
|
|
238
270
|
const message = error instanceof Error ? error.message : String(error);
|
|
239
|
-
logger.warn(`
|
|
271
|
+
logger.warn(`UDP port mapping failed to start: ${message}`);
|
|
240
272
|
});
|
|
241
273
|
} else {
|
|
242
274
|
logger.info("Automatic port mapping is disabled (--no-port-mapping).");
|
|
@@ -270,6 +302,13 @@ try {
|
|
|
270
302
|
const message = error instanceof Error ? error.message : String(error);
|
|
271
303
|
logger.error(`Re-registration after tunnel connect failed: ${message}`);
|
|
272
304
|
});
|
|
305
|
+
// Re-report the mapped endpoint on every (re)connect — the server's
|
|
306
|
+
// in-memory reachability state resets on restart, and the mapping may
|
|
307
|
+
// have completed before this connection existed.
|
|
308
|
+
const endpoint = portMapper?.getMappedEndpoint();
|
|
309
|
+
if (endpoint) {
|
|
310
|
+
tunnelClient?.sendEndpoint(endpoint);
|
|
311
|
+
}
|
|
273
312
|
},
|
|
274
313
|
onLog: (message) => logger.info(message)
|
|
275
314
|
});
|
|
@@ -280,6 +319,9 @@ try {
|
|
|
280
319
|
});
|
|
281
320
|
|
|
282
321
|
webRtcManager = createWebRtcManager({
|
|
322
|
+
// Pin all WebRTC sessions to this single UDP port (multiplexed via ICE UDP
|
|
323
|
+
// mux) so the UPnP UDP mapping above makes the WebRTC path reachable.
|
|
324
|
+
udpPort: actualPort,
|
|
283
325
|
sendSignal(sessionId, signal) {
|
|
284
326
|
tunnelClient?.sendSignal(sessionId, signal);
|
|
285
327
|
},
|
package/package.json
CHANGED
|
@@ -72,6 +72,9 @@ import { WebSocket } from "ws";
|
|
|
72
72
|
* @property {() => void} disconnect - Close the tunnel; suppresses reconnects.
|
|
73
73
|
* @property {(sessionId: string, signal: WebRtcSignal) => void} sendSignal
|
|
74
74
|
* Send a WebRTC signal (answer / candidate) back to the browser.
|
|
75
|
+
* @property {(endpoint: { externalIp: string | null, externalPort: number, protocol: string }) => void} sendEndpoint
|
|
76
|
+
* Report this proxy's UPnP-mapped external endpoint to the server so it can
|
|
77
|
+
* dial back and verify reachability.
|
|
75
78
|
*/
|
|
76
79
|
|
|
77
80
|
const RECONNECT_DELAY_MS = 5_000;
|
|
@@ -312,6 +315,18 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onSig
|
|
|
312
315
|
*/
|
|
313
316
|
sendSignal(sessionId, signal) {
|
|
314
317
|
send({ type: "signal", sessionId, signal });
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Report this proxy's UPnP-mapped external endpoint to the server.
|
|
322
|
+
* No-op if the tunnel is not currently open (the caller re-sends on
|
|
323
|
+
* connect / reconnect).
|
|
324
|
+
*
|
|
325
|
+
* @param {{ externalIp: string | null, externalPort: number, protocol: string }} endpoint
|
|
326
|
+
* @returns {void}
|
|
327
|
+
*/
|
|
328
|
+
sendEndpoint(endpoint) {
|
|
329
|
+
send({ type: "proxy-endpoint", endpoint });
|
|
315
330
|
}
|
|
316
331
|
};
|
|
317
332
|
}
|
|
@@ -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
|
//
|