socket-function 1.2.23 → 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.
- package/.claude/settings.local.json +2 -1
- package/index.d.ts +41 -4
- package/package.json +1 -1
- package/src/forwardPort.d.ts +41 -4
- package/src/forwardPort.ts +146 -24
- package/src/webSocketServer.ts +11 -52
- package/test.ts +66 -14
|
@@ -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,14 +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
|
-
/**
|
|
988
|
-
*
|
|
989
|
-
|
|
990
|
-
|
|
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>;
|
|
991
1013
|
/** Our machine's LAN IP, as the router sees it — used to tell whether an existing port
|
|
992
1014
|
* mapping points at us or at a different machine on the network. */
|
|
993
1015
|
export declare function getLocalInternalIP(): Promise<string | undefined>;
|
|
@@ -996,6 +1018,21 @@ declare module "socket-function/src/forwardPort" {
|
|
|
996
1018
|
* directly reachable and forwarding is unnecessary. This is the cross-platform gate that
|
|
997
1019
|
* replaced the old "skip forwarding on linux" check, so Linux hosts behind NAT forward. */
|
|
998
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>;
|
|
999
1036
|
|
|
1000
1037
|
}
|
|
1001
1038
|
|
package/package.json
CHANGED
package/src/forwardPort.d.ts
CHANGED
|
@@ -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,14 +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
|
-
/**
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
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>;
|
|
26
48
|
/** Our machine's LAN IP, as the router sees it — used to tell whether an existing port
|
|
27
49
|
* mapping points at us or at a different machine on the network. */
|
|
28
50
|
export declare function getLocalInternalIP(): Promise<string | undefined>;
|
|
@@ -31,3 +53,18 @@ export declare function getLocalInternalIP(): Promise<string | undefined>;
|
|
|
31
53
|
* directly reachable and forwarding is unnecessary. This is the cross-platform gate that
|
|
32
54
|
* replaced the old "skip forwarding on linux" check, so Linux hosts behind NAT forward. */
|
|
33
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>;
|
package/src/forwardPort.ts
CHANGED
|
@@ -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 {
|
|
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,29 +74,82 @@ 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
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
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
|
+
};
|
|
81
98
|
|
|
82
99
|
export async function forwardPort(config: {
|
|
83
100
|
externalPort: number;
|
|
84
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. */
|
|
85
106
|
duration?: number;
|
|
86
|
-
/**
|
|
87
|
-
*
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
}
|
|
125
|
+
|
|
126
|
+
async function takeOwnership(): Promise<ForwardPortResult> {
|
|
94
127
|
try {
|
|
95
|
-
const { externalPort, internalPort } = config;
|
|
96
128
|
const { internalIP, gatewayIP, controlPort, controlURLs } = await resolveGateway();
|
|
97
129
|
|
|
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;
|
|
98
148
|
for (let controlURL of controlURLs) {
|
|
99
149
|
try {
|
|
150
|
+
if (needDelete) {
|
|
151
|
+
await deletePortMapping({ externalPort, gatewayIP, controlPort, controlPath: controlURL }).catch(() => { });
|
|
152
|
+
}
|
|
100
153
|
await createPortMapping({
|
|
101
154
|
externalPort, internalPort,
|
|
102
155
|
gatewayIP,
|
|
@@ -105,28 +158,59 @@ export async function forwardPort(config: {
|
|
|
105
158
|
internalIP,
|
|
106
159
|
duration,
|
|
107
160
|
});
|
|
108
|
-
|
|
109
|
-
|
|
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`);
|
|
110
170
|
} catch (e) {
|
|
111
171
|
console.error(`Failed to create port mapping using controlURL ${controlURL}`, e);
|
|
112
172
|
}
|
|
113
173
|
}
|
|
114
|
-
|
|
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(", ")})`);
|
|
115
178
|
} catch (e) {
|
|
116
179
|
console.error("Error in forwardPort", e);
|
|
117
180
|
}
|
|
181
|
+
return { owned: false, reason: "error" };
|
|
118
182
|
}
|
|
119
183
|
|
|
120
|
-
|
|
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.`);
|
|
194
|
+
}
|
|
195
|
+
} catch (e) {
|
|
196
|
+
console.error(`Failed to check port forward ${externalPort} for supersession`, (e as Error).stack ?? e);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
121
199
|
|
|
122
|
-
|
|
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) {
|
|
123
205
|
// unref so a script that only wants a one-off forward (and then exits) isn't held open by
|
|
124
|
-
// the
|
|
206
|
+
// the monitor timer; a running server stays alive on its own and the checks keep firing.
|
|
125
207
|
let timer = setInterval(() => {
|
|
126
|
-
void
|
|
127
|
-
},
|
|
208
|
+
void warnIfSuperseded();
|
|
209
|
+
}, SUPERSEDE_CHECK_INTERVAL);
|
|
128
210
|
timer.unref?.();
|
|
129
211
|
}
|
|
212
|
+
|
|
213
|
+
return result;
|
|
130
214
|
}
|
|
131
215
|
|
|
132
216
|
/** Our machine's LAN IP, as the router sees it — used to tell whether an existing port
|
|
@@ -256,7 +340,7 @@ async function getControlPaths(gateway: string) {
|
|
|
256
340
|
return matches;
|
|
257
341
|
}
|
|
258
342
|
|
|
259
|
-
async function createPortMapping(config: {
|
|
343
|
+
export async function createPortMapping(config: {
|
|
260
344
|
externalPort: number;
|
|
261
345
|
internalPort: number;
|
|
262
346
|
gatewayIP: string;
|
|
@@ -302,6 +386,44 @@ async function createPortMapping(config: {
|
|
|
302
386
|
}
|
|
303
387
|
}
|
|
304
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
|
+
|
|
305
427
|
// UPnP returns this error code when we walk past the last mapping index, which is how
|
|
306
428
|
// we detect the end of the list rather than a real failure.
|
|
307
429
|
const UPNP_ARRAY_INDEX_INVALID = 713;
|
package/src/webSocketServer.ts
CHANGED
|
@@ -13,12 +13,11 @@ import { getNodeId } from "./nodeCache";
|
|
|
13
13
|
import crypto from "crypto";
|
|
14
14
|
import { Watchable, getRootDomain } from "./misc";
|
|
15
15
|
import { delay } from "./batching";
|
|
16
|
-
import { magenta, red } from "./formatting/logColors";
|
|
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,
|
|
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-
|
|
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
|
-
//
|
|
364
|
-
//
|
|
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
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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,12 +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; this establishes the maintained forward, which
|
|
443
|
-
// auto-renews its own lease (at 80% of the duration) so it doesn't expire.
|
|
444
|
-
await forwardPort({ externalPort: port, internalPort: port });
|
|
445
|
-
}
|
|
446
|
-
|
|
447
406
|
let nodeId = getNodeId(getCommonName(config.cert), port);
|
|
448
407
|
console.log(green(`Started Listening on ${nodeId} (${host}) after ${formatTime(process.uptime() * 1000)}`), {
|
|
449
408
|
domains: Object.keys(config.SNICerts || {}),
|
package/test.ts
CHANGED
|
@@ -1,21 +1,73 @@
|
|
|
1
|
-
import { listPortMappings,
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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));
|