socket-function 1.2.22 → 1.2.24

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.
@@ -4,7 +4,8 @@
4
4
  "Bash(mkdir -p C:/Users/quent/AppData/Local/Temp/claude/D--repos-socket-function/117d10bd-31c6-4c68-938d-1fff47788fa6/scratchpad/tm-recover)",
5
5
  "Bash(cp -r 'C:/Users/quent/AppData/Local/BraveSoftware/Brave-Browser/User Data/Default/Local Extension Settings/dhdgffkkebhmkfjojejmpbldmpobfkfo' C:/Users/quent/AppData/Local/Temp/claude/D--repos-socket-function/117d10bd-31c6-4c68-938d-1fff47788fa6/scratchpad/tm-recover/LocalExtSettings)",
6
6
  "Bash(yarn test-dns-claude *)",
7
- "Bash(yarn type *)"
7
+ "Bash(yarn type *)",
8
+ "Bash(yarn test *)"
8
9
  ]
9
10
  }
10
11
  }
package/index.d.ts CHANGED
@@ -963,6 +963,15 @@ declare module "socket-function/src/formatting/logColors" {
963
963
  }
964
964
 
965
965
  declare module "socket-function/src/forwardPort" {
966
+ /** Resolves the UPnP Internet Gateway Device we can talk to, along with the local
967
+ * addressing needed to build SOAP requests against it. Shared by every operation
968
+ * that needs to reach the router's control endpoint. */
969
+ export declare function resolveGateway(): Promise<{
970
+ internalIP: string;
971
+ gatewayIP: string;
972
+ controlPort: number;
973
+ controlURLs: string[];
974
+ }>;
966
975
  export interface PortMapping {
967
976
  externalPort: number;
968
977
  internalPort: number;
@@ -980,11 +989,27 @@ declare module "socket-function/src/forwardPort" {
980
989
  * GetGenericPortMappingEntry from index 0 until the gateway reports the index
981
990
  * is out of range (SOAP error 713 / a non-200 response). */
982
991
  export declare function listPortMappings(): Promise<PortMapping[]>;
992
+ /** Outcome of forwardPort. `owned` is true once we hold the router mapping for the port. When
993
+ * false, `reason` says why: "declined" = noPortStealing and another host holds the port (the
994
+ * caller should try a different port); "error" = UPnP unreachable / create failed (best-effort,
995
+ * nothing forwarded but the caller can carry on). */
996
+ export type ForwardPortResult = {
997
+ owned: boolean;
998
+ reason?: "declined" | "error";
999
+ };
983
1000
  export declare function forwardPort(config: {
984
1001
  externalPort: number;
985
1002
  internalPort: number;
1003
+ /** Lease length in ms. Defaults to a PERMANENT mapping (never expires), which is what you want:
1004
+ * finite leases can't be refreshed gap-free (see PERMANENT_LEASE). Pass a finite duration
1005
+ * only if you specifically want the mapping to expire on its own — in that case we do NOT
1006
+ * run the supersession monitor, since a finite mapping is expected to disappear. */
986
1007
  duration?: number;
987
- }): Promise<void>;
1008
+ /** If the port is already forwarded to a DIFFERENT internal client, don't steal it: return
1009
+ * { owned: false, reason: "declined" } instead of taking over. Off by default (default is
1010
+ * last-writer-wins takeover). An existing mapping that is ours (or none) is still (re)claimed. */
1011
+ noPortStealing?: boolean;
1012
+ }): Promise<ForwardPortResult>;
988
1013
  /** Our machine's LAN IP, as the router sees it — used to tell whether an existing port
989
1014
  * mapping points at us or at a different machine on the network. */
990
1015
  export declare function getLocalInternalIP(): Promise<string | undefined>;
@@ -993,6 +1018,21 @@ declare module "socket-function/src/forwardPort" {
993
1018
  * directly reachable and forwarding is unnecessary. This is the cross-platform gate that
994
1019
  * replaced the old "skip forwarding on linux" check, so Linux hosts behind NAT forward. */
995
1020
  export declare function isBehindNAT(): Promise<boolean>;
1021
+ export declare function createPortMapping(config: {
1022
+ externalPort: number;
1023
+ internalPort: number;
1024
+ gatewayIP: string;
1025
+ controlPort: number;
1026
+ controlPath: string;
1027
+ internalIP: string;
1028
+ duration: number;
1029
+ }): Promise<void>;
1030
+ export declare function deletePortMapping(config: {
1031
+ externalPort: number;
1032
+ gatewayIP: string;
1033
+ controlPort: number;
1034
+ controlPath: string;
1035
+ }): Promise<void>;
996
1036
 
997
1037
  }
998
1038
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socket-function",
3
- "version": "1.2.22",
3
+ "version": "1.2.24",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "dependencies": {
@@ -1,3 +1,12 @@
1
+ /** Resolves the UPnP Internet Gateway Device we can talk to, along with the local
2
+ * addressing needed to build SOAP requests against it. Shared by every operation
3
+ * that needs to reach the router's control endpoint. */
4
+ export declare function resolveGateway(): Promise<{
5
+ internalIP: string;
6
+ gatewayIP: string;
7
+ controlPort: number;
8
+ controlURLs: string[];
9
+ }>;
1
10
  export interface PortMapping {
2
11
  externalPort: number;
3
12
  internalPort: number;
@@ -15,11 +24,27 @@ export interface PortMapping {
15
24
  * GetGenericPortMappingEntry from index 0 until the gateway reports the index
16
25
  * is out of range (SOAP error 713 / a non-200 response). */
17
26
  export declare function listPortMappings(): Promise<PortMapping[]>;
27
+ /** Outcome of forwardPort. `owned` is true once we hold the router mapping for the port. When
28
+ * false, `reason` says why: "declined" = noPortStealing and another host holds the port (the
29
+ * caller should try a different port); "error" = UPnP unreachable / create failed (best-effort,
30
+ * nothing forwarded but the caller can carry on). */
31
+ export type ForwardPortResult = {
32
+ owned: boolean;
33
+ reason?: "declined" | "error";
34
+ };
18
35
  export declare function forwardPort(config: {
19
36
  externalPort: number;
20
37
  internalPort: number;
38
+ /** Lease length in ms. Defaults to a PERMANENT mapping (never expires), which is what you want:
39
+ * finite leases can't be refreshed gap-free (see PERMANENT_LEASE). Pass a finite duration
40
+ * only if you specifically want the mapping to expire on its own — in that case we do NOT
41
+ * run the supersession monitor, since a finite mapping is expected to disappear. */
21
42
  duration?: number;
22
- }): Promise<void>;
43
+ /** If the port is already forwarded to a DIFFERENT internal client, don't steal it: return
44
+ * { owned: false, reason: "declined" } instead of taking over. Off by default (default is
45
+ * last-writer-wins takeover). An existing mapping that is ours (or none) is still (re)claimed. */
46
+ noPortStealing?: boolean;
47
+ }): Promise<ForwardPortResult>;
23
48
  /** Our machine's LAN IP, as the router sees it — used to tell whether an existing port
24
49
  * mapping points at us or at a different machine on the network. */
25
50
  export declare function getLocalInternalIP(): Promise<string | undefined>;
@@ -28,3 +53,18 @@ export declare function getLocalInternalIP(): Promise<string | undefined>;
28
53
  * directly reachable and forwarding is unnecessary. This is the cross-platform gate that
29
54
  * replaced the old "skip forwarding on linux" check, so Linux hosts behind NAT forward. */
30
55
  export declare function isBehindNAT(): Promise<boolean>;
56
+ export declare function createPortMapping(config: {
57
+ externalPort: number;
58
+ internalPort: number;
59
+ gatewayIP: string;
60
+ controlPort: number;
61
+ controlPath: string;
62
+ internalIP: string;
63
+ duration: number;
64
+ }): Promise<void>;
65
+ export declare function deletePortMapping(config: {
66
+ externalPort: number;
67
+ gatewayIP: string;
68
+ controlPort: number;
69
+ controlPath: string;
70
+ }): Promise<void>;
@@ -1,7 +1,7 @@
1
1
  import debugbreak from "debugbreak";
2
2
  import * as dgram from "dgram";
3
3
  import os from "os";
4
- import { timeInHour } from "./misc";
4
+ import { timeInMinute } from "./misc";
5
5
 
6
6
  const SSDP_DISCOVER_MX = 2;
7
7
  const SSDP_DISCOVER_MSG = `M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: "ssdp:discover"\r\nMX: ${SSDP_DISCOVER_MX}\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n`;
@@ -9,7 +9,7 @@ const SSDP_DISCOVER_MSG = `M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\n
9
9
  /** Resolves the UPnP Internet Gateway Device we can talk to, along with the local
10
10
  * addressing needed to build SOAP requests against it. Shared by every operation
11
11
  * that needs to reach the router's control endpoint. */
12
- async function resolveGateway(): Promise<{
12
+ export async function resolveGateway(): Promise<{
13
13
  internalIP: string;
14
14
  gatewayIP: string;
15
15
  controlPort: number;
@@ -74,37 +74,143 @@ export async function listPortMappings(): Promise<PortMapping[]> {
74
74
  throw new Error(`Failed to list port mappings, could not find a working controlURL. Last error: ${(lastError as Error)?.stack ?? lastError}`);
75
75
  }
76
76
 
77
+ // We forward with a PERMANENT lease (leaseDuration 0) rather than a finite one that we renew.
78
+ // A finite lease can't be refreshed gap-free on real routers: an in-place AddPortMapping while
79
+ // the lease is active is silently ignored (returns 200 but the lease keeps ticking to zero), and
80
+ // the only way to reset it — delete then re-add — leaves a window where the port isn't forwarded.
81
+ // A permanent mapping never expires, so there's nothing to renew and no gap. (Verified against a
82
+ // live IGD; see test.ts.)
83
+ const PERMANENT_LEASE = 0;
84
+
85
+ // A permanent mapping never expires, so we don't renew. We only watch, on this interval, for
86
+ // another application having superseded our forward (or a router reboot having wiped it), and log
87
+ // loudly if so. We do NOT re-take it — last writer wins, so fighting back would just be a war.
88
+ const SUPERSEDE_CHECK_INTERVAL = timeInMinute * 30;
89
+
90
+ /** Outcome of forwardPort. `owned` is true once we hold the router mapping for the port. When
91
+ * false, `reason` says why: "declined" = noPortStealing and another host holds the port (the
92
+ * caller should try a different port); "error" = UPnP unreachable / create failed (best-effort,
93
+ * nothing forwarded but the caller can carry on). */
94
+ export type ForwardPortResult = {
95
+ owned: boolean;
96
+ reason?: "declined" | "error";
97
+ };
98
+
77
99
  export async function forwardPort(config: {
78
100
  externalPort: number;
79
101
  internalPort: number;
102
+ /** Lease length in ms. Defaults to a PERMANENT mapping (never expires), which is what you want:
103
+ * finite leases can't be refreshed gap-free (see PERMANENT_LEASE). Pass a finite duration
104
+ * only if you specifically want the mapping to expire on its own — in that case we do NOT
105
+ * run the supersession monitor, since a finite mapping is expected to disappear. */
80
106
  duration?: number;
81
- }) {
82
- try {
83
- const { externalPort, internalPort } = config;
84
- let duration = config.duration ?? timeInHour;
107
+ /** If the port is already forwarded to a DIFFERENT internal client, don't steal it: return
108
+ * { owned: false, reason: "declined" } instead of taking over. Off by default (default is
109
+ * last-writer-wins takeover). An existing mapping that is ours (or none) is still (re)claimed. */
110
+ noPortStealing?: boolean;
111
+ }): Promise<ForwardPortResult> {
112
+ const { externalPort, internalPort } = config;
113
+ let duration = config.duration ?? PERMANENT_LEASE;
114
+ let permanent = duration === PERMANENT_LEASE;
115
+
116
+ // Take ownership of the router's mapping for this external port: delete whatever's there (a
117
+ // stale mapping of ours, or another host's) so our AddPortMapping isn't rejected as a conflict
118
+ // (718), then install our mapping. This makes the last writer win — whichever application
119
+ // forwards most recently owns the port. With noPortStealing we first bail out if the existing
120
+ // mapping belongs to someone else, leaving it untouched.
121
+ // Our current mapping for this external port (TCP), from a fresh listing.
122
+ async function readMapping(): Promise<PortMapping | undefined> {
123
+ return (await listPortMappings()).find(m => m.externalPort === externalPort && m.protocol.toUpperCase() === "TCP");
124
+ }
85
125
 
86
- const { internalIP, gatewayIP, controlPort, controlURLs } = await resolveGateway();
126
+ async function takeOwnership(): Promise<ForwardPortResult> {
127
+ try {
128
+ const { internalIP, gatewayIP, controlPort, controlURLs } = await resolveGateway();
87
129
 
88
- for (let controlURL of controlURLs) {
89
- try {
90
- await createPortMapping({
91
- externalPort, internalPort,
92
- gatewayIP,
93
- controlPort,
94
- controlPath: controlURL,
95
- internalIP,
96
- duration,
97
- });
98
- console.log(`Port mapping created on ${gatewayIP}:${externalPort} -> ${internalIP}:${internalPort}`);
99
- return;
100
- } catch (e) {
101
- console.error(`Failed to create port mapping using controlURL ${controlURL}`, e);
130
+ // One listing up front: tells us who currently owns the port (for noPortStealing) and
131
+ // whether a mapping exists at all (so we only delete when there's something to delete).
132
+ let existing = await readMapping();
133
+
134
+ if (existing && existing.internalClient !== internalIP && config.noPortStealing) {
135
+ console.log(`Port ${externalPort} is already forwarded to ${existing.internalClient} (not us); not stealing it (noPortStealing).`);
136
+ return { owned: false, reason: "declined" };
137
+ }
138
+
139
+ // Already exactly what we want (ours, permanent, right internal port) — don't churn it.
140
+ if (existing && existing.internalClient === internalIP && existing.internalPort === internalPort && permanent && existing.leaseDuration === 0) {
141
+ return { owned: true };
142
+ }
143
+
144
+ // Only delete when a mapping already exists (deleting a missing entry just errors 714,
145
+ // and a free port needs no delete). Once one exists we clear it so our AddPortMapping
146
+ // isn't rejected as a conflict (718) — this is also what makes the last writer win.
147
+ let needDelete = !!existing;
148
+ for (let controlURL of controlURLs) {
149
+ try {
150
+ if (needDelete) {
151
+ await deletePortMapping({ externalPort, gatewayIP, controlPort, controlPath: controlURL }).catch(() => { });
152
+ }
153
+ await createPortMapping({
154
+ externalPort, internalPort,
155
+ gatewayIP,
156
+ controlPort,
157
+ controlPath: controlURL,
158
+ internalIP,
159
+ duration,
160
+ });
161
+ // AddPortMapping can return 200 on a control URL that isn't the real WAN
162
+ // connection service without actually taking effect, so confirm the mapping is
163
+ // now ours before trusting it; otherwise move on and try the next control URL.
164
+ let after = await readMapping();
165
+ if (after && after.internalClient === internalIP) {
166
+ console.log(`Port mapping created on ${gatewayIP}:${externalPort} -> ${internalIP}:${internalPort}`);
167
+ return { owned: true };
168
+ }
169
+ console.error(`AddPortMapping on ${controlURL} reported success but ${externalPort} isn't forwarded to us; trying the next control URL`);
170
+ } catch (e) {
171
+ console.error(`Failed to create port mapping using controlURL ${controlURL}`, e);
172
+ }
173
+ }
174
+ // Not a port-contention issue — the port itself is fine, we just couldn't program the
175
+ // router. Return "error" (not "declined") so the caller keeps this port rather than
176
+ // scanning for another one; the console.errors above are the record of what went wrong.
177
+ console.error(`Failed to create port mapping for ${externalPort} on any control URL (${controlURLs.join(", ")})`);
178
+ } catch (e) {
179
+ console.error("Error in forwardPort", e);
180
+ }
181
+ return { owned: false, reason: "error" };
182
+ }
183
+
184
+ // Re-read the mapping and warn if we no longer own it. Compares against our CURRENT LAN IP, so
185
+ // this also catches the case where our own IP changed out from under the old mapping.
186
+ async function warnIfSuperseded() {
187
+ try {
188
+ let currentIP = await getLocalInternalIP();
189
+ let ours = (await listPortMappings()).find(m => m.externalPort === externalPort && m.protocol.toUpperCase() === "TCP");
190
+ if (!ours) {
191
+ console.error(`Port forward ${externalPort} is gone — the mapping was removed (router reboot, or another host deleted it).`);
192
+ } else if (currentIP && ours.internalClient !== currentIP) {
193
+ console.error(`Port forward ${externalPort} was superseded — it now forwards to ${ours.internalClient} instead of us (${currentIP}). Another application has taken the port.`);
102
194
  }
195
+ } catch (e) {
196
+ console.error(`Failed to check port forward ${externalPort} for supersession`, (e as Error).stack ?? e);
103
197
  }
104
- console.error("Failed to create port mapping, could not find controlURL");
105
- } catch (e) {
106
- console.error("Error in forwardPort", e);
107
198
  }
199
+
200
+ let result = await takeOwnership();
201
+
202
+ // Only monitor permanent mappings we actually own. A finite lease is expected to expire, so its
203
+ // disappearance isn't a supersession worth warning about; a declined/failed claim owns nothing.
204
+ if (result.owned && permanent) {
205
+ // unref so a script that only wants a one-off forward (and then exits) isn't held open by
206
+ // the monitor timer; a running server stays alive on its own and the checks keep firing.
207
+ let timer = setInterval(() => {
208
+ void warnIfSuperseded();
209
+ }, SUPERSEDE_CHECK_INTERVAL);
210
+ timer.unref?.();
211
+ }
212
+
213
+ return result;
108
214
  }
109
215
 
110
216
  /** Our machine's LAN IP, as the router sees it — used to tell whether an existing port
@@ -234,7 +340,7 @@ async function getControlPaths(gateway: string) {
234
340
  return matches;
235
341
  }
236
342
 
237
- async function createPortMapping(config: {
343
+ export async function createPortMapping(config: {
238
344
  externalPort: number;
239
345
  internalPort: number;
240
346
  gatewayIP: string;
@@ -280,6 +386,44 @@ async function createPortMapping(config: {
280
386
  }
281
387
  }
282
388
 
389
+ export async function deletePortMapping(config: {
390
+ externalPort: number;
391
+ gatewayIP: string;
392
+ controlPort: number;
393
+ controlPath: string;
394
+ }): Promise<void> {
395
+ const { externalPort, controlPath, controlPort, gatewayIP } = config;
396
+ const action = "\"urn:schemas-upnp-org:service:WANIPConnection:1#DeletePortMapping\"";
397
+
398
+ const soapBody = `
399
+ <?xml version="1.0"?>
400
+ <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
401
+ <s:Body>
402
+ <u:DeletePortMapping xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
403
+ <NewRemoteHost></NewRemoteHost>
404
+ <NewExternalPort>${externalPort}</NewExternalPort>
405
+ <NewProtocol>TCP</NewProtocol>
406
+ </u:DeletePortMapping>
407
+ </s:Body>
408
+ </s:Envelope>
409
+ `;
410
+
411
+ const res = await fetch(`http://${gatewayIP}:${controlPort}${controlPath}`, {
412
+ method: "POST",
413
+ headers: {
414
+ "Content-Type": "text/xml; charset=\"utf-8\"",
415
+ "SOAPAction": action,
416
+ "Content-Length": Buffer.byteLength(soapBody) + "",
417
+ },
418
+ body: soapBody
419
+ });
420
+
421
+ if (res.status !== 200) {
422
+ const data = await res.text();
423
+ throw new Error(`Failed to delete port mapping: ${data}`);
424
+ }
425
+ }
426
+
283
427
  // UPnP returns this error code when we walk past the last mapping index, which is how
284
428
  // we detect the end of the list rather than a real failure.
285
429
  const UPNP_ARRAY_INDEX_INVALID = 713;
@@ -11,14 +11,13 @@ import { parseSNIExtension, parseTLSHello, SNIType } from "./tlsParsing";
11
11
  import debugbreak from "debugbreak";
12
12
  import { getNodeId } from "./nodeCache";
13
13
  import crypto from "crypto";
14
- import { Watchable, getRootDomain, timeInHour, timeInMinute } from "./misc";
15
- import { delay, runInfinitePoll } from "./batching";
16
- import { magenta, red } from "./formatting/logColors";
14
+ import { Watchable, getRootDomain } from "./misc";
15
+ import { delay } from "./batching";
17
16
  import { yellow } from "./formatting/logColors";
18
17
  import { green } from "./formatting/logColors";
19
18
  import { formatTime } from "./formatting/format";
20
19
  import { getExternalIP, testTCPIsListening } from "./networking";
21
- import { forwardPort, listPortMappings, getLocalInternalIP, isBehindNAT, PortMapping } from "./forwardPort";
20
+ import { forwardPort, isBehindNAT } from "./forwardPort";
22
21
 
23
22
  // When a requested port is taken and useAvailablePortIfPortInUse is set, we scan
24
23
  // upwards from this base instead of binding a random OS-assigned port, so restarts
@@ -349,7 +348,7 @@ export async function startSocketServer(
349
348
  }
350
349
 
351
350
  // Frees the currently-bound listening socket so we can rebind on a different port
352
- // (used when a locally-free port turns out to be unusable for forwarding).
351
+ // (used when a locally-bound port turns out to be forwarded to another host).
353
352
  async function releasePort(): Promise<void> {
354
353
  await new Promise<void>((resolve, reject) => {
355
354
  if (!realServer.listening) {
@@ -360,39 +359,10 @@ export async function startSocketServer(
360
359
  });
361
360
  }
362
361
 
363
- // Forwarding maps the external port to an equal internal port, so a candidate is only
364
- // usable if we can also own its external mapping on the router. Only worthwhile when a
365
- // NAT sits between us and the internet; a directly-reachable public host needs no forward.
362
+ // Only worthwhile when a NAT sits between us and the internet; a directly-reachable public host
363
+ // needs no forward.
366
364
  const doForward = !!(config.autoForwardPort && config.public) && await isBehindNAT();
367
365
 
368
- // Ensures the router's external mapping for `externalPort` belongs to us. Returns true
369
- // if it's ours to keep (existing-and-ours → refresh the lease; free → create and
370
- // confirm we won it), false if another machine owns it and we should try a new port.
371
- async function claimPortForward(externalPort: number): Promise<boolean> {
372
- const ourIP = await getLocalInternalIP();
373
- const matches = (m: PortMapping) => m.externalPort === externalPort && m.protocol.toUpperCase() === "TCP";
374
-
375
- const existing = (await listPortMappings()).find(matches);
376
- if (existing) {
377
- if (ourIP && existing.internalClient === ourIP) {
378
- await forwardPort({ externalPort, internalPort: externalPort });
379
- return true;
380
- }
381
- console.log(magenta(`External port ${externalPort} is already forwarded to ${existing.internalClient}, trying another port`));
382
- return false;
383
- }
384
-
385
- // Free right now — create the mapping, then re-read it to make sure another host
386
- // didn't grab the same external port in the race between our list and our create.
387
- await forwardPort({ externalPort, internalPort: externalPort });
388
- const ours = (await listPortMappings()).find(matches);
389
- if (!ours || (ourIP && ours.internalClient !== ourIP)) {
390
- console.log(magenta(`Failed to claim external port ${externalPort} (now ${ours ? `forwarded to ${ours.internalClient}` : "still unmapped"}), trying another port`));
391
- return false;
392
- }
393
- return true;
394
- }
395
-
396
366
  // Candidate ports: an explicitly requested port first (a falsy port, the default 0,
397
367
  // means "no preference"), then a consistent upward scan so restarts are predictable.
398
368
  function* candidatePorts(): Generator<number> {
@@ -411,19 +381,14 @@ export async function startSocketServer(
411
381
  }
412
382
  continue;
413
383
  }
414
- // Locally bound. If we also forward, the external mapping must be ours too, or we
415
- // release this port and keep scanning.
384
+ // Locally bound. If we also forward, we need to own the router mapping for this port too.
416
385
  if (doForward) {
417
- let claimed: boolean;
418
- try {
419
- claimed = await claimPortForward(candidate);
420
- } catch (e) {
421
- // UPnP unavailable (no gateway / discovery failed): fall back to best-effort
422
- // forwarding rather than refusing to start.
423
- console.error(red(`Could not verify forwarding for port ${candidate}, continuing best-effort: ${(e as Error).stack}`));
424
- claimed = true;
425
- }
426
- if (!claimed) {
386
+ // Take over the explicitly-requested port (the user asked for it), but during the
387
+ // fallback scan don't steal a port another host is already forwarding — skip it and
388
+ // keep scanning. forwardPort is best-effort: on a UPnP error it returns owned:false
389
+ // reason:"error", and we keep the port anyway rather than refusing to start.
390
+ let result = await forwardPort({ externalPort: candidate, internalPort: candidate, noPortStealing: candidate !== config.port });
391
+ if (!result.owned && result.reason === "declined") {
427
392
  await releasePort();
428
393
  continue;
429
394
  }
@@ -438,15 +403,6 @@ export async function startSocketServer(
438
403
 
439
404
  port = (realServer.address() as net.AddressInfo).port;
440
405
 
441
- if (doForward) {
442
- // The mapping is claimed above; keep refreshing the lease so it doesn't expire.
443
- async function refreshForward() {
444
- await forwardPort({ externalPort: port, internalPort: port });
445
- console.log(magenta(`Refreshed port forward ${port} to our machine`));
446
- }
447
- runInfinitePoll(timeInMinute * 30, refreshForward);
448
- }
449
-
450
406
  let nodeId = getNodeId(getCommonName(config.cert), port);
451
407
  console.log(green(`Started Listening on ${nodeId} (${host}) after ${formatTime(process.uptime() * 1000)}`), {
452
408
  domains: Object.keys(config.SNICerts || {}),
package/test.ts CHANGED
@@ -1,21 +1,73 @@
1
- import { listPortMappings, getLocalInternalIP, isBehindNAT } from "./src/forwardPort";
1
+ import { forwardPort, listPortMappings, resolveGateway, createPortMapping, deletePortMapping, PortMapping } from "./src/forwardPort";
2
2
 
3
+ // Verifies noPortStealing:
4
+ // 1. Against a real foreign mapping (owned by another device, if the router has one): forwardPort
5
+ // must return { owned:false, reason:"declined" } and leave that mapping UNTOUCHED.
6
+ // 2. On a free port: noPortStealing still claims it (owned:true).
7
+ // 3. When the existing mapping is OURS: noPortStealing still (re)claims it (owned:true).
8
+ // 4. Default (stealing) still takes over an existing finite mapping and makes it permanent.
9
+
10
+ const TEST_EXTERNAL_PORT = 54000;
11
+ const TEST_INTERNAL_PORT = 54000;
3
12
 
4
13
  async function main() {
5
- let ourIP = await getLocalInternalIP();
6
- let behindNAT = await isBehindNAT();
7
- console.log(`Outbound / local IP: ${ourIP ?? "(could not determine)"}`);
8
- console.log(`Behind NAT (forwarding ${behindNAT ? "WILL" : "will NOT"} be attempted): ${behindNAT}`);
9
-
10
- let mappings = await listPortMappings();
11
- console.log(`Found ${mappings.length} port mapping(s):`);
12
- for (let mapping of mappings) {
13
- let host = mapping.remoteHost || "*";
14
- let lease = mapping.leaseDuration === 0 ? "permanent" : `${mapping.leaseDuration}s left`;
15
- let ours = ourIP && mapping.internalClient === ourIP ? " <-- OURS" : "";
16
- console.log(` ${mapping.protocol} ${host}:${mapping.externalPort} -> ${mapping.internalClient}:${mapping.internalPort} (${mapping.enabled ? "enabled" : "disabled"}, ${lease})${mapping.description ? ` "${mapping.description}"` : ""}${ours}`);
14
+ let { internalIP, gatewayIP, controlPort, controlURLs } = await resolveGateway();
15
+ console.log(`Our IP: ${internalIP}`);
16
+
17
+ async function mappingFor(externalPort: number): Promise<PortMapping | undefined> {
18
+ return (await listPortMappings()).find(m => m.externalPort === externalPort && m.protocol.toUpperCase() === "TCP");
19
+ }
20
+ async function cleanup() {
21
+ for (let controlPath of controlURLs) {
22
+ await deletePortMapping({ externalPort: TEST_EXTERNAL_PORT, gatewayIP, controlPort, controlPath }).catch(() => { });
23
+ }
24
+ }
25
+ let pass = true;
26
+ let check = (ok: boolean, msg: string) => { if (!ok) pass = false; console.log(` ${ok ? "PASS" : "FAIL"}: ${msg}`); };
27
+
28
+ // 1. Real foreign mapping, if the router has one.
29
+ console.log(`\n=== 1) decline a real foreign mapping (noPortStealing) ===`);
30
+ let foreign = (await listPortMappings()).find(m => m.protocol.toUpperCase() === "TCP" && m.internalClient && m.internalClient !== internalIP);
31
+ if (!foreign) {
32
+ console.log(` (skipped: router has no mapping owned by another device to test against)`);
33
+ } else {
34
+ console.log(` found foreign mapping: port ${foreign.externalPort} -> ${foreign.internalClient} (lease ${foreign.leaseDuration}s)`);
35
+ let result = await forwardPort({ externalPort: foreign.externalPort, internalPort: foreign.internalPort, noPortStealing: true });
36
+ let after = await mappingFor(foreign.externalPort);
37
+ check(!result.owned && result.reason === "declined", `forwardPort returned declined (got owned=${result.owned}, reason=${result.reason})`);
38
+ check(after?.internalClient === foreign.internalClient && after?.leaseDuration === foreign.leaseDuration, `foreign mapping left untouched (still -> ${after?.internalClient}, lease ${after?.leaseDuration}s)`);
17
39
  }
18
- console.table(mappings);
40
+
41
+ // 2. Free port: noPortStealing still claims.
42
+ console.log(`\n=== 2) claim a free port (noPortStealing) ===`);
43
+ await cleanup();
44
+ let r2 = await forwardPort({ externalPort: TEST_EXTERNAL_PORT, internalPort: TEST_INTERNAL_PORT, noPortStealing: true });
45
+ let m2 = await mappingFor(TEST_EXTERNAL_PORT);
46
+ check(r2.owned === true, `returned owned=true (got ${r2.owned}, reason=${r2.reason})`);
47
+ check(m2?.internalClient === internalIP && m2?.leaseDuration === 0, `mapping is ours & permanent (-> ${m2?.internalClient}, lease ${m2?.leaseDuration}s)`);
48
+
49
+ // 3. Existing mapping is OURS: noPortStealing still reclaims.
50
+ console.log(`\n=== 3) reclaim our own mapping (noPortStealing) ===`);
51
+ let r3 = await forwardPort({ externalPort: TEST_EXTERNAL_PORT, internalPort: TEST_INTERNAL_PORT, noPortStealing: true });
52
+ check(r3.owned === true, `returned owned=true (got ${r3.owned}, reason=${r3.reason})`);
53
+
54
+ // 4. Default (stealing) takes over a finite mapping -> permanent.
55
+ console.log(`\n=== 4) default takeover of a finite mapping ===`);
56
+ await cleanup();
57
+ for (let controlPath of controlURLs) {
58
+ try {
59
+ await createPortMapping({ externalPort: TEST_EXTERNAL_PORT, internalPort: TEST_INTERNAL_PORT, gatewayIP, controlPort, controlPath, internalIP, duration: 120 * 1000 });
60
+ break;
61
+ } catch { }
62
+ }
63
+ let seeded = await mappingFor(TEST_EXTERNAL_PORT);
64
+ let r4 = await forwardPort({ externalPort: TEST_EXTERNAL_PORT, internalPort: TEST_INTERNAL_PORT });
65
+ let m4 = await mappingFor(TEST_EXTERNAL_PORT);
66
+ check(seeded?.leaseDuration === 120, `seeded a finite (120s) mapping first (got ${seeded?.leaseDuration}s)`);
67
+ check(r4.owned === true && m4?.leaseDuration === 0, `took over -> permanent (owned=${r4.owned}, lease ${m4?.leaseDuration}s)`);
68
+
69
+ await cleanup();
70
+ console.log(`\n${pass ? "ALL PASS" : "SOME FAILED"}. Cleaned up test mapping on port ${TEST_EXTERNAL_PORT}.`);
19
71
  }
20
72
 
21
73
  main().catch(e => console.error(e.stack ?? e)).finally(() => process.exit(0));