freehop 0.1.0-alpha.0

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.
@@ -0,0 +1,1058 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // peerlane port mapper: asks the home router to forward an inbound UDP/TCP port so a desktop participant or a
3
+ // volunteer relay becomes directly reachable. Speaks PCP (RFC 6887), NAT-PMP (RFC 6886) and UPnP IGD v1/v2
4
+ // (SSDP discovery + SOAP WANIPConnection/WANPPPConnection). Node built-ins only.
5
+ //
6
+ // const mapper = await createPortMapper({ log: (event, details) => console.debug(event, details) });
7
+ // const status = await mapper.probe();
8
+ // const mapping = await mapper.map({ protocol: 'udp', internalPort: 3478 });
9
+ // mapper.on('renewed', (m) => {}).on('lost', (m, err) => {});
10
+ // await mapper.close();
11
+
12
+ import dgram from 'node:dgram';
13
+ import http from 'node:http';
14
+ import net from 'node:net';
15
+ import os from 'node:os';
16
+ import { execFile } from 'node:child_process';
17
+ import { readFile } from 'node:fs/promises';
18
+ import { EventEmitter } from 'node:events';
19
+ import { randomBytes, randomInt } from 'node:crypto';
20
+
21
+ const METHODS = ['pcp', 'natpmp', 'upnp'];
22
+ const DEFAULTS = { methods: METHODS, timeoutMs: 2500, pcpPort: 5351, natpmpPort: 5351, ssdpAddress: '239.255.255.250', ssdpPort: 1900 };
23
+
24
+ const PCP_VERSION = 2;
25
+ const OP_ANNOUNCE = 0;
26
+ const OP_MAP = 1;
27
+ const PCP_RESULTS = ['SUCCESS', 'UNSUPP_VERSION', 'NOT_AUTHORIZED', 'MALFORMED_REQUEST', 'UNSUPP_OPCODE', 'UNSUPP_OPTION', 'MALFORMED_OPTION',
28
+ 'NETWORK_FAILURE', 'NO_RESOURCES', 'UNSUPP_PROTOCOL', 'USER_EX_QUOTA', 'CANNOT_PROVIDE_EXTERNAL', 'ADDRESS_MISMATCH', 'EXCESSIVE_REMOTE_PEERS'];
29
+ const PCP_SHORT_LIVED = new Set([7, 8, 10, 11]); // RFC 6887 §7.4: short-lifetime errors, worth retrying later
30
+ const NATPMP_RESULTS = ['SUCCESS', 'UNSUPP_VERSION', 'NOT_AUTHORIZED', 'NETWORK_FAILURE', 'OUT_OF_RESOURCES', 'UNSUPP_OPCODE'];
31
+ const NATPMP_RETRYABLE = new Set([3, 4]);
32
+
33
+ const IGD_TARGETS = ['urn:schemas-upnp-org:device:InternetGatewayDevice:2', 'urn:schemas-upnp-org:device:InternetGatewayDevice:1'];
34
+ const WAN_SERVICES = ['urn:schemas-upnp-org:service:WANIPConnection:2', 'urn:schemas-upnp-org:service:WANIPConnection:1', 'urn:schemas-upnp-org:service:WANPPPConnection:1'];
35
+ const IGD_HINT = /InternetGatewayDevice|WAN(?:IP|PPP)Connection|WANConnectionDevice|WANDevice/i;
36
+ const UPNP_ERRORS = {
37
+ 401: 'Invalid Action', 402: 'Invalid Args', 501: 'Action Failed', 602: 'Optional Action Not Implemented', 606: 'Action Not Authorized',
38
+ 714: 'NoSuchEntryInArray', 715: 'WildCardNotPermittedInSrcIP', 716: 'WildCardNotPermittedInExtPort', 718: 'ConflictInMappingEntry',
39
+ 724: 'SamePortValuesRequired', 725: 'OnlyPermanentLeasesSupported', 726: 'RemoteHostOnlySupportsWildcard', 727: 'ExternalPortOnlySupportsWildcard',
40
+ 728: 'NoPortMapsAvailable', 729: 'ConflictWithOtherMechanisms', 732: 'WildCardNotPermittedInIntPort',
41
+ };
42
+ const XML_LIMIT = 64 * 1024;
43
+ const SSDP_LIMIT = 4096;
44
+ const UPNP_MAX_LEASE = 604800; // IGDv2 upper bound; several v1 routers reject more as well
45
+ const CONFLICT_ATTEMPTS = 5;
46
+ const UNAVAILABLE_TTL_MS = 30_000;
47
+ const MIN_RETRY_MS = 250;
48
+ const MAX_TIMER_MS = 2 ** 31 - 1;
49
+
50
+ // ---------------------------------------------------------------------------------------------------------------
51
+ // errors
52
+
53
+ export class PortMapperError extends Error {
54
+ constructor(message, code, { cause, ...details } = {}) {
55
+ super(message, cause === undefined ? undefined : { cause });
56
+ this.name = 'PortMapperError';
57
+ this.code = code;
58
+ Object.assign(this, details); // method, transient, unavailable, resultCode, upnpErrorCode, errors, ...
59
+ }
60
+ }
61
+ const fail = (message, code, details) => new PortMapperError(message, code, details);
62
+ const aborted = () => fail('operation aborted: port mapper closed', 'ABORTED');
63
+ const closedError = () => fail('port mapper is closed', 'CLOSED');
64
+
65
+ // ---------------------------------------------------------------------------------------------------------------
66
+ // IPv4 helpers
67
+
68
+ const isV4 = (ip) => typeof ip === 'string' && net.isIPv4(ip);
69
+ const v4Int = (ip) => ip.split('.').reduce((n, octet) => ((n << 8) | Number(octet)) >>> 0, 0);
70
+ const inCidr = (ip, base, bits) => bits <= 0 || ((v4Int(ip) ^ v4Int(base)) >>> (32 - bits)) === 0;
71
+ const maskBits = (mask) => { let n = v4Int(mask), bits = 0; while (n & 0x80000000) { bits++; n = (n << 1) >>> 0; } return bits; };
72
+ const isV4Family = (entry) => entry.family === 'IPv4' || entry.family === 4;
73
+ const validPort = (port) => Number.isInteger(port) && port >= 1 && port <= 65535;
74
+ const externalOrNull = (ip) => (isV4(ip) && ip !== '0.0.0.0' ? ip : null);
75
+
76
+ // Not globally routable: RFC 1918, RFC 6598 CGNAT, loopback, link-local, "this network", IETF protocol assignments
77
+ // (DS-Lite B4 192.0.0.2), benchmarking, multicast/reserved/broadcast. A router reporting one of these as its WAN
78
+ // address sits behind another NAT, so a mapping on it does not make the host reachable from the Internet.
79
+ const NON_PUBLIC_V4 = [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
80
+ ['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 3]];
81
+ export const isPrivateIPv4 = (ip) => isV4(ip) && NON_PUBLIC_V4.some(([base, bits]) => inCidr(ip, base, bits));
82
+
83
+ const writeMappedV4 = (buf, offset, ip) => { buf.fill(0, offset, offset + 10); buf.writeUInt16BE(0xffff, offset + 10); buf.writeUInt32BE(v4Int(ip), offset + 12); };
84
+ const readMappedV4 = (buf, offset) => {
85
+ for (let i = offset; i < offset + 10; i++) if (buf[i] !== 0) return null;
86
+ return buf.readUInt16BE(offset + 10) === 0xffff ? [...buf.subarray(offset + 12, offset + 16)].join('.') : null;
87
+ };
88
+
89
+ // ---------------------------------------------------------------------------------------------------------------
90
+ // gateway and local address discovery
91
+
92
+ /** Parse default-route output: platform 'linux' (/proc/net/route), 'ip' (`ip -4 route show default`), 'win32' (`route print -4`), else BSD/darwin (`route -n get default`). */
93
+ export function parseDefaultGateway(platform, text) {
94
+ const out = String(text ?? '');
95
+ let best = null;
96
+ const consider = (gateway, iface, metric) => {
97
+ if (isV4(gateway) && gateway !== '0.0.0.0' && (!best || metric < best.metric)) best = { gateway, interface: iface, metric };
98
+ };
99
+ if (platform === 'linux') {
100
+ for (const line of out.split('\n').slice(1)) {
101
+ const f = line.trim().split(/\s+/);
102
+ if (f.length < 8 || f[1] !== '00000000' || f[7] !== '00000000' || (parseInt(f[3], 16) & 3) !== 3) continue; // default, RTF_UP|RTF_GATEWAY
103
+ const g = parseInt(f[2], 16) >>> 0;
104
+ consider([0, 8, 16, 24].map((shift) => (g >>> shift) & 255).join('.'), f[0], Number(f[6]) || 0); // little-endian hex
105
+ }
106
+ } else if (platform === 'ip') {
107
+ for (const m of out.matchAll(/^default\s+via\s+(\S+)\s+dev\s+(\S+)(?:.*?\bmetric\s+(\d+))?/gm)) consider(m[1], m[2], Number(m[3] ?? 0));
108
+ } else if (platform === 'win32') {
109
+ for (const m of out.matchAll(/^\s*0\.0\.0\.0\s+0\.0\.0\.0\s+(\d{1,3}(?:\.\d{1,3}){3})\s+(\d{1,3}(?:\.\d{1,3}){3})\s+(\d+)\s*$/gm)) consider(m[1], m[2], Number(m[3]));
110
+ } else {
111
+ consider(/^\s*gateway:\s*(\S+)\s*$/m.exec(out)?.[1], /^\s*interface:\s*(\S+)\s*$/m.exec(out)?.[1], 0);
112
+ }
113
+ return best && { gateway: best.gateway, interface: best.interface ?? null };
114
+ }
115
+
116
+ /** Find the default IPv4 gateway. Fixed argv, no shell, bounded time. Resolves { gateway, interface } or null. */
117
+ export async function detectGateway({ timeoutMs = 2500, signal } = {}) {
118
+ const run = (file, args) => new Promise((resolve) => {
119
+ try {
120
+ execFile(file, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 256 * 1024, signal, env: { ...process.env, LC_ALL: 'C' } },
121
+ (err, stdout) => resolve(err ? '' : String(stdout)));
122
+ } catch { resolve(''); }
123
+ });
124
+ if (process.platform === 'win32') {
125
+ const exe = process.env.SystemRoot ? `${process.env.SystemRoot}\\System32\\route.exe` : 'route';
126
+ return parseDefaultGateway('win32', await run(exe, ['print', '-4', '0.0.0.0'])) ?? parseDefaultGateway('win32', await run(exe, ['print', '-4']));
127
+ }
128
+ if (process.platform === 'linux' || process.platform === 'android') {
129
+ const table = await readFile('/proc/net/route', 'utf8').catch(() => '');
130
+ return parseDefaultGateway('linux', table) ?? parseDefaultGateway('ip', await run('ip', ['-4', 'route', 'show', 'default']));
131
+ }
132
+ const out = (await run('/sbin/route', ['-n', 'get', 'default'])) || (await run('route', ['-n', 'get', 'default']));
133
+ return parseDefaultGateway(process.platform, out);
134
+ }
135
+
136
+ function localAddressFor(gateway, hint) {
137
+ const matches = [];
138
+ for (const [name, list] of Object.entries(os.networkInterfaces())) {
139
+ for (const a of list ?? []) {
140
+ if (!isV4Family(a) || !isV4(a.address) || !isV4(a.netmask)) continue;
141
+ const bits = maskBits(a.netmask);
142
+ if (bits > 0 && inCidr(gateway, a.address, bits)) matches.push({ name, address: a.address });
143
+ }
144
+ }
145
+ return (matches.find((m) => m.name === hint || m.address === hint) ?? matches[0])?.address ?? null;
146
+ }
147
+
148
+ function prefixFor(localAddress) {
149
+ for (const list of Object.values(os.networkInterfaces())) {
150
+ for (const a of list ?? []) if (isV4Family(a) && a.address === localAddress && isV4(a.netmask)) return maskBits(a.netmask);
151
+ }
152
+ return 32;
153
+ }
154
+
155
+ // Ask the kernel which source address it would use towards the gateway (UDP connect sends nothing).
156
+ async function routedLocalAddress(gateway) {
157
+ const sock = dgram.createSocket('udp4');
158
+ try {
159
+ await new Promise((resolve, reject) => { sock.once('error', reject); sock.connect(9, gateway, resolve); });
160
+ const { address } = sock.address();
161
+ return isV4(address) && address !== '0.0.0.0' ? address : null;
162
+ } catch {
163
+ return null;
164
+ } finally {
165
+ try { sock.close(); } catch {}
166
+ }
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------------------------------------------
170
+ // transports
171
+
172
+ // One request/response over a fresh *connected* UDP socket (the kernel drops datagrams from anyone but the gateway
173
+ // port, and ICMP port-unreachable surfaces as ECONNREFUSED for a fast fallback). Retransmits with doubling backoff
174
+ // (250 ms, 500 ms, ...) inside timeoutMs. decode(msg) returns a value, undefined (not ours: keep waiting) or throws.
175
+ function udpExchange({ host, port, localAddress, payload, timeoutMs, signal, decode, label }) {
176
+ return new Promise((resolve, reject) => {
177
+ let sock = null, done = false, resend = null, rto = 250;
178
+ const until = Date.now() + timeoutMs;
179
+ const finish = (err, value) => {
180
+ if (done) return;
181
+ done = true;
182
+ clearTimeout(resend);
183
+ clearTimeout(deadline);
184
+ signal?.removeEventListener('abort', onAbort);
185
+ try { sock?.close(); } catch {}
186
+ if (err) reject(err); else resolve(value);
187
+ };
188
+ const onAbort = () => finish(aborted());
189
+ const netError = (err) => finish(['ECONNREFUSED', 'ECONNRESET'].includes(err?.code)
190
+ ? fail(`${label}: ${host}:${port} is unreachable (port closed)`, 'UNREACHABLE', { transient: true, unavailable: true, cause: err })
191
+ : fail(`${label}: socket error ${err?.code ?? err?.message}`, 'SOCKET_ERROR', { transient: true, unavailable: true, cause: err }));
192
+ const deadline = setTimeout(() => finish(fail(`${label}: no response from ${host}:${port} within ${timeoutMs} ms`, 'TIMEOUT', { transient: true, unavailable: true })), timeoutMs);
193
+ if (signal?.aborted) return onAbort();
194
+ signal?.addEventListener('abort', onAbort, { once: true });
195
+ const send = () => {
196
+ if (done) return;
197
+ try { sock.send(payload, (err) => err && netError(err)); } catch (err) { return netError(err); }
198
+ const wait = Math.min(rto, until - Date.now());
199
+ rto *= 2;
200
+ if (wait > 0) resend = setTimeout(send, wait);
201
+ };
202
+ try {
203
+ sock = dgram.createSocket('udp4');
204
+ sock.on('error', netError);
205
+ sock.on('message', (msg) => {
206
+ if (done) return;
207
+ let value;
208
+ try { value = decode(msg); } catch (err) { return finish(err); }
209
+ if (value !== undefined) finish(null, value);
210
+ });
211
+ sock.bind({ address: localAddress, port: 0 }, () => {
212
+ if (done) return;
213
+ try { sock.connect(port, host, (err) => (err ? netError(err) : send())); } catch (err) { netError(err); }
214
+ });
215
+ } catch (err) {
216
+ netError(err);
217
+ }
218
+ });
219
+ }
220
+
221
+ // Single HTTP/1.1 request (no keep-alive agent, so no socket outlives the call), total-time bounded, body capped.
222
+ function httpExchange(url, { method = 'GET', headers = {}, body, timeoutMs, signal, localAddress, limit = XML_LIMIT }) {
223
+ return new Promise((resolve, reject) => {
224
+ let req = null, done = false;
225
+ const finish = (err, value) => {
226
+ if (done) return;
227
+ done = true;
228
+ clearTimeout(timer);
229
+ signal?.removeEventListener('abort', onAbort);
230
+ try { req?.destroy(); } catch {}
231
+ if (err) reject(err); else resolve(value);
232
+ };
233
+ const onAbort = () => finish(aborted());
234
+ const where = `HTTP ${method} ${url.host}${url.pathname}`;
235
+ const transportError = (err) => finish(fail(`${where}: ${err?.code ?? err?.message}`, 'HTTP_ERROR', { transient: true, cause: err }));
236
+ const tooLarge = () => finish(fail(`${where}: response larger than ${limit} bytes`, 'TOO_LARGE'));
237
+ const timer = setTimeout(() => finish(fail(`${where}: no complete response within ${timeoutMs} ms`, 'TIMEOUT', { transient: true })), timeoutMs);
238
+ if (signal?.aborted) return onAbort();
239
+ signal?.addEventListener('abort', onAbort, { once: true });
240
+ try {
241
+ req = http.request({
242
+ host: url.hostname, port: url.port || 80, path: `${url.pathname}${url.search}`, method, agent: false, localAddress,
243
+ insecureHTTPParser: true, // consumer router firmware ships sloppy HTTP; one request per connection, size-capped
244
+ headers: body === undefined ? headers : { ...headers, 'Content-Length': Buffer.byteLength(body) },
245
+ });
246
+ } catch (err) {
247
+ return transportError(err);
248
+ }
249
+ req.on('error', transportError);
250
+ req.on('response', (res) => {
251
+ if (Number(res.headers['content-length']) > limit) return tooLarge();
252
+ const chunks = [];
253
+ let size = 0;
254
+ res.on('data', (chunk) => { size += chunk.length; if (size > limit) tooLarge(); else chunks.push(chunk); });
255
+ res.on('error', transportError);
256
+ res.on('end', () => finish(null, { status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
257
+ res.on('close', () => { if (!res.complete) transportError(new Error('connection closed mid-response')); });
258
+ });
259
+ req.end(body);
260
+ });
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------------------------------------------
264
+ // PCP (RFC 6887) and NAT-PMP (RFC 6886) wire formats
265
+
266
+ function encodePcp(op, { lifetime = 0, client, nonce, protocol, internalPort, externalPort = 0, externalAddress = '0.0.0.0' }) {
267
+ const buf = Buffer.alloc(op === OP_MAP ? 60 : 24);
268
+ buf[0] = PCP_VERSION;
269
+ buf[1] = op; // R bit 0: request
270
+ buf.writeUInt32BE(lifetime, 4);
271
+ writeMappedV4(buf, 8, client); // client IP as IPv4-mapped IPv6
272
+ if (op === OP_MAP) {
273
+ nonce.copy(buf, 24);
274
+ buf[36] = protocol === 'tcp' ? 6 : 17;
275
+ buf.writeUInt16BE(internalPort, 40);
276
+ buf.writeUInt16BE(externalPort, 42);
277
+ writeMappedV4(buf, 44, externalAddress); // ::ffff:0.0.0.0 = no preference
278
+ }
279
+ return buf;
280
+ }
281
+
282
+ const pcpError = (op, result, lifetime, extra) => {
283
+ const name = PCP_RESULTS[result] ?? `PCP_RESULT_${result}`;
284
+ return fail(`PCP ${op === OP_MAP ? 'MAP' : 'ANNOUNCE'} refused: ${name}`, name, {
285
+ method: 'pcp', resultCode: result, errorLifetime: lifetime, transient: PCP_SHORT_LIVED.has(result),
286
+ unavailable: result === 1 || (op === OP_MAP && result === 4), ...extra,
287
+ });
288
+ };
289
+
290
+ function decodePcp(msg, { op, nonce, protocol, internalPort }, ignore) {
291
+ if (msg.length < 4) return ignore('short');
292
+ if (msg[0] !== PCP_VERSION) {
293
+ // A NAT-PMP-only gateway answers a v2 request with a v0 "unsupported version" (RFC 6887 §9 / RFC 6886 §3.5);
294
+ // a PCP server speaking another version answers UNSUPP_VERSION with its own version number.
295
+ if ((msg[0] === 0 && msg[1] >= 128) || ((msg[1] & 0x80) && msg[3] === 1)) throw pcpError(op, 1, 0, { serverVersion: msg[0] });
296
+ return ignore('version');
297
+ }
298
+ if (msg.length < 24 || msg.length > 1100 || msg.length % 4) return ignore('length');
299
+ if (!(msg[1] & 0x80) || (msg[1] & 0x7f) !== op) return ignore('opcode');
300
+ const result = msg[3], lifetime = msg.readUInt32BE(4), epoch = msg.readUInt32BE(8);
301
+ if (op === OP_MAP) {
302
+ if (msg.length >= 60) {
303
+ if (!msg.subarray(24, 36).equals(nonce)) return ignore('nonce-mismatch');
304
+ if (msg[36] !== (protocol === 'tcp' ? 6 : 17) || msg.readUInt16BE(40) !== internalPort) return ignore('mapping-mismatch');
305
+ } else if (result === 0) {
306
+ return ignore('length');
307
+ }
308
+ }
309
+ if (result !== 0) throw pcpError(op, result, lifetime, { epoch });
310
+ return op === OP_MAP ? { epoch, lifetime, externalPort: msg.readUInt16BE(42), externalAddress: readMappedV4(msg, 44) } : { epoch };
311
+ }
312
+
313
+ function encodeNatpmpMap(protocol, internalPort, externalPort, lifetime) {
314
+ const buf = Buffer.alloc(12);
315
+ buf[1] = protocol === 'udp' ? 1 : 2;
316
+ buf.writeUInt16BE(internalPort, 4);
317
+ buf.writeUInt16BE(externalPort, 6);
318
+ buf.writeUInt32BE(lifetime, 8);
319
+ return buf;
320
+ }
321
+
322
+ const natpmpError = (result, extra) => {
323
+ const name = NATPMP_RESULTS[result] ?? `NATPMP_RESULT_${result}`;
324
+ return fail(`NAT-PMP request refused: ${name}`, name, { method: 'natpmp', resultCode: result, transient: NATPMP_RETRYABLE.has(result), unavailable: result === 1 || result === 5, ...extra });
325
+ };
326
+
327
+ function decodeNatpmp(msg, op, internalPort, ignore) {
328
+ if (msg.length < 4) return ignore('short');
329
+ if (msg[0] !== 0) {
330
+ if ((msg[1] & 0x80) && msg[3] === 1) throw natpmpError(1, { serverVersion: msg[0] }); // PCP-only server (RFC 6887 §9)
331
+ return ignore('version');
332
+ }
333
+ if (msg[1] !== 128 + op) return ignore('opcode');
334
+ const result = msg.readUInt16BE(2);
335
+ if (result !== 0) throw natpmpError(result);
336
+ if (op === 0) return msg.length < 12 ? ignore('length') : { epoch: msg.readUInt32BE(4), externalAddress: [...msg.subarray(8, 12)].join('.') };
337
+ if (msg.length < 16) return ignore('length');
338
+ if (msg.readUInt16BE(8) !== internalPort) return ignore('mapping-mismatch');
339
+ return { epoch: msg.readUInt32BE(4), externalPort: msg.readUInt16BE(10), lifetime: msg.readUInt32BE(12) };
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------------------------------------------
343
+ // minimal bounded XML handling (router descriptions and SOAP bodies are capped at 64 KiB before parsing)
344
+
345
+ const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
346
+ const decodeXml = (s) => s.replace(/&(?:#x([0-9a-f]{1,6})|#(\d{1,7})|(amp|lt|gt|quot|apos));/gi, (_, hex, dec, named) => {
347
+ if (named) return XML_ENTITIES[named.toLowerCase()];
348
+ const cp = hex ? parseInt(hex, 16) : Number(dec);
349
+ return cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : '';
350
+ });
351
+ const escapeXml = (value) => String(value).replace(/[&<>"']/g, (c) => `&${{ '&': 'amp', '<': 'lt', '>': 'gt', '"': 'quot', "'": 'apos' }[c]};`);
352
+ const openTag = (tag) => `<(?:[\\w.-]{1,32}:)?${tag}(?:\\s[^<>]{0,512})?>`;
353
+ const closeTag = (tag) => `<\\/(?:[\\w.-]{1,32}:)?${tag}\\s*>`;
354
+
355
+ /** Text content of the first <tag> (any namespace prefix), entity-decoded and trimmed; null if absent. */
356
+ function xmlText(xml, tag) {
357
+ const m = new RegExp(`${openTag(tag)}([^<]{0,4096})${closeTag(tag)}`, 'i').exec(xml);
358
+ return m ? decodeXml(m[1]).trim() : null;
359
+ }
360
+
361
+ function* xmlElements(xml, tag, max = 64) {
362
+ const open = new RegExp(openTag(tag), 'gi');
363
+ const close = new RegExp(closeTag(tag), 'gi');
364
+ for (let n = 0; n < max && open.exec(xml); n++) {
365
+ close.lastIndex = open.lastIndex;
366
+ const end = close.exec(xml);
367
+ if (!end) return;
368
+ yield xml.slice(open.lastIndex, end.index);
369
+ open.lastIndex = close.lastIndex;
370
+ }
371
+ }
372
+
373
+ /** WAN connection services in an IGD description, best first (WANIPConnection:2, :1, WANPPPConnection:1). */
374
+ export function parseIgdDescription(xml, location) {
375
+ let base = location;
376
+ const urlBase = xmlText(xml, 'URLBase');
377
+ if (urlBase) try { base = new URL(urlBase).href; } catch {}
378
+ const services = [];
379
+ for (const block of xmlElements(xml, 'service')) {
380
+ const serviceType = xmlText(block, 'serviceType'), control = xmlText(block, 'controlURL');
381
+ const rank = serviceType ? WAN_SERVICES.findIndex((s) => s.toLowerCase() === serviceType.toLowerCase()) : -1;
382
+ if (rank < 0 || !control) continue;
383
+ try { services.push({ serviceType, version: rank === 0 ? 2 : 1, rank, controlURL: new URL(control, base).href }); } catch {}
384
+ }
385
+ return services.sort((a, b) => a.rank - b.rank);
386
+ }
387
+
388
+ /** { errorCode, errorDescription, faultCode, faultString } for a SOAP fault / UPnPError body, else null. */
389
+ export function parseSoapFault(xml) {
390
+ if (typeof xml !== 'string' || !/<(?:[\w.-]{1,32}:)?(?:Fault|UPnPError)[\s>]/i.test(xml)) return null;
391
+ const code = xmlText(xml, 'errorCode');
392
+ return {
393
+ errorCode: code && /^\d{1,4}$/.test(code) ? Number(code) : null,
394
+ errorDescription: xmlText(xml, 'errorDescription'),
395
+ faultCode: xmlText(xml, 'faultcode'),
396
+ faultString: xmlText(xml, 'faultstring'),
397
+ };
398
+ }
399
+
400
+ // ---------------------------------------------------------------------------------------------------------------
401
+ // the mapper
402
+
403
+ function normalizeRequest(request) {
404
+ const protocol = String(request?.protocol ?? '').toLowerCase();
405
+ if (protocol !== 'udp' && protocol !== 'tcp') throw new TypeError("map(): protocol must be 'udp' or 'tcp'");
406
+ const { internalPort } = request;
407
+ const suggestedExternalPort = request.suggestedExternalPort ?? internalPort, lifetimeSeconds = request.lifetimeSeconds ?? 7200;
408
+ if (!validPort(internalPort)) throw new TypeError('map(): internalPort must be an integer 1-65535');
409
+ if (!(suggestedExternalPort === 0 || validPort(suggestedExternalPort))) throw new TypeError('map(): suggestedExternalPort must be an integer 0-65535');
410
+ if (!Number.isInteger(lifetimeSeconds) || lifetimeSeconds < 1 || lifetimeSeconds > 0xffffffff) throw new TypeError('map(): lifetimeSeconds must be a positive integer');
411
+ const description = String(request.description ?? 'peerlane').replace(/[^\x20-\x7e]/g, '').trim().slice(0, 64) || 'peerlane';
412
+ return { protocol, internalPort, suggestedExternalPort, lifetimeSeconds, description };
413
+ }
414
+
415
+ const brief = ({ mapping: m }) => ({ method: m.method, protocol: m.protocol, internalPort: m.internalPort, externalAddress: m.externalAddress, externalPort: m.externalPort, lifetimeSeconds: m.lifetimeSeconds });
416
+
417
+ class PortMapper extends EventEmitter {
418
+ #o;
419
+ #methods;
420
+ #timeoutMs;
421
+ #ac = new AbortController(); // in-flight probe/map/renew work, aborted by close()
422
+ #teardown = new AbortController(); // deletes keep their own budget so close() can still unmap
423
+ #closed = false;
424
+ #closing = null;
425
+ #net = null;
426
+ #netPromise = null;
427
+ #active = new Map(); // `${protocol}:${internalPort}` -> record
428
+ #pending = new Map(); // same key -> in-flight map() promise
429
+ #deleting = new Set();
430
+ #unavailable = new Map(); // method -> skip-until timestamp
431
+ #igdCache = null; // { promise, value }
432
+ #epochs = new Map(); // method -> { server, client } (seconds)
433
+ #resetAt = new Map();
434
+ #nonces = new Map(); // `${protocol}:${internalPort}` -> PCP nonce, kept until a confirmed delete
435
+
436
+ constructor(options) {
437
+ super();
438
+ const o = { ...DEFAULTS };
439
+ for (const [key, value] of Object.entries(options ?? {})) if (value !== undefined) o[key] = value;
440
+ if (o.gateway !== undefined && !isV4(o.gateway)) throw new TypeError('createPortMapper(): gateway must be an IPv4 address');
441
+ if (o.localAddress !== undefined && !isV4(o.localAddress)) throw new TypeError('createPortMapper(): localAddress must be an IPv4 address');
442
+ const methods = [...new Set(Array.isArray(o.methods) ? o.methods : [o.methods])];
443
+ if (!methods.length || methods.some((m) => !METHODS.includes(m))) throw new TypeError(`createPortMapper(): methods must be a non-empty subset of ${METHODS.join(', ')}`);
444
+ if (!(Number.isFinite(o.timeoutMs) && o.timeoutMs >= 50 && o.timeoutMs <= 60_000)) throw new TypeError('createPortMapper(): timeoutMs must be 50-60000');
445
+ for (const key of ['pcpPort', 'natpmpPort', 'ssdpPort']) if (!validPort(o[key])) throw new TypeError(`createPortMapper(): ${key} must be a port number`);
446
+ if (!isV4(o.ssdpAddress)) throw new TypeError('createPortMapper(): ssdpAddress must be an IPv4 address');
447
+ if (o.log !== undefined && typeof o.log !== 'function') throw new TypeError('createPortMapper(): log must be a function');
448
+ this.#o = o;
449
+ this.#methods = methods;
450
+ this.#timeoutMs = o.timeoutMs;
451
+ }
452
+
453
+ get gateway() { return this.#net?.gateway ?? this.#o.gateway ?? null; }
454
+ get localAddress() { return this.#net?.localAddress ?? this.#o.localAddress ?? null; }
455
+ get closed() { return this.#closed; }
456
+ get mappings() { return [...this.#active.values()].map((r) => r.mapping); }
457
+
458
+ /** Read-only capability check: PCP ANNOUNCE, NAT-PMP external address, UPnP discovery + GetExternalIPAddress. */
459
+ async probe() {
460
+ this.#assertOpen();
461
+ let netInfo;
462
+ try {
463
+ netInfo = await this.#network();
464
+ } catch (err) {
465
+ this.#assertOpen();
466
+ return { gateway: this.#o.gateway ?? null, localAddress: this.#o.localAddress ?? null, available: { pcp: false, natpmp: false, upnp: false },
467
+ externalAddress: null, externalAddressIsPrivate: null, details: { error: `${err.code}: ${err.message}` } };
468
+ }
469
+ const signal = this.#ac.signal;
470
+ const check = (method, run) => (this.#methods.includes(method)
471
+ ? run().then((value) => { this.#unavailable.delete(method); return { ok: true, ...value }; },
472
+ (err) => { if (err.unavailable) this.#markUnavailable(method); return { ok: false, error: `${err.code}: ${err.message}` }; })
473
+ : Promise.resolve({ ok: false, skipped: true }));
474
+ const [pcp, natpmp, upnp] = await Promise.all([
475
+ check('pcp', async () => ({ epoch: (await this.#pcp(OP_ANNOUNCE, {}, signal)).epoch })),
476
+ check('natpmp', () => this.#natpmpExternal(signal)),
477
+ check('upnp', async () => {
478
+ const igd = await this.#getIgd(signal, true);
479
+ return { location: igd.location, serviceType: igd.serviceType, controlURL: igd.controlURL, externalAddress: igd.externalAddress };
480
+ }),
481
+ ]);
482
+ this.#assertOpen();
483
+ const externalAddress = natpmp.externalAddress ?? upnp.externalAddress ?? null;
484
+ return {
485
+ gateway: netInfo.gateway, localAddress: netInfo.localAddress,
486
+ available: { pcp: pcp.ok, natpmp: natpmp.ok, upnp: upnp.ok },
487
+ externalAddress, externalAddressIsPrivate: externalAddress ? isPrivateIPv4(externalAddress) : null,
488
+ details: { pcp, natpmp, upnp },
489
+ };
490
+ }
491
+
492
+ /** Map an inbound port; idempotent per (protocol, internalPort) while the mapping is live. */
493
+ async map(request = {}) {
494
+ this.#assertOpen();
495
+ const req = normalizeRequest(request);
496
+ const key = `${req.protocol}:${req.internalPort}`;
497
+ const live = this.#active.get(key);
498
+ if (live) return live.mapping;
499
+ if (!this.#pending.has(key)) this.#pending.set(key, this.#mapFresh(req, key).finally(() => this.#pending.delete(key)));
500
+ return this.#pending.get(key);
501
+ }
502
+
503
+ /** Best-effort delete. Resolves true when the gateway acknowledged it, false otherwise; never rejects for network reasons. */
504
+ async unmap(mapping) {
505
+ if (mapping === null || typeof mapping !== 'object') throw new TypeError('unmap(): expected a mapping returned by map()');
506
+ const record = this.#find(mapping);
507
+ if (!record) return false;
508
+ this.#active.delete(record.key);
509
+ clearTimeout(record.timer);
510
+ // tracked right away (not after the in-flight renewal) so a concurrent close() waits for this delete too
511
+ return this.#track((record.busy ?? Promise.resolve()).then(() => this.#delete(record)));
512
+ }
513
+
514
+ /** Unmaps everything best-effort, aborts in-flight work, clears timers and sockets. Idempotent. */
515
+ close() {
516
+ if (this.#closing) return this.#closing;
517
+ this.#closed = true;
518
+ this.#ac.abort();
519
+ const records = [...this.#active.values()];
520
+ this.#active.clear();
521
+ for (const record of records) clearTimeout(record.timer);
522
+ this.#closing = (async () => {
523
+ const guard = setTimeout(() => this.#teardown.abort(), this.#timeoutMs + 1000);
524
+ try {
525
+ for (const record of records) this.#track((record.busy ?? Promise.resolve()).then(() => this.#delete(record)));
526
+ while (this.#deleting.size) await Promise.allSettled([...this.#deleting]);
527
+ } finally {
528
+ clearTimeout(guard);
529
+ this.#teardown.abort();
530
+ }
531
+ this.#log('closed', { unmapped: records.length });
532
+ })();
533
+ return this.#closing;
534
+ }
535
+
536
+ // ---- map / renew / delete ----------------------------------------------------------------------------------
537
+
538
+ async #mapFresh(req, key) {
539
+ try { await this.#network(); } catch (err) { this.#assertOpen(); throw err; }
540
+ this.#assertOpen();
541
+ const signal = this.#ac.signal;
542
+ // Read-only discovery for the later methods runs in parallel, so a silently dropping first method does not
543
+ // serialize timeouts; the mapping attempts themselves stay strictly in the configured order.
544
+ const warm = new Map();
545
+ for (const method of this.#methods.slice(1)) if (!this.#isUnavailable(method)) warm.set(method, this.#discover(method, signal));
546
+ const errors = [];
547
+ for (const method of this.#methods) {
548
+ if (this.#closed) break;
549
+ if (this.#isUnavailable(method)) {
550
+ errors.push(fail(`${method} skipped: unavailable on this gateway (cached ${UNAVAILABLE_TTL_MS / 1000} s)`, 'SKIPPED', { method }));
551
+ continue;
552
+ }
553
+ try {
554
+ try { await warm.get(method); } catch (err) { if (err.unavailable || this.#closed) throw err; }
555
+ this.#log('map-attempt', { method, protocol: req.protocol, internalPort: req.internalPort, suggestedExternalPort: req.suggestedExternalPort });
556
+ const state = await this.#attempt(method, req, signal, null);
557
+ const record = this.#newRecord(method, req, key, state);
558
+ if (this.#closed) { this.#track(this.#delete(record)); break; }
559
+ this.#unavailable.delete(method);
560
+ this.#active.set(key, record);
561
+ this.#scheduleRenewal(record);
562
+ this.#log('mapped', brief(record));
563
+ return record.mapping;
564
+ } catch (err) {
565
+ if (this.#closed) break;
566
+ err.method ??= method;
567
+ errors.push(err);
568
+ if (err.unavailable) this.#markUnavailable(method);
569
+ this.#log('map-failed', { method, code: err.code, message: err.message });
570
+ }
571
+ }
572
+ if (this.#closed) throw closedError();
573
+ throw fail(`port mapping failed: ${errors.map((e) => `[${e.method}] ${e.message}`).join('; ')}`, 'MAP_FAILED', { errors, cause: errors.at(-1) });
574
+ }
575
+
576
+ #attempt(method, req, signal, record) {
577
+ if (method === 'pcp') return this.#pcpMap(req, signal, record);
578
+ if (method === 'natpmp') return this.#natpmpMap(req, signal, record);
579
+ return this.#upnpMap(req, signal, record);
580
+ }
581
+
582
+ #discover(method, signal) {
583
+ const p = method === 'pcp' ? this.#pcp(OP_ANNOUNCE, {}, signal) : method === 'natpmp' ? this.#natpmpExternal(signal) : this.#getIgd(signal);
584
+ p.catch(() => {});
585
+ return p;
586
+ }
587
+
588
+ #newRecord(method, req, key, state) {
589
+ const { gateway, localAddress } = this.#net;
590
+ const record = {
591
+ key, method, request: req, nonce: null, lease: null, timer: null, busy: null, lifetimeMs: 0, expiresAtMs: 0,
592
+ mapping: {
593
+ method, protocol: req.protocol, internalAddress: localAddress, internalPort: req.internalPort, externalAddress: null, externalPort: 0,
594
+ lifetimeSeconds: 0, expiresAt: null, externalAddressIsPrivate: null, gateway, description: req.description,
595
+ },
596
+ };
597
+ this.#applyState(record, state);
598
+ return record;
599
+ }
600
+
601
+ #applyState(record, state) {
602
+ const granted = state.lifetime; // seconds; 0 = permanent UPnP lease
603
+ record.nonce = state.nonce ?? record.nonce;
604
+ record.lease = state.lease ?? record.lease;
605
+ // permanent leases are still refreshed on the requested cadence so a router reboot is noticed and repaired
606
+ record.lifetimeMs = (granted || Math.min(record.request.lifetimeSeconds, UPNP_MAX_LEASE)) * 1000;
607
+ record.expiresAtMs = Date.now() + record.lifetimeMs;
608
+ const externalAddress = state.externalAddress ?? record.mapping.externalAddress ?? null;
609
+ Object.assign(record.mapping, {
610
+ externalAddress, externalPort: state.externalPort, lifetimeSeconds: granted, expiresAt: granted ? record.expiresAtMs : null,
611
+ externalAddressIsPrivate: externalAddress ? isPrivateIPv4(externalAddress) : null,
612
+ });
613
+ }
614
+
615
+ #scheduleRenewal(record, delay = record.lifetimeMs / 2) {
616
+ clearTimeout(record.timer);
617
+ record.timer = null;
618
+ if (this.#closed || this.#active.get(record.key) !== record) return;
619
+ record.timer = setTimeout(() => {
620
+ record.timer = null;
621
+ record.busy = this.#renew(record).catch(() => {}).finally(() => { record.busy = null; });
622
+ }, Math.min(Math.max(delay, 0), MAX_TIMER_MS));
623
+ record.timer.unref?.(); // renewals alone never keep the process alive
624
+ }
625
+
626
+ async #renew(record) {
627
+ const live = () => !this.#closed && this.#active.get(record.key) === record;
628
+ if (!live()) return;
629
+ let state;
630
+ try {
631
+ state = await this.#attempt(record.method, record.request, this.#ac.signal, record);
632
+ } catch (err) {
633
+ if (!live()) return;
634
+ const remaining = record.expiresAtMs - Date.now();
635
+ const retryIn = Math.min(remaining / 2, remaining - this.#timeoutMs - 100);
636
+ this.#log('renew-failed', { ...brief(record), code: err.code, message: err.message, transient: !!err.transient, remainingMs: Math.max(0, Math.round(remaining)) });
637
+ if (err.transient && retryIn >= MIN_RETRY_MS) return this.#scheduleRenewal(record, retryIn);
638
+ return this.#lose(record, err);
639
+ }
640
+ if (!live()) return;
641
+ const before = record.mapping.externalPort;
642
+ this.#applyState(record, state);
643
+ this.#scheduleRenewal(record);
644
+ this.#log('renewed', { ...brief(record), ...(before !== record.mapping.externalPort && { previousExternalPort: before }) });
645
+ this.#emitSafe('renewed', record.mapping);
646
+ }
647
+
648
+ #lose(record, err) {
649
+ if (this.#active.get(record.key) !== record) return;
650
+ this.#active.delete(record.key);
651
+ clearTimeout(record.timer);
652
+ this.#log('lost', { ...brief(record), code: err.code, message: err.message });
653
+ this.#emitSafe('lost', record.mapping, err);
654
+ }
655
+
656
+ async #delete(record) {
657
+ const signal = this.#teardown.signal;
658
+ try {
659
+ const { method, mapping: m } = record;
660
+ if (method === 'pcp') {
661
+ await this.#pcp(OP_MAP, { lifetime: 0, nonce: record.nonce, protocol: m.protocol, internalPort: m.internalPort, externalPort: m.externalPort, externalAddress: m.externalAddress ?? '0.0.0.0' }, signal);
662
+ this.#nonces.delete(record.key);
663
+ } else if (method === 'natpmp') {
664
+ await this.#natpmp(m.protocol === 'udp' ? 1 : 2, encodeNatpmpMap(m.protocol, m.internalPort, 0, 0), m.internalPort, signal);
665
+ } else {
666
+ const igd = await this.#getIgd(signal);
667
+ try {
668
+ await this.#soap(igd, 'DeletePortMapping', [['NewRemoteHost', ''], ['NewExternalPort', m.externalPort], ['NewProtocol', m.protocol.toUpperCase()]], signal);
669
+ } catch (err) {
670
+ if (err.upnpErrorCode !== 714) throw err; // NoSuchEntryInArray: already gone
671
+ }
672
+ }
673
+ this.#log('unmapped', brief(record));
674
+ return true;
675
+ } catch (err) {
676
+ this.#log('unmap-failed', { ...brief(record), code: err.code, message: err.message });
677
+ return false;
678
+ }
679
+ }
680
+
681
+ #track(promise) {
682
+ this.#deleting.add(promise);
683
+ promise.finally(() => this.#deleting.delete(promise)).catch(() => {});
684
+ return promise;
685
+ }
686
+
687
+ #find(m) {
688
+ const records = [...this.#active.values()];
689
+ return records.find((r) => r.mapping === m)
690
+ ?? records.find(({ mapping: x }) => x.protocol === m.protocol && x.internalPort === m.internalPort
691
+ && (m.method === undefined || m.method === x.method) && (m.externalPort === undefined || m.externalPort === x.externalPort))
692
+ ?? null;
693
+ }
694
+
695
+ // ---- PCP ---------------------------------------------------------------------------------------------------
696
+
697
+ async #pcp(op, fields, signal) {
698
+ const { gateway, localAddress } = this.#net;
699
+ const res = await udpExchange({
700
+ host: gateway, port: this.#o.pcpPort, localAddress, timeoutMs: this.#timeoutMs, signal, label: 'PCP',
701
+ payload: encodePcp(op, { ...fields, client: localAddress }),
702
+ decode: (msg) => decodePcp(msg, { op, ...fields }, (reason) => this.#log('pcp-ignored', { reason, bytes: msg.length })),
703
+ }).catch((err) => { err.method ??= 'pcp'; throw err; });
704
+ this.#noteEpoch('pcp', res.epoch);
705
+ return res;
706
+ }
707
+
708
+ async #pcpMap(req, signal, record) {
709
+ // The nonce proves ownership: refreshes reuse it, and so does a re-map after 'lost' (the gateway may still hold
710
+ // the old mapping and would answer NOT_AUTHORIZED to a new nonce until it expires).
711
+ const key = `${req.protocol}:${req.internalPort}`;
712
+ const nonce = record?.nonce ?? this.#nonces.get(key) ?? randomBytes(12);
713
+ this.#nonces.set(key, nonce);
714
+ const prev = record?.mapping;
715
+ const res = await this.#pcp(OP_MAP, {
716
+ lifetime: req.lifetimeSeconds, nonce, protocol: req.protocol, internalPort: req.internalPort,
717
+ externalPort: prev?.externalPort ?? req.suggestedExternalPort, externalAddress: prev?.externalAddress ?? '0.0.0.0',
718
+ }, signal);
719
+ if (!res.lifetime) throw fail('PCP: gateway granted a zero lifetime', 'ZERO_LIFETIME', { method: 'pcp', transient: true });
720
+ if (!validPort(res.externalPort)) throw fail(`PCP: invalid assigned external port ${res.externalPort}`, 'BAD_RESPONSE', { method: 'pcp' });
721
+ return { nonce, lifetime: res.lifetime, externalPort: res.externalPort, externalAddress: externalOrNull(res.externalAddress) };
722
+ }
723
+
724
+ // ---- NAT-PMP -----------------------------------------------------------------------------------------------
725
+
726
+ async #natpmp(op, payload, internalPort, signal) {
727
+ const { gateway, localAddress } = this.#net;
728
+ const res = await udpExchange({
729
+ host: gateway, port: this.#o.natpmpPort, localAddress, timeoutMs: this.#timeoutMs, signal, label: 'NAT-PMP', payload,
730
+ decode: (msg) => decodeNatpmp(msg, op, internalPort, (reason) => this.#log('natpmp-ignored', { reason, bytes: msg.length })),
731
+ }).catch((err) => { err.method ??= 'natpmp'; throw err; });
732
+ this.#noteEpoch('natpmp', res.epoch);
733
+ return res;
734
+ }
735
+
736
+ async #natpmpExternal(signal) {
737
+ const { externalAddress, epoch } = await this.#natpmp(0, Buffer.from([0, 0]), 0, signal);
738
+ return { externalAddress: externalOrNull(externalAddress), epoch };
739
+ }
740
+
741
+ async #natpmpMap(req, signal, record) {
742
+ const op = req.protocol === 'udp' ? 1 : 2;
743
+ const suggested = record?.mapping.externalPort ?? req.suggestedExternalPort;
744
+ const [res, ext] = await Promise.all([
745
+ this.#natpmp(op, encodeNatpmpMap(req.protocol, req.internalPort, suggested, req.lifetimeSeconds), req.internalPort, signal),
746
+ this.#natpmpExternal(signal).catch(() => null),
747
+ ]);
748
+ if (!res.lifetime) throw fail('NAT-PMP: gateway granted a zero lifetime', 'ZERO_LIFETIME', { method: 'natpmp', transient: true });
749
+ if (!validPort(res.externalPort)) throw fail(`NAT-PMP: invalid mapped external port ${res.externalPort}`, 'BAD_RESPONSE', { method: 'natpmp' });
750
+ return { lifetime: res.lifetime, externalPort: res.externalPort, externalAddress: ext?.externalAddress ?? null };
751
+ }
752
+
753
+ // Epoch sanity (RFC 6887 §8.5, RFC 6886 §3.6): a gateway whose epoch went backwards or drifted too far lost its
754
+ // mapping state (reboot), so every mapping relying on it is refreshed right away.
755
+ #noteEpoch(method, epoch) {
756
+ if (!Number.isFinite(epoch)) return;
757
+ const now = Date.now() / 1000;
758
+ const prev = this.#epochs.get(method);
759
+ this.#epochs.set(method, { server: epoch, client: now });
760
+ if (!prev) return;
761
+ const clientDelta = now - prev.client, serverDelta = epoch - prev.server;
762
+ if (epoch + 1 >= prev.server && serverDelta + 2 >= clientDelta - clientDelta / 16 && clientDelta + 2 >= serverDelta - serverDelta / 16) return;
763
+ this.#log('epoch-reset', { method, previous: prev.server, current: epoch });
764
+ if (now - (this.#resetAt.get(method) ?? -Infinity) < 5) return;
765
+ this.#resetAt.set(method, now);
766
+ for (const record of this.#active.values()) if (record.method === method && !record.busy) this.#scheduleRenewal(record, 0);
767
+ }
768
+
769
+ // ---- UPnP IGD ----------------------------------------------------------------------------------------------
770
+
771
+ #getIgd(signal, fresh = false) {
772
+ if (!fresh && this.#igdCache) return this.#igdCache.promise;
773
+ const entry = { promise: null, value: null };
774
+ entry.promise = this.#discoverIgd(signal).then(
775
+ (igd) => (entry.value = igd),
776
+ (err) => { if (this.#igdCache === entry) this.#igdCache = null; throw err; });
777
+ entry.promise.catch(() => {});
778
+ this.#igdCache = entry;
779
+ return entry.promise;
780
+ }
781
+
782
+ #forgetIgd(igd) {
783
+ if (this.#igdCache?.value === igd) this.#igdCache = null;
784
+ }
785
+
786
+ async #discoverIgd(signal) {
787
+ const candidates = await this.#ssdpSearch(signal);
788
+ if (!candidates.length) throw fail('UPnP: no Internet Gateway Device answered the SSDP search', 'NO_IGD', { method: 'upnp', transient: true, unavailable: true });
789
+ const errors = [];
790
+ let fallback = null;
791
+ for (const { location } of candidates.slice(0, 4)) {
792
+ try {
793
+ const res = await httpExchange(new URL(location), { timeoutMs: this.#timeoutMs, signal, localAddress: this.#net.localAddress, headers: { Accept: 'text/xml, application/xml' } });
794
+ if (res.status !== 200) throw fail(`UPnP: ${location} answered HTTP ${res.status}`, 'HTTP_STATUS', { method: 'upnp' });
795
+ for (const service of parseIgdDescription(res.body, location)) {
796
+ if (!this.#lanUrl(service.controlURL)) {
797
+ this.#log('upnp-control-rejected', { location, controlURL: service.controlURL, reason: 'not on the local subnet' });
798
+ continue;
799
+ }
800
+ const igd = { ...service, location, externalAddress: null, noAddAny: false };
801
+ try {
802
+ igd.externalAddress = externalOrNull(xmlText(await this.#soap(igd, 'GetExternalIPAddress', [], signal), 'NewExternalIPAddress'));
803
+ } catch (err) {
804
+ if (err.code === 'ABORTED') throw err;
805
+ errors.push(err);
806
+ }
807
+ if (igd.externalAddress) {
808
+ this.#log('upnp-igd', { location, serviceType: igd.serviceType, controlURL: igd.controlURL, externalAddress: igd.externalAddress });
809
+ return igd;
810
+ }
811
+ fallback ??= igd; // e.g. WAN reported down or GetExternalIPAddress unsupported; mapping may still work
812
+ }
813
+ } catch (err) {
814
+ if (err.code === 'ABORTED') throw err;
815
+ errors.push(err);
816
+ this.#log('upnp-description-failed', { location, code: err.code, message: err.message });
817
+ }
818
+ }
819
+ if (fallback) {
820
+ this.#log('upnp-igd', { location: fallback.location, serviceType: fallback.serviceType, controlURL: fallback.controlURL, externalAddress: null });
821
+ return fallback;
822
+ }
823
+ throw fail('UPnP: no usable WANIPConnection/WANPPPConnection service found', 'NO_IGD', { method: 'upnp', transient: true, unavailable: true, errors });
824
+ }
825
+
826
+ #ssdpSearch(signal) {
827
+ const { localAddress } = this.#net;
828
+ const { ssdpAddress: host, ssdpPort: port } = this.#o;
829
+ const mx = Math.max(1, Math.min(5, Math.floor(this.#timeoutMs / 2000)));
830
+ const search = (st) => Buffer.from(`M-SEARCH * HTTP/1.1\r\nHOST: ${host}:${port}\r\nMAN: "ssdp:discover"\r\nMX: ${mx}\r\nST: ${st}\r\n\r\n`, 'latin1');
831
+ return new Promise((resolve, reject) => {
832
+ const found = new Map(), timers = [];
833
+ let sock = null, done = false, bound = false;
834
+ const finish = (err) => {
835
+ if (done) return;
836
+ done = true;
837
+ timers.forEach(clearTimeout);
838
+ signal?.removeEventListener('abort', onAbort);
839
+ try { sock?.close(); } catch {}
840
+ if (err) reject(err); else resolve([...found.values()].sort((a, b) => b.score - a.score));
841
+ };
842
+ const onAbort = () => finish(aborted());
843
+ if (signal?.aborted) return onAbort();
844
+ signal?.addEventListener('abort', onAbort, { once: true });
845
+ const blast = (targets) => {
846
+ for (const st of targets) {
847
+ if (done) return;
848
+ try { sock.send(search(st), port, host, (err) => err && this.#log('ssdp-send-failed', { st, code: err.code })); } catch {}
849
+ }
850
+ };
851
+ timers.push(setTimeout(() => finish(), this.#timeoutMs));
852
+ try {
853
+ sock = dgram.createSocket('udp4');
854
+ sock.on('error', (err) => {
855
+ this.#log('ssdp-error', { code: err.code, message: err.message });
856
+ if (!bound) finish(fail(`UPnP: SSDP socket error ${err.code ?? err.message}`, 'SOCKET_ERROR', { method: 'upnp', transient: true, unavailable: true, cause: err }));
857
+ });
858
+ sock.on('message', (msg, rinfo) => {
859
+ if (done) return;
860
+ let candidate = null;
861
+ try { candidate = this.#ssdpCandidate(msg, rinfo); } catch {}
862
+ if (!candidate || found.has(candidate.location)) return;
863
+ found.set(candidate.location, candidate);
864
+ if (found.size === 1) timers.push(setTimeout(() => finish(), 200)); // short grace: let IGD:2 / gateway answers land
865
+ if (found.size >= 8) finish();
866
+ });
867
+ sock.bind({ address: localAddress, port: 0 }, () => {
868
+ if (done) return;
869
+ bound = true;
870
+ if (inCidr(host, '224.0.0.0', 4)) {
871
+ try { sock.setMulticastInterface(localAddress); sock.setMulticastTTL(2); } catch (err) { this.#log('ssdp-error', { code: err.code, message: err.message }); }
872
+ }
873
+ blast(IGD_TARGETS);
874
+ // UDP is lossy: repeat, adding a filtered ssdp:all for gateways that only answer generic searches
875
+ timers.push(setTimeout(() => { if (!found.size) blast([...IGD_TARGETS, 'ssdp:all']); }, Math.floor(this.#timeoutMs / 3)));
876
+ });
877
+ } catch (err) {
878
+ finish(fail(`UPnP: SSDP socket error ${err.code ?? err.message}`, 'SOCKET_ERROR', { method: 'upnp', transient: true, unavailable: true, cause: err }));
879
+ }
880
+ });
881
+ }
882
+
883
+ #ssdpCandidate(msg, rinfo) {
884
+ if (msg.length > SSDP_LIMIT) return null;
885
+ const lines = msg.toString('latin1').split(/\r?\n/, 32);
886
+ if (!/^HTTP\/1\.[01]\s+200\b/i.test(lines[0] ?? '')) return null;
887
+ const headers = {};
888
+ for (const line of lines.slice(1)) {
889
+ const i = line.indexOf(':');
890
+ if (i > 0) headers[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim();
891
+ }
892
+ const { gateway } = this.#net;
893
+ const st = headers.st ?? '', usn = headers.usn ?? '';
894
+ if (!IGD_HINT.test(st) && !IGD_HINT.test(usn) && !(rinfo.address === gateway && /^upnp:rootdevice$/i.test(st))) return null; // ssdp:all noise
895
+ if (!this.#isLanHost(rinfo.address)) {
896
+ this.#log('ssdp-ignored', { from: rinfo.address, reason: 'sender not on the local subnet' });
897
+ return null;
898
+ }
899
+ const url = headers.location ? this.#lanUrl(headers.location) : null;
900
+ if (!url) {
901
+ this.#log('upnp-location-rejected', { from: rinfo.address, location: headers.location ?? null, reason: 'LOCATION must be http:// on the gateway or the local subnet' });
902
+ return null;
903
+ }
904
+ const score = (url.hostname === gateway ? 4 : 0) + (url.hostname === rinfo.address ? 2 : 0) + (/InternetGatewayDevice:2/i.test(st) ? 1 : 0);
905
+ return { location: url.href, st, from: rinfo.address, score };
906
+ }
907
+
908
+ // SSRF guard: only plain-http URLs whose host is an IPv4 literal on the gateway or the local subnet.
909
+ #lanUrl(href) {
910
+ let url;
911
+ try { url = new URL(href); } catch { return null; }
912
+ return url.protocol === 'http:' && !url.username && !url.password && this.#isLanHost(url.hostname) ? url : null;
913
+ }
914
+
915
+ #isLanHost(ip) {
916
+ if (!isV4(ip) || !this.#net) return false;
917
+ const { gateway, localAddress, prefix } = this.#net;
918
+ if (ip === gateway) return true;
919
+ if (ip === '0.0.0.0' || inCidr(ip, '224.0.0.0', 3)) return false;
920
+ return prefix >= 8 && inCidr(ip, localAddress, prefix);
921
+ }
922
+
923
+ async #soap(igd, action, args, signal) {
924
+ const url = this.#lanUrl(igd.controlURL);
925
+ if (!url) throw fail(`UPnP: refusing control URL ${igd.controlURL} outside the local subnet`, 'UNSAFE_URL', { method: 'upnp' });
926
+ const body = '<?xml version="1.0"?>\r\n<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>'
927
+ + `<u:${action} xmlns:u="${escapeXml(igd.serviceType)}">${args.map(([name, value]) => `<${name}>${escapeXml(value)}</${name}>`).join('')}</u:${action}>`
928
+ + '</s:Body></s:Envelope>\r\n';
929
+ let res;
930
+ try {
931
+ res = await httpExchange(url, {
932
+ method: 'POST', body, signal, timeoutMs: this.#timeoutMs, localAddress: this.#net.localAddress,
933
+ headers: { 'Content-Type': 'text/xml; charset="utf-8"', SOAPAction: `"${igd.serviceType}#${action}"` },
934
+ });
935
+ } catch (err) {
936
+ if (err.code !== 'ABORTED') this.#forgetIgd(igd); // rebooted router or new HTTP port: rediscover next time
937
+ err.method ??= 'upnp';
938
+ throw err;
939
+ }
940
+ const fault = parseSoapFault(res.body);
941
+ if (!fault && res.status === 200) return res.body;
942
+ const code = fault?.errorCode ?? null;
943
+ const description = fault?.errorDescription ?? UPNP_ERRORS[code] ?? null;
944
+ this.#log('upnp-soap-fault', { action, httpStatus: res.status, upnpErrorCode: code, upnpErrorDescription: description });
945
+ throw fail(`UPnP ${action} failed: ${code ? `${code} ${description ?? ''}`.trim() : `HTTP ${res.status}`}`, code ? 'UPNP_ERROR' : 'HTTP_STATUS', {
946
+ method: 'upnp', action, httpStatus: res.status, upnpErrorCode: code, upnpErrorDescription: description,
947
+ transient: code === 501 || (!code && res.status >= 500),
948
+ });
949
+ }
950
+
951
+ async #upnpOwns(igd, protocol, externalPort, internalPort, signal) {
952
+ try {
953
+ const body = await this.#soap(igd, 'GetSpecificPortMappingEntry', [['NewRemoteHost', ''], ['NewExternalPort', externalPort], ['NewProtocol', protocol]], signal);
954
+ return xmlText(body, 'NewInternalClient') === this.#net.localAddress && Number(xmlText(body, 'NewInternalPort')) === internalPort;
955
+ } catch {
956
+ return false;
957
+ }
958
+ }
959
+
960
+ async #upnpMap(req, signal, record) {
961
+ const igd = await this.#getIgd(signal);
962
+ const protocol = req.protocol.toUpperCase();
963
+ const { internalPort } = req;
964
+ let lease = record ? record.lease : Math.min(req.lifetimeSeconds, UPNP_MAX_LEASE);
965
+ let port = record?.mapping.externalPort ?? (req.suggestedExternalPort || internalPort);
966
+ const add = (action, externalPort, leaseSeconds) => this.#soap(igd, action, [
967
+ ['NewRemoteHost', ''], ['NewExternalPort', externalPort], ['NewProtocol', protocol], ['NewInternalPort', internalPort],
968
+ ['NewInternalClient', this.#net.localAddress], ['NewEnabled', 1], ['NewPortMappingDescription', req.description], ['NewLeaseDuration', leaseSeconds],
969
+ ], signal);
970
+ let mapped = null;
971
+ if (!record && igd.version >= 2 && !igd.noAddAny) {
972
+ try {
973
+ const reserved = Number(xmlText(await add('AddAnyPortMapping', port, lease), 'NewReservedPort'));
974
+ if (validPort(reserved)) mapped = reserved;
975
+ else igd.noAddAny = true;
976
+ } catch (err) {
977
+ if (!err.upnpErrorCode || [606, 728, 729].includes(err.upnpErrorCode)) throw err;
978
+ igd.noAddAny = true; // non-conforming IGDv2: fall back to AddPortMapping
979
+ this.#log('upnp-addany-unsupported', { upnpErrorCode: err.upnpErrorCode });
980
+ }
981
+ }
982
+ const tried = new Set();
983
+ for (let conflicts = 0, leaseRetried = false, sameTried = false, reclaimed = false; mapped === null;) {
984
+ tried.add(port);
985
+ try {
986
+ await add('AddPortMapping', port, lease);
987
+ mapped = port;
988
+ } catch (err) {
989
+ const code = err.upnpErrorCode;
990
+ if (code === 725 && lease !== 0 && !leaseRetried) { // OnlyPermanentLeasesSupported
991
+ leaseRetried = true;
992
+ lease = 0;
993
+ this.#log('upnp-permanent-lease', { protocol, externalPort: port });
994
+ continue;
995
+ }
996
+ if (code === 724 && port !== internalPort && !sameTried) { sameTried = true; port = internalPort; continue; } // SamePortValuesRequired
997
+ if (code === 718) { // ConflictInMappingEntry
998
+ if (!reclaimed && await this.#upnpOwns(igd, protocol, port, internalPort, signal)) {
999
+ // ours already (stale entry, or firmware that refuses refreshes): replace it
1000
+ reclaimed = true;
1001
+ await this.#soap(igd, 'DeletePortMapping', [['NewRemoteHost', ''], ['NewExternalPort', port], ['NewProtocol', protocol]], signal).catch(() => {});
1002
+ continue;
1003
+ }
1004
+ if (!record && ++conflicts < CONFLICT_ATTEMPTS) {
1005
+ do port = randomInt(1024, 65536); while (tried.has(port));
1006
+ this.#log('upnp-conflict', { protocol, retryExternalPort: port, attempt: conflicts });
1007
+ continue;
1008
+ }
1009
+ }
1010
+ throw err;
1011
+ }
1012
+ }
1013
+ const externalAddress = await this.#soap(igd, 'GetExternalIPAddress', [], signal)
1014
+ .then((body) => externalOrNull(xmlText(body, 'NewExternalIPAddress')), () => null);
1015
+ if (externalAddress) igd.externalAddress = externalAddress;
1016
+ return { lease, lifetime: lease, externalPort: mapped, externalAddress: externalAddress ?? igd.externalAddress ?? null };
1017
+ }
1018
+
1019
+ // ---- plumbing ----------------------------------------------------------------------------------------------
1020
+
1021
+ #network() {
1022
+ if (this.#net) return Promise.resolve(this.#net);
1023
+ this.#netPromise ??= this.#detectNetwork().then((info) => (this.#net = info), (err) => { this.#netPromise = null; throw err; });
1024
+ return this.#netPromise;
1025
+ }
1026
+
1027
+ async #detectNetwork() {
1028
+ let { gateway, localAddress } = this.#o, hint = null;
1029
+ if (!gateway) {
1030
+ const found = await detectGateway({ timeoutMs: this.#timeoutMs, signal: this.#ac.signal });
1031
+ if (!found) throw fail('could not detect the default IPv4 gateway', 'NO_GATEWAY', { transient: true, unavailable: true });
1032
+ ({ gateway, interface: hint } = found);
1033
+ }
1034
+ localAddress ??= localAddressFor(gateway, hint) ?? (await routedLocalAddress(gateway));
1035
+ if (!localAddress) throw fail(`no local IPv4 address on the subnet of gateway ${gateway}`, 'NO_LOCAL_ADDRESS', { transient: true, unavailable: true });
1036
+ const info = { gateway, localAddress, prefix: prefixFor(localAddress) };
1037
+ this.#log('network', info);
1038
+ return info;
1039
+ }
1040
+
1041
+ #isUnavailable(method) { return (this.#unavailable.get(method) ?? 0) > Date.now(); }
1042
+ #markUnavailable(method) { this.#unavailable.set(method, Date.now() + UNAVAILABLE_TTL_MS); }
1043
+ #assertOpen() { if (this.#closed) throw closedError(); }
1044
+
1045
+ #log(event, details = {}) {
1046
+ const log = this.#o.log;
1047
+ if (log) try { log(event, details); } catch {}
1048
+ }
1049
+
1050
+ #emitSafe(event, ...args) {
1051
+ try { this.emit(event, ...args); } catch (err) { this.#log('listener-error', { event, message: err?.message }); }
1052
+ }
1053
+ }
1054
+
1055
+ /** Create a port mapper (an EventEmitter emitting 'renewed'(mapping) and 'lost'(mapping, error)). */
1056
+ export async function createPortMapper(options = {}) {
1057
+ return new PortMapper(options);
1058
+ }