@torrent-tv/proxy 2.9.15 → 2.9.17
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 +32 -23
- package/bin/cli.js +43 -0
- package/package.json +2 -1
- package/services/port-mapper.js +196 -0
- package/services/tunnel-client.js +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.9.17
|
|
2
|
+
|
|
3
|
+
- **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.
|
|
4
|
+
|
|
5
|
+
## 2.9.16
|
|
6
|
+
|
|
7
|
+
- **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).
|
|
8
|
+
|
|
1
9
|
## 2.9.15
|
|
2
10
|
|
|
3
11
|
- **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.)
|
package/CLAUDE.md
CHANGED
|
@@ -51,24 +51,34 @@ Linux-only host (e.g. POSIX-only signals must degrade elsewhere).
|
|
|
51
51
|
|
|
52
52
|
Decided direction — full plan in the parent `../CLAUDE.md`. Proxy-side pieces:
|
|
53
53
|
|
|
54
|
-
- **Auto port mapping**
|
|
55
|
-
`@silentbot1/nat-api
|
|
56
|
-
|
|
57
|
-
on
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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. 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).
|
|
61
69
|
- **HTTPS listener**: serve the existing routes over TLS with a per-proxy
|
|
62
70
|
certificate delivered by the server through the tunnel (persist cert+key
|
|
63
71
|
locally; ~90-day renewals are pushed the same way). Add CORS headers for the
|
|
64
72
|
web-app origin so hls.js / `<video>` can fetch cross-origin.
|
|
65
73
|
- Plain HTTPS becomes the preferred video transport; WebRTC data channel stays
|
|
66
74
|
as fallback for hosts where no port could be opened.
|
|
67
|
-
- **
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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).
|
|
72
82
|
- Future: ed25519 proxy identity (sign announcements), BEP 44 endpoint
|
|
73
83
|
announcements via the `bittorrent-dht` already bundled with WebTorrent.
|
|
74
84
|
|
|
@@ -77,17 +87,16 @@ All of this must stay deployment-agnostic (HA addon, bare npm, Docker).
|
|
|
77
87
|
## Disk hygiene (open item — torrent data is NOT cleaned up today)
|
|
78
88
|
|
|
79
89
|
HLS segments are handled (`hls-session-manager.js`: idle TTL, `disposeSession`,
|
|
80
|
-
`disposeAll`). Torrent data is
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
(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.)
|
|
91
100
|
Level 2 (research): sliding-window chunk store. Full rationale in the parent
|
|
92
101
|
`../CLAUDE.md` "Disk hygiene" section.
|
|
93
102
|
|
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,30 @@ 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
|
|
238
|
+
.start()
|
|
239
|
+
.then(() => {
|
|
240
|
+
// Mapping may finish after the tunnel is already connected; report the
|
|
241
|
+
// endpoint now. If the tunnel is not open yet, onConnect re-sends it.
|
|
242
|
+
const endpoint = portMapper?.getMappedEndpoint();
|
|
243
|
+
if (endpoint) {
|
|
244
|
+
tunnelClient?.sendEndpoint(endpoint);
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
.catch((error) => {
|
|
248
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
249
|
+
logger.warn(`Port mapping failed to start: ${message}`);
|
|
250
|
+
});
|
|
251
|
+
} else {
|
|
252
|
+
logger.info("Automatic port mapping is disabled (--no-port-mapping).");
|
|
253
|
+
}
|
|
254
|
+
|
|
219
255
|
// Create tunnel + WebRTC manager.
|
|
220
256
|
// The tunnel forwards WebRTC signals between browser (via server) and this proxy.
|
|
221
257
|
// The WebRTC manager handles the actual peer connection and data channel.
|
|
@@ -244,6 +280,13 @@ try {
|
|
|
244
280
|
const message = error instanceof Error ? error.message : String(error);
|
|
245
281
|
logger.error(`Re-registration after tunnel connect failed: ${message}`);
|
|
246
282
|
});
|
|
283
|
+
// Re-report the mapped endpoint on every (re)connect — the server's
|
|
284
|
+
// in-memory reachability state resets on restart, and the mapping may
|
|
285
|
+
// have completed before this connection existed.
|
|
286
|
+
const endpoint = portMapper?.getMappedEndpoint();
|
|
287
|
+
if (endpoint) {
|
|
288
|
+
tunnelClient?.sendEndpoint(endpoint);
|
|
289
|
+
}
|
|
247
290
|
},
|
|
248
291
|
onLog: (message) => logger.info(message)
|
|
249
292
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@torrent-tv/proxy",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.17",
|
|
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",
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|