@torrent-tv/proxy 2.9.14 → 2.9.16

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.16
2
+
3
+ - **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).
4
+
5
+ ## 2.9.15
6
+
7
+ - **Fix**: Torrent data is now cleaned up on graceful shutdown. `TorrentPool.destroyAll()` removes every torrent **with its on-disk store** (`torrent.destroy({ destroyStore: true })`) and then tears down the WebTorrent client; it is wired into the Fastify `onClose` hook (after `hlsSessionManager.disposeAll()`, so ffmpeg readers stop before their source files are removed). Previously nothing called `client.remove()`/`torrent.destroy()` anywhere, so downloaded files accumulated under `os.tmpdir()` until the process was killed — and even a clean SIGTERM/SIGINT left them behind. (First step of disk-hygiene Level 1; refcount/TTL removal and the startup orphan sweep are separate, still pending.)
8
+
1
9
  ## 2.9.14
2
10
 
3
11
  - **New**: `GET /api/sources/:sourceKey/stats` now reports `headerBytes` / `headerDownloadedBytes` — how much of the file's header/index region (leading 256 KB + trailing 2 MB, the bytes the codec probe needs) is downloaded, counted by whole torrent pieces from the bitfield. Lets the browser show the download phase's progress and ETA toward the next (transcode) phase. Coarse by design (piece granularity).
package/CLAUDE.md CHANGED
@@ -47,6 +47,63 @@ Linux-only host (e.g. POSIX-only signals must degrade elsewhere).
47
47
  around it with `--ignore-scripts` + a targeted rebuild of `node-datachannel`;
48
48
  if you ever change install flow, keep that in mind.
49
49
 
50
+ ## Planned: public reachability (remote access)
51
+
52
+ Decided direction — full plan in the parent `../CLAUDE.md`. Proxy-side pieces:
53
+
54
+ - **Auto port mapping** — IMPLEMENTED (`services/port-mapper.js`, changelog
55
+ 2.9.16). UPnP IGD / NAT-PMP via `@silentbot1/nat-api` (now a direct dep; the
56
+ same lib WebTorrent uses for the torrent port). Maps TCP 9090 with a 2 h
57
+ auto-renewed lease, removed on shutdown (lease expiry covers hard kills).
58
+ Best-effort + start/stop timeouts; `--no-port-mapping` opts out;
59
+ `getMappedEndpoint()` exposes the external endpoint for the reachability
60
+ probe. NOT yet done: mapping the **UDP** port WebRTC actually uses (it binds
61
+ ephemeral UDP ports, so this TCP mapping does not yet help WebRTC — see the
62
+ NAT-traversal toolbox in the parent CLAUDE.md), and reporting the endpoint
63
+ to the server.
64
+ - Report the mapped external endpoint (and local addresses) to the server over
65
+ the tunnel; the server dial-back-verifies reachability before use.
66
+ - **HTTPS listener**: serve the existing routes over TLS with a per-proxy
67
+ certificate delivered by the server through the tunnel (persist cert+key
68
+ locally; ~90-day renewals are pushed the same way). Add CORS headers for the
69
+ web-app origin so hls.js / `<video>` can fetch cross-origin.
70
+ - Plain HTTPS becomes the preferred video transport; WebRTC data channel stays
71
+ as fallback for hosts where no port could be opened.
72
+ - **NAT-traversal toolbox** (staged, see parent `../CLAUDE.md`): birthday-
73
+ paradox port prediction (open ~256 UDP sockets, inject predicted-port ICE
74
+ candidates) for symmetric NAT; IPv6-first (audit the candidate filter — do
75
+ not drop *global* v6); STUN-based NAT pre-classification at startup reported
76
+ to the registry; relay-then-upgrade later.
77
+ - Future: ed25519 proxy identity (sign announcements), BEP 44 endpoint
78
+ announcements via the `bittorrent-dht` already bundled with WebTorrent.
79
+
80
+ All of this must stay deployment-agnostic (HA addon, bare npm, Docker).
81
+
82
+ ## Disk hygiene (open item — torrent data is NOT cleaned up today)
83
+
84
+ HLS segments are handled (`hls-session-manager.js`: idle TTL, `disposeSession`,
85
+ `disposeAll`). Torrent data is NOT: `new WebTorrent()` in `torrent-pool.js` uses
86
+ the default FS store under `os.tmpdir()`, there is no `client.remove()` /
87
+ `torrent.destroy()`, `deselect()` only stops further download, nothing sweeps
88
+ orphans at startup, and `TorrentPool` is not wired into the `onClose` shutdown
89
+ hook (`server.js` only disposes HLS sessions on close — see `cli.js` shutdown →
90
+ `app.close()` → `onClose`).
91
+
92
+ Level 1 (do first): `client.remove(torrent, { destroyStore: true })` on last-
93
+ file refcount 0 + idle TTL; startup sweep of orphaned store dirs; wire
94
+ `TorrentPool` teardown into the `onClose` hook so closing the proxy cleans up
95
+ (not only startup); global disk cap with LRU eviction of whole torrents.
96
+ Level 2 (research): sliding-window chunk store. Full rationale in the parent
97
+ `../CLAUDE.md` "Disk hygiene" section.
98
+
99
+ ## Cloud proxy
100
+
101
+ The same proxy code also runs as the company-hosted fallback when the user
102
+ pool can't serve a viewer. Keep the proxy host-agnostic so it runs unchanged on
103
+ rented infra (flat-rate/unmetered bandwidth — Hetzner dedicated / OVH; NOT
104
+ metered-egress clouds). Provider/economics analysis in the parent
105
+ `../CLAUDE.md` "Cloud proxy" section.
106
+
50
107
  ## Changelog
51
108
 
52
109
  Every behavioural change must be recorded in `CHANGELOG.md` — add an entry under
package/bin/cli.js CHANGED
@@ -19,6 +19,7 @@ import { createTunnelClient } from "../services/tunnel-client.js";
19
19
  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
+ import { createPortMapper } from "../services/port-mapper.js";
22
23
  import { logger } from "../utils/logger.js";
23
24
 
24
25
  const require = createRequire(import.meta.url);
@@ -53,6 +54,7 @@ program
53
54
  .option("--id <id>", "Stable client id")
54
55
  .option("--name <name>", "Display name")
55
56
  .option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
57
+ .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
56
58
  .option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
57
59
  .option("--token <token>", "Registration token", "")
58
60
  .addHelpText("after", HELP_EXAMPLES);
@@ -75,6 +77,7 @@ const clientId = options.id ? String(options.id) : crypto.randomUUID();
75
77
  const clientName = options.name ? String(options.name) : `proxy-${clientId.slice(0, 8)}`;
76
78
  const token = String(options.token ?? "");
77
79
  const transcodeAudio = options.transcodeAudio !== false;
80
+ const portMappingEnabled = options.portMapping !== false;
78
81
  const bundledFfmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : "";
79
82
  const ffmpegBin = options.ffmpegBin ? String(options.ffmpegBin) : bundledFfmpegBin || "ffmpeg";
80
83
 
@@ -116,6 +119,9 @@ let shutdownInProgress = false;
116
119
  /** @type {ReturnType<typeof createTunnelClient> | null} */
117
120
  let tunnelClient = null;
118
121
 
122
+ /** @type {ReturnType<typeof createPortMapper> | null} */
123
+ let portMapper = null;
124
+
119
125
 
120
126
  /**
121
127
  * Register this proxy with the registry server.
@@ -184,6 +190,12 @@ async function shutdown(signal) {
184
190
  }
185
191
  logger.warn(`Received ${signal}, shutting down...`);
186
192
  try {
193
+ // Remove the router port mapping before exiting (lease expiry is the
194
+ // backstop if this is skipped on a hard kill).
195
+ if (portMapper) {
196
+ await portMapper.stop();
197
+ portMapper = null;
198
+ }
187
199
  if (app) {
188
200
  await app.close();
189
201
  }
@@ -216,6 +228,20 @@ try {
216
228
  logger.info(`Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`);
217
229
  }
218
230
 
231
+ // Try to open the local port on the home router (UPnP/NAT-PMP) so the proxy
232
+ // is reachable from the internet without manual port forwarding. Best-effort
233
+ // and fire-and-forget: failure is normal (router without UPnP) and must not
234
+ // delay tunnel connect / registration, so we do not await it.
235
+ if (portMappingEnabled) {
236
+ portMapper = createPortMapper({ port: actualPort, protocol: "TCP" });
237
+ void portMapper.start().catch((error) => {
238
+ const message = error instanceof Error ? error.message : String(error);
239
+ logger.warn(`Port mapping failed to start: ${message}`);
240
+ });
241
+ } else {
242
+ logger.info("Automatic port mapping is disabled (--no-port-mapping).");
243
+ }
244
+
219
245
  // Create tunnel + WebRTC manager.
220
246
  // The tunnel forwards WebRTC signals between browser (via server) and this proxy.
221
247
  // 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.14",
3
+ "version": "2.9.16",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -21,6 +21,7 @@
21
21
  "@fastify/cors": "^11.2.0",
22
22
  "@fastify/helmet": "^13.0.2",
23
23
  "@fastify/static": "^9.1.3",
24
+ "@silentbot1/nat-api": "^0.4.9",
24
25
  "chalk": "^5.4.1",
25
26
  "commander": "^12.1.0",
26
27
  "fastify": "^5.8.5",
package/server.js CHANGED
@@ -154,7 +154,10 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }
154
154
  });
155
155
 
156
156
  app.addHook("onClose", async () => {
157
+ // Order matters: stop the ffmpeg readers (HLS sessions) before destroying
158
+ // the torrents whose files they read from, then remove the torrent data.
157
159
  await hlsSessionManager.disposeAll();
160
+ await torrentPool.destroyAll();
158
161
  });
159
162
 
160
163
  await app.listen({ host, port: selectedPort });
@@ -0,0 +1,196 @@
1
+ /**
2
+ * @file Automatic port mapping (UPnP IGD / NAT-PMP / PCP) for the proxy.
3
+ *
4
+ * Opens a port on the user's home router so the proxy is reachable from the
5
+ * internet without any manual port forwarding. Uses `@silentbot1/nat-api`
6
+ * (the same library WebTorrent already uses for the torrent port, so this adds
7
+ * no new dependency surface on the host).
8
+ *
9
+ * Strictly best-effort: a router without UPnP/NAT-PMP — or one that declines —
10
+ * is a normal case, not an error. Mapping failure never blocks proxy startup
11
+ * and never throws to the caller. Reachability of the mapped endpoint is
12
+ * verified separately (server-side dial-back probe — a later stage).
13
+ *
14
+ * The mapping is created with a TTL and auto-renewed while the proxy runs
15
+ * (`autoUpdate`), and removed on graceful shutdown via {@link stop}. If the
16
+ * process dies without calling `stop()`, the router drops the mapping when the
17
+ * lease expires.
18
+ */
19
+
20
+ import NatAPI from "@silentbot1/nat-api";
21
+ import { logger } from "../utils/logger.js";
22
+
23
+ // nat-api clamps ttl to a 1200 s minimum; it auto-renews at (ttl - 600) s.
24
+ const DEFAULT_TTL_SECONDS = 7200;
25
+ // SSDP discovery can hang on networks with no responding gateway — bound it so
26
+ // startup is never delayed waiting for a router that will not answer.
27
+ const START_TIMEOUT_MS = 10_000;
28
+ // A slow/unreachable router must not hang shutdown; lease expiry is the backstop.
29
+ const STOP_TIMEOUT_MS = 5_000;
30
+
31
+ /**
32
+ * @param {unknown} error
33
+ * @returns {string}
34
+ */
35
+ function describeError(error) {
36
+ return error instanceof Error ? error.message : String(error);
37
+ }
38
+
39
+ /**
40
+ * Reject after `ms` if `promise` has not settled, so a hung NAT operation
41
+ * cannot block startup or shutdown.
42
+ *
43
+ * @template T
44
+ * @param {Promise<T>} promise
45
+ * @param {number} ms
46
+ * @param {string} label
47
+ * @returns {Promise<T>}
48
+ */
49
+ function withTimeout(promise, ms, label) {
50
+ return new Promise((resolve, reject) => {
51
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms} ms`)), ms);
52
+ timer.unref?.();
53
+ promise.then(
54
+ (value) => {
55
+ clearTimeout(timer);
56
+ resolve(value);
57
+ },
58
+ (error) => {
59
+ clearTimeout(timer);
60
+ reject(error);
61
+ }
62
+ );
63
+ });
64
+ }
65
+
66
+ /**
67
+ * @typedef {object} MappedEndpoint
68
+ * @property {string | null} externalIp - Public IP as seen by NAT-PMP/UPnP, or null if unknown.
69
+ * @property {number} externalPort
70
+ * @property {"TCP" | "UDP"} protocol
71
+ */
72
+
73
+ /**
74
+ * @typedef {object} PortMapper
75
+ * @property {() => Promise<void>} start - Create the mapping (best-effort, never throws).
76
+ * @property {() => Promise<void>} stop - Remove the mapping and stop auto-renew (idempotent).
77
+ * @property {() => MappedEndpoint | null} getMappedEndpoint - The active mapping, or null.
78
+ */
79
+
80
+ /**
81
+ * Create a port mapper for a single local port.
82
+ *
83
+ * @param {object} opts
84
+ * @param {number} opts.port - The local port to expose (used as both public and private port).
85
+ * @param {"TCP" | "UDP"} [opts.protocol] - Protocol to map. Defaults to "TCP" (the HTTP/stream port).
86
+ * @param {string} [opts.description] - Human-readable label shown in the router's port-mapping table.
87
+ * @param {number} [opts.ttlSeconds] - Lease time in seconds. Defaults to {@link DEFAULT_TTL_SECONDS}.
88
+ * @returns {PortMapper}
89
+ */
90
+ export function createPortMapper({
91
+ port,
92
+ protocol = "TCP",
93
+ description = "torrent-tv proxy",
94
+ ttlSeconds = DEFAULT_TTL_SECONDS
95
+ } = {}) {
96
+ /** @type {InstanceType<typeof NatAPI> | null} */
97
+ let nat = null;
98
+ /** @type {MappedEndpoint | null} */
99
+ let mappedEndpoint = null;
100
+ let started = false;
101
+
102
+ /**
103
+ * Destroy the NatAPI instance, swallowing errors. `destroy()` unmaps every
104
+ * open port and clears the auto-renew timers.
105
+ *
106
+ * @param {InstanceType<typeof NatAPI>} instance
107
+ * @returns {Promise<void>}
108
+ */
109
+ async function safeDestroy(instance) {
110
+ try {
111
+ await withTimeout(instance.destroy(), STOP_TIMEOUT_MS, "destroy");
112
+ } catch (error) {
113
+ // Lease expiry (ttl) is the backstop if we cannot unmap cleanly.
114
+ logger.warn(`port-mapper: failed to remove port mapping cleanly: ${describeError(error)}`);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * @returns {Promise<void>}
120
+ */
121
+ async function start() {
122
+ if (started) {
123
+ return;
124
+ }
125
+ started = true;
126
+
127
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) {
128
+ logger.warn(`port-mapper: invalid port ${port}; skipping port mapping`);
129
+ return;
130
+ }
131
+
132
+ let instance;
133
+ try {
134
+ instance = new NatAPI({ ttl: ttlSeconds, autoUpdate: true, description });
135
+ await withTimeout(
136
+ instance.map({ publicPort: port, privatePort: port, protocol, description, ttl: ttlSeconds }),
137
+ START_TIMEOUT_MS,
138
+ "map"
139
+ );
140
+ } 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
+ mappedEndpoint = null;
144
+ logger.warn(`port-mapper: no port mapping available (${describeError(error)}); continuing without it`);
145
+ if (instance) {
146
+ await safeDestroy(instance);
147
+ }
148
+ return;
149
+ }
150
+
151
+ // Mapping succeeded — keep the instance so its auto-renew timers stay alive
152
+ // and stop() can remove the mapping later.
153
+ nat = instance;
154
+
155
+ // Discover the external IP (best-effort; the mapping is valid without it).
156
+ let externalIp = null;
157
+ try {
158
+ externalIp = await withTimeout(instance.externalIp(), START_TIMEOUT_MS, "externalIp");
159
+ } catch (error) {
160
+ logger.warn(`port-mapper: mapped ${protocol} ${port} but could not read external IP: ${describeError(error)}`);
161
+ }
162
+
163
+ mappedEndpoint = { externalIp: externalIp || null, externalPort: port, protocol };
164
+ if (externalIp) {
165
+ logger.success(
166
+ `port-mapper: mapped ${externalIp}:${port} → ${protocol} ${port} (ttl ${ttlSeconds}s, auto-renew)`
167
+ );
168
+ } else {
169
+ logger.info(
170
+ `port-mapper: mapped ${protocol} ${port} (external IP unknown; ttl ${ttlSeconds}s, auto-renew)`
171
+ );
172
+ }
173
+ }
174
+
175
+ /**
176
+ * @returns {Promise<void>}
177
+ */
178
+ async function stop() {
179
+ if (!nat) {
180
+ return;
181
+ }
182
+ const instance = nat;
183
+ nat = null;
184
+ mappedEndpoint = null;
185
+ await safeDestroy(instance);
186
+ }
187
+
188
+ /**
189
+ * @returns {MappedEndpoint | null}
190
+ */
191
+ function getMappedEndpoint() {
192
+ return mappedEndpoint;
193
+ }
194
+
195
+ return { start, stop, getMappedEndpoint };
196
+ }
@@ -423,4 +423,56 @@ export class TorrentPool {
423
423
  // Best effort — never break streaming because prioritization failed.
424
424
  }
425
425
  }
426
+
427
+ /**
428
+ * Destroy every torrent together with its on-disk store, then tear down the
429
+ * WebTorrent client. Called from the proxy's graceful-shutdown `onClose`
430
+ * hook so downloaded torrent data does not linger under `os.tmpdir()` after
431
+ * the process stops.
432
+ *
433
+ * `client.destroy()` on its own destroys the torrents but only *closes* their
434
+ * stores (data stays on disk), so each torrent is removed explicitly with
435
+ * `{ destroyStore: true }` first. Best-effort: never rejects and never hangs
436
+ * on a single store-removal error during shutdown.
437
+ *
438
+ * @returns {Promise<void>}
439
+ */
440
+ async destroyAll() {
441
+ if (!this.client || this.client.destroyed) {
442
+ return;
443
+ }
444
+
445
+ // Destroy each torrent with its store so downloaded pieces are removed
446
+ // from disk. This also removes the torrent from `client.torrents`, so the
447
+ // subsequent `client.destroy()` only tears down the client internals.
448
+ const torrents = [...this.client.torrents];
449
+ await Promise.all(
450
+ torrents.map(
451
+ (torrent) =>
452
+ new Promise((resolve) => {
453
+ try {
454
+ torrent.destroy({ destroyStore: true }, () => resolve());
455
+ } catch (error) {
456
+ const message = error instanceof Error ? error.message : String(error);
457
+ logger.warn(`failed to destroy torrent store: ${message}`);
458
+ resolve();
459
+ }
460
+ })
461
+ )
462
+ );
463
+
464
+ // Tear down the client itself (DHT, connection pool, TCP server).
465
+ await new Promise((resolve) => {
466
+ try {
467
+ this.client.destroy(() => resolve());
468
+ } catch (error) {
469
+ const message = error instanceof Error ? error.message : String(error);
470
+ logger.warn(`failed to destroy WebTorrent client: ${message}`);
471
+ resolve();
472
+ }
473
+ });
474
+
475
+ this.torrents.clear();
476
+ this.#pending.clear();
477
+ }
426
478
  }