@iicp/client 0.5.5 → 0.5.7

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.
@@ -28,10 +28,14 @@
28
28
  Object.defineProperty(exports, "__esModule", { value: true });
29
29
  exports.detectNat = detectNat;
30
30
  exports.detectIpv6 = detectIpv6;
31
+ exports.rankedGlobalIpv6Candidates = rankedGlobalIpv6Candidates;
31
32
  exports.tryUpnpMapping = tryUpnpMapping;
32
33
  exports.probeExternalIp = probeExternalIp;
33
34
  exports.looksRoutable = looksRoutable;
34
35
  exports.detectCgnat = detectCgnat;
36
+ exports.tryUpnpIpv6Pinhole = tryUpnpIpv6Pinhole;
37
+ exports.tryOpenV6PinholeForEndpoint = tryOpenV6PinholeForEndpoint;
38
+ exports.deleteIpv6Pinhole = deleteIpv6Pinhole;
35
39
  const dns = require("node:dns");
36
40
  const net = require("node:net");
37
41
  function newProfile(tier, method) {
@@ -73,6 +77,18 @@ async function detectNat(opts) {
73
77
  }
74
78
  profile.detectionLog.push(`tier-0: operator-configured public_endpoint=${JSON.stringify(operatorPublicEndpoint)} non-routable — falling through to tier-1 UPnP`);
75
79
  }
80
+ // Tier 0 auto-detect — cloud VM with a public IPv4 directly on a local interface.
81
+ const autoV4 = detectPublicV4OnInterfaces();
82
+ if (autoV4) {
83
+ const autoUrl = `http://${autoV4}:${bindPort}`;
84
+ profile.detectionLog.push(`tier-0: auto-detected public IPv4 on local interface → ${JSON.stringify(autoUrl)}`);
85
+ const t0 = newProfile(0, "direct");
86
+ t0.publicEndpoint = autoUrl;
87
+ t0.internalEndpoint = profile.internalEndpoint;
88
+ t0.detectionLog = profile.detectionLog;
89
+ t0.ipv6 = profile.ipv6;
90
+ return t0;
91
+ }
76
92
  // Tier 1 — UPnP
77
93
  const portsToMap = [bindPort];
78
94
  if (transportPort && transportPort !== bindPort)
@@ -121,7 +137,7 @@ async function detectNat(opts) {
121
137
  if (cgnatWarning) {
122
138
  profile.detectionLog.push(`tier-1: ${cgnatWarning}`);
123
139
  // ADR-043 §10 — CGNAT IPv4 unreachable, but advertise IPv6 GUA if usable.
124
- const v6 = tryIpv6Fallback(profile, bindPort, transportPort);
140
+ const v6 = await tryIpv6Fallback(profile, bindPort, transportPort);
125
141
  if (v6)
126
142
  return v6;
127
143
  profile.operatorGuidance =
@@ -132,14 +148,20 @@ async function detectNat(opts) {
132
148
  `funnel), (c) switch to IPv6 if your network supports it.`;
133
149
  return profile; // tier 4
134
150
  }
135
- const publicUrl = `http://${upnp.externalIp}:${bindPort}`;
151
+ // ADR-041 §3 — advertise the EXTERNAL ports the IGD actually assigned.
152
+ // With AddAnyPortMapping fallback the external can differ from the
153
+ // internal (typical when another LAN host owns 9484 already).
154
+ const extBind = upnp.portMapping?.[bindPort] ?? bindPort;
155
+ const publicUrl = `http://${upnp.externalIp}:${extBind}`;
136
156
  let transportUrl;
137
157
  if (transportPort && upnp.mappedPorts.includes(transportPort) && transportPort !== bindPort) {
138
- transportUrl = `iicp://${upnp.externalIp}:${transportPort}`;
139
- profile.detectionLog.push(`tier-1: UPnP mapped ${bindPort} → ${publicUrl} AND ${transportPort} → ${transportUrl} (spec v0.7.0 dual-endpoint)`);
158
+ const extTransport = upnp.portMapping?.[transportPort] ?? transportPort;
159
+ transportUrl = `iicp://${upnp.externalIp}:${extTransport}`;
160
+ profile.detectionLog.push(`tier-1: UPnP mapped ${bindPort}→${extBind} (${publicUrl}) AND ${transportPort}→${extTransport} (${transportUrl}) ` +
161
+ `(spec v0.7.0 dual-endpoint; AddAnyPortMapping used if ext≠internal)`);
140
162
  }
141
163
  else {
142
- profile.detectionLog.push(`tier-1: UPnP mapped ${bindPort} ${publicUrl}`);
164
+ profile.detectionLog.push(`tier-1: UPnP mapped ${bindPort}→${extBind} (${publicUrl})`);
143
165
  }
144
166
  const result = newProfile(1, "upnp_mapped");
145
167
  result.publicEndpoint = publicUrl;
@@ -159,17 +181,111 @@ async function detectNat(opts) {
159
181
  profile.detectionLog.push(`tier-1: IGD found (${upnp.igdDevice}) but mapping refused — ${upnp.error}`);
160
182
  }
161
183
  // ADR-043 §10 — IPv6 fallback when no v4 path is usable.
162
- const v6 = tryIpv6Fallback(profile, bindPort, transportPort);
184
+ const v6 = await tryIpv6Fallback(profile, bindPort, transportPort);
163
185
  if (v6)
164
186
  return v6;
187
+ // Tier 4 external tunnel auto-detect: ngrok, tailscale funnel, env-var override.
188
+ const tunnelUrl = await detectExternalTunnel(bindPort, Math.min(timeoutMs, 3000));
189
+ if (tunnelUrl) {
190
+ profile.detectionLog.push(`tier-4: external tunnel auto-detected → ${JSON.stringify(tunnelUrl)}`);
191
+ const t4 = newProfile(1, "external_tunnel");
192
+ t4.publicEndpoint = tunnelUrl;
193
+ t4.internalEndpoint = profile.internalEndpoint;
194
+ t4.detectionLog = profile.detectionLog;
195
+ return t4;
196
+ }
165
197
  profile.operatorGuidance =
166
198
  "No automatic port mapping available. Options:\n" +
167
199
  " 1. Configure your router to forward an external port to this host\n" +
168
200
  " 2. Set publicEndpoint to your real external URL\n" +
169
- " 3. Use an external tunnel (Cloudflare Tunnel, ngrok, tailscale funnel)\n" +
201
+ " 3. Run `ngrok http <port>` or `cloudflared tunnel --url http://localhost:<port>` " +
202
+ "and export IICP_TUNNEL_URL=<the https URL>\n" +
170
203
  "See iicp.network/docs/nat-aware-adapter-setup.md for the details.";
171
204
  return profile;
172
205
  }
206
+ // ── External tunnel auto-detect ──────────────────────────────────────────────
207
+ /**
208
+ * Detect a running external tunnel daemon and return its public HTTPS URL.
209
+ *
210
+ * Checks in order:
211
+ * 1. IICP_TUNNEL_URL / TUNNEL_URL / CLOUDFLARE_TUNNEL_URL env vars
212
+ * 2. ngrok — local REST API at http://127.0.0.1:4040/api/tunnels
213
+ * 3. tailscale funnel — `tailscale serve status --json` CLI
214
+ */
215
+ async function detectExternalTunnel(bindPort, timeoutMs = 3000) {
216
+ // env-var override (cloudflared Quick Tunnels, any custom setup)
217
+ for (const envVar of ["IICP_TUNNEL_URL", "TUNNEL_URL", "CLOUDFLARE_TUNNEL_URL"]) {
218
+ const url = process.env[envVar] ?? "";
219
+ if (url.startsWith("https://"))
220
+ return url;
221
+ }
222
+ const ngrok = await detectNgrokTunnel(bindPort, timeoutMs);
223
+ if (ngrok)
224
+ return ngrok;
225
+ return detectTailscaleFunnel(bindPort, timeoutMs);
226
+ }
227
+ async function detectNgrokTunnel(bindPort, timeoutMs = 3000) {
228
+ const ctrl = new AbortController();
229
+ const t = setTimeout(() => ctrl.abort(), Math.min(timeoutMs, 2000));
230
+ try {
231
+ const resp = await fetch("http://127.0.0.1:4040/api/tunnels", { signal: ctrl.signal });
232
+ if (!resp.ok)
233
+ return null;
234
+ const data = (await resp.json());
235
+ let firstHttps = null;
236
+ for (const tunnel of data.tunnels ?? []) {
237
+ const pubUrl = tunnel.public_url ?? "";
238
+ if (!pubUrl.startsWith("https://"))
239
+ continue;
240
+ const cfgAddr = tunnel.config?.addr ?? "";
241
+ if (cfgAddr.includes(`:${bindPort}`))
242
+ return pubUrl;
243
+ if (!firstHttps)
244
+ firstHttps = pubUrl;
245
+ }
246
+ return firstHttps;
247
+ }
248
+ catch {
249
+ return null;
250
+ }
251
+ finally {
252
+ clearTimeout(t);
253
+ }
254
+ }
255
+ async function detectTailscaleFunnel(bindPort, timeoutMs = 3000) {
256
+ const { execFile } = await import("node:child_process").catch(() => ({ execFile: null }));
257
+ if (!execFile)
258
+ return null;
259
+ const run = (cmd, args) => new Promise((resolve, reject) => {
260
+ const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
261
+ execFile(cmd, args, (err, stdout) => {
262
+ clearTimeout(timer);
263
+ if (err)
264
+ reject(err);
265
+ else
266
+ resolve(stdout);
267
+ });
268
+ });
269
+ try {
270
+ const statusJson = await run("tailscale", ["status", "--json"]);
271
+ const status = JSON.parse(statusJson);
272
+ const dnsName = (status.Self?.DNSName ?? "").replace(/\.$/, "");
273
+ if (!dnsName)
274
+ return null;
275
+ const serveJson = await run("tailscale", ["serve", "status", "--json"]);
276
+ const serve = JSON.parse(serveJson);
277
+ for (const [src, cfg] of Object.entries(serve.TCP ?? {})) {
278
+ if (cfg.Funnel && src.includes(`:${bindPort}`)) {
279
+ const portSuffix = bindPort !== 443 && bindPort !== 80 ? `:${bindPort}` : "";
280
+ return `https://${dnsName}${portSuffix}`;
281
+ }
282
+ }
283
+ }
284
+ catch {
285
+ /* not running or not installed */
286
+ }
287
+ return null;
288
+ }
173
289
  /**
174
290
  * ADR-043 §4 — IPv6 qualification probe (#342).
175
291
  *
@@ -256,6 +372,56 @@ function listGlobalIpv6Addresses() {
256
372
  }
257
373
  return Array.from(new Set(out)).sort();
258
374
  }
375
+ /**
376
+ * Enumerate local GUAs ranked by likelihood-of-being-pinhole-accepted.
377
+ *
378
+ * macOS only has the "secured" vs "temporary" vs "deprecated" flag distinction
379
+ * via `ifconfig` (node:os doesn't expose it). On Linux we fall back to first-
380
+ * GUA-per-interface. AVM FRITZ!Box only authorises AddPinhole for the
381
+ * **current temporary** RFC 4941 address, so on Mac we put those first.
382
+ */
383
+ function rankedGlobalIpv6Candidates() {
384
+ const platform = process.platform;
385
+ if (platform === "darwin") {
386
+ try {
387
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
388
+ const { execSync } = require("node:child_process");
389
+ const out = execSync("ifconfig", { encoding: "utf-8" });
390
+ const currentTemp = [];
391
+ const secured = [];
392
+ const other = [];
393
+ for (const raw of out.split("\n")) {
394
+ const line = raw.trim();
395
+ if (!line.startsWith("inet6 "))
396
+ continue;
397
+ const parts = line.split(/\s+/);
398
+ if (parts.length < 2)
399
+ continue;
400
+ const addr = (parts[1] ?? "").split("%")[0];
401
+ if (!addr)
402
+ continue;
403
+ const first = parseInt(addr.split(":")[0] ?? "0", 16);
404
+ if ((first & 0xe000) !== 0x2000)
405
+ continue; // GUA only
406
+ const flags = parts.slice(2).join(" ").toLowerCase();
407
+ if (flags.includes("deprecated"))
408
+ other.push(addr);
409
+ else if (flags.includes("secured"))
410
+ secured.push(addr);
411
+ else if (flags.includes("temporary") || flags.includes("autoconf"))
412
+ currentTemp.push(addr);
413
+ else
414
+ other.push(addr);
415
+ }
416
+ const ranked = [...currentTemp, ...secured, ...other];
417
+ return Array.from(new Set(ranked));
418
+ }
419
+ catch {
420
+ return listGlobalIpv6Addresses();
421
+ }
422
+ }
423
+ return listGlobalIpv6Addresses();
424
+ }
259
425
  function isPrivacyV6(addr) {
260
426
  // Heuristic — EUI-64 places `ff:fe` in the middle 16 bits of the interface ID.
261
427
  // RFC 4941 privacy addresses use a random interface ID without this marker.
@@ -266,7 +432,7 @@ function isPrivacyV6(addr) {
266
432
  // Crude: privacy addresses don't have 'ff:fe' anywhere in the last 4 hextets
267
433
  return !addr.toLowerCase().includes("ff:fe");
268
434
  }
269
- function tryIpv6Fallback(profile, bindPort, transportPort) {
435
+ async function tryIpv6Fallback(profile, bindPort, transportPort) {
270
436
  if (!profile.ipv6)
271
437
  return null;
272
438
  if (!profile.ipv6.globalV6Available || !profile.ipv6.externalV6Reachable)
@@ -277,17 +443,39 @@ function tryIpv6Fallback(profile, bindPort, transportPort) {
277
443
  if (transportPort && transportPort !== bindPort) {
278
444
  transportUrl = `iicp://[${v6Addr}]:${transportPort}`;
279
445
  }
280
- profile.detectionLog.push(`tier-1-ipv6: advertising ${publicUrl} (verified outbound v6; router firewall pinhole still required covered by #343)`);
446
+ profile.detectionLog.push(`tier-1-ipv6: advertising ${publicUrl} (verified outbound v6; attempting UPnP IGDv2 pinhole — #343)`);
447
+ // ADR-043 §5 / #343 — attempt UPnP IPv6 firewall pinhole.
448
+ try {
449
+ const pin = await tryUpnpIpv6Pinhole(v6Addr, bindPort, { leaseSeconds: 3600 });
450
+ if (pin) {
451
+ profile.ipv6.pinholeActive = true;
452
+ profile.ipv6.pinholeUniqueId = pin.uniqueId;
453
+ profile.ipv6.pinholeLeaseSeconds = pin.leaseSeconds;
454
+ profile.ipv6.pinholeInboundAllowed = pin.inboundAllowed;
455
+ profile.detectionLog.push(`tier-1-ipv6: AddPinhole OK — uid=${pin.uniqueId} lease=${pin.leaseSeconds}s`);
456
+ }
457
+ else {
458
+ profile.ipv6.pinholeActive = false;
459
+ profile.detectionLog.push(`tier-1-ipv6: AddPinhole declined or no WANIPv6FirewallControl IGD found — operator must open inbound TCP/${bindPort} manually`);
460
+ }
461
+ }
462
+ catch (exc) {
463
+ profile.ipv6.pinholeActive = false;
464
+ const msg = exc instanceof Error ? exc.message : String(exc);
465
+ profile.detectionLog.push(`tier-1-ipv6: pinhole attempt error: ${msg}`);
466
+ }
281
467
  const result = newProfile(1, "direct");
282
468
  result.publicEndpoint = publicUrl;
283
469
  result.transportEndpoint = transportUrl;
284
470
  result.internalEndpoint = profile.internalEndpoint;
285
471
  result.detectionLog = profile.detectionLog;
286
472
  result.ipv6 = profile.ipv6;
473
+ const pinholeNote = profile.ipv6.pinholeActive
474
+ ? `UPnP IPv6 pinhole opened (uid=${profile.ipv6.pinholeUniqueId}). `
475
+ : `Router firewall pinhole not opened — manual rule may be required. `;
287
476
  result.operatorGuidance =
288
477
  `Advertising IPv6 GUA ${v6Addr}. Inbound IPv4 isn't available (no UPnP success / CGNAT), ` +
289
- `but your IPv6 surface is routable. For external clients to reach this node over IPv6, ` +
290
- `ensure your router's firewall allows inbound TCP on port ${bindPort} → ${v6Addr}. ` +
478
+ `but your IPv6 surface is routable. ${pinholeNote}` +
291
479
  `The directory will Layer-2 dial-back to verify.`;
292
480
  return result;
293
481
  }
@@ -326,48 +514,185 @@ async function tryUpnpMapping(internalPorts, leaseSeconds) {
326
514
  client.close();
327
515
  return { success: false, mappedPorts: [], error: `externalIp failed: ${msg}` };
328
516
  }
329
- try {
330
- await client.portMapping({
331
- public: primary,
332
- private: primary,
333
- ttl: leaseSeconds,
334
- description: `iicp-client (ADR-041 tier-1) ${primary}`,
335
- protocol: "tcp",
336
- });
517
+ // Map a single port — try 1:1 portMapping first, fall back to a raw-SOAP
518
+ // AddAnyPortMapping call (IGDv2 §2.5.13) when the IGD reports conflict.
519
+ // nat-upnp doesn't expose AddAnyPortMapping directly, so we hit the
520
+ // WANIPConnection service via the same SSDP+SOAP plumbing the v6 pinhole
521
+ // path uses.
522
+ async function mapOne(internal) {
523
+ const desc = `iicp-client (ADR-041 tier-1) ${internal}`;
524
+ try {
525
+ await client.portMapping({
526
+ public: internal,
527
+ private: internal,
528
+ ttl: leaseSeconds,
529
+ description: desc,
530
+ protocol: "tcp",
531
+ });
532
+ return internal;
533
+ }
534
+ catch (exc) {
535
+ const msg = exc instanceof Error ? exc.message : String(exc);
536
+ // eslint-disable-next-line no-console
537
+ console.warn(`UPnP: portMapping ${internal} failed (${msg}); trying AddAnyPortMapping`);
538
+ }
539
+ const assigned = await tryAddAnyPortMapping(internal, desc, leaseSeconds);
540
+ if (assigned !== null) {
541
+ // eslint-disable-next-line no-console
542
+ console.log(`UPnP: AddAnyPortMapping assigned external ${assigned} for internal ${internal}`);
543
+ }
544
+ return assigned;
337
545
  }
338
- catch (exc) {
339
- const msg = exc instanceof Error ? exc.message : String(exc);
546
+ const assignedPrimary = await mapOne(primary);
547
+ if (assignedPrimary === null) {
340
548
  if (client.close)
341
549
  client.close();
342
550
  return {
343
551
  success: false,
344
552
  externalIp,
345
553
  mappedPorts: [],
346
- error: `AddPortMapping failed for primary port ${primary}: ${msg}`,
554
+ error: `portMapping + AddAnyPortMapping both failed for primary port ${primary}`,
347
555
  };
348
556
  }
349
557
  const mapped = [primary];
558
+ const portMapping = { [primary]: assignedPrimary };
350
559
  for (const extra of internalPorts.slice(1)) {
351
- try {
352
- await client.portMapping({
353
- public: extra,
354
- private: extra,
355
- ttl: leaseSeconds,
356
- description: `iicp-client (ADR-041 tier-1) ${extra}`,
357
- protocol: "tcp",
358
- });
560
+ const assigned = await mapOne(extra);
561
+ if (assigned !== null) {
359
562
  mapped.push(extra);
563
+ portMapping[extra] = assigned;
360
564
  }
361
- catch (exc) {
362
- const msg = exc instanceof Error ? exc.message : String(exc);
363
- // Best-effort — log via detection_log path; primary already succeeded.
565
+ else {
364
566
  // eslint-disable-next-line no-console
365
- console.warn(`UPnP: failed to map additional port ${extra} (primary ${primary} ok): ${msg}`);
567
+ console.warn(`UPnP: failed to map additional port ${extra} (primary ${primary}→${assignedPrimary} ok)`);
366
568
  }
367
569
  }
368
570
  if (client.close)
369
571
  client.close();
370
- return { success: true, externalIp, externalPort: primary, mappedPorts: mapped };
572
+ return {
573
+ success: true,
574
+ externalIp,
575
+ externalPort: assignedPrimary,
576
+ mappedPorts: mapped,
577
+ portMapping,
578
+ };
579
+ }
580
+ /**
581
+ * Raw-SOAP AddAnyPortMapping (IGDv2 §2.5.13) for the WANIPConnection /
582
+ * WANPPPConnection service. Used as a fallback when nat-upnp's 1:1
583
+ * portMapping fails on conflict.
584
+ */
585
+ async function tryAddAnyPortMapping(internalPort, description, leaseSeconds) {
586
+ // Discover WANIPConnection / WANPPPConnection
587
+ const STypes = [
588
+ "urn:schemas-upnp-org:service:WANIPConnection:2",
589
+ "urn:schemas-upnp-org:service:WANIPConnection:1",
590
+ "urn:schemas-upnp-org:service:WANPPPConnection:1",
591
+ ];
592
+ for (const st of STypes) {
593
+ const hits = await ssdpDiscover(st, 3000);
594
+ for (const hit of hits) {
595
+ const svc = await fetchWanConnectionService(hit.location, st, 3000);
596
+ if (!svc)
597
+ continue;
598
+ // We need the LAN IP that's on the IGD's subnet — reuse the picked
599
+ // local IP. nat-upnp normally figures that out; here we let the IGD
600
+ // resolve it via the request source IP (NewInternalClient set below).
601
+ const localV4 = pickLocalV4ForGateway(hit.location);
602
+ if (!localV4)
603
+ continue;
604
+ const result = await soapCall(svc.controlURL, svc.serviceType, "AddAnyPortMapping", {
605
+ NewRemoteHost: "",
606
+ NewExternalPort: internalPort,
607
+ NewProtocol: "TCP",
608
+ NewInternalPort: internalPort,
609
+ NewInternalClient: localV4,
610
+ NewEnabled: 1,
611
+ NewPortMappingDescription: description,
612
+ NewLeaseDuration: leaseSeconds,
613
+ }, 5000);
614
+ const assigned = result ? parseInt(result.NewReservedPort ?? "0", 10) : 0;
615
+ if (assigned > 0)
616
+ return assigned;
617
+ }
618
+ }
619
+ return null;
620
+ }
621
+ async function fetchWanConnectionService(deviceUrl, expectedType, timeoutMs) {
622
+ let xml;
623
+ try {
624
+ const r = await fetch(deviceUrl, { signal: AbortSignal.timeout(timeoutMs) });
625
+ if (!r.ok)
626
+ return null;
627
+ xml = await r.text();
628
+ }
629
+ catch {
630
+ return null;
631
+ }
632
+ const re = /<service>([\s\S]*?)<\/service>/gi;
633
+ let m;
634
+ while ((m = re.exec(xml)) !== null) {
635
+ const block = m[1];
636
+ const typeM = block.match(/<serviceType>([^<]+)<\/serviceType>/i);
637
+ const ctrlM = block.match(/<controlURL>([^<]+)<\/controlURL>/i);
638
+ if (typeM && ctrlM && typeM[1].trim() === expectedType) {
639
+ let controlURL = ctrlM[1].trim();
640
+ if (!/^https?:\/\//i.test(controlURL)) {
641
+ const base = new URL(deviceUrl);
642
+ controlURL = new URL(controlURL, `${base.protocol}//${base.host}`).toString();
643
+ }
644
+ return { controlURL, serviceType: expectedType };
645
+ }
646
+ }
647
+ return null;
648
+ }
649
+ function pickLocalV4ForGateway(deviceUrl) {
650
+ try {
651
+ const base = new URL(deviceUrl);
652
+ const gwHost = base.hostname;
653
+ const m = gwHost.match(/^(\d+\.\d+\.\d+)\./);
654
+ if (!m)
655
+ return null;
656
+ const prefix = m[1];
657
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
658
+ const os = require("node:os");
659
+ const ifs = os.networkInterfaces();
660
+ for (const list of Object.values(ifs)) {
661
+ if (!list)
662
+ continue;
663
+ for (const ent of list) {
664
+ if (ent.family === "IPv4" && !ent.internal && ent.address.startsWith(prefix + ".")) {
665
+ return ent.address;
666
+ }
667
+ }
668
+ }
669
+ }
670
+ catch {
671
+ /* no-op */
672
+ }
673
+ return null;
674
+ }
675
+ /**
676
+ * Cloud-VM auto-detect: returns the first public-routable IPv4 found on a
677
+ * local network interface. Covers bare-metal VPS scenarios (Hetzner,
678
+ * DigitalOcean, Vultr) where the public IP is assigned directly to an
679
+ * interface. On AWS/GCP (private IP only visible) returns null.
680
+ */
681
+ function detectPublicV4OnInterfaces() {
682
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
683
+ const nodeOs = require("node:os");
684
+ const ifs = nodeOs.networkInterfaces();
685
+ for (const list of Object.values(ifs)) {
686
+ if (!list)
687
+ continue;
688
+ for (const ent of list) {
689
+ if (ent.family !== "IPv4" || ent.internal)
690
+ continue;
691
+ if (!isNonPublicIpv4(ent.address))
692
+ return ent.address;
693
+ }
694
+ }
695
+ return null;
371
696
  }
372
697
  // ── External-IP probe + routability helpers ──────────────────────────────────
373
698
  async function probeExternalIp(url, timeoutMs = 5000) {
@@ -506,4 +831,209 @@ function withTimeout(p, ms) {
506
831
  });
507
832
  });
508
833
  }
834
+ async function ssdpDiscover(serviceType, timeoutMs = 3000) {
835
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
836
+ const dgram = require("node:dgram");
837
+ const sock = dgram.createSocket({ type: "udp4", reuseAddr: true });
838
+ const hits = [];
839
+ const msg = Buffer.from(`M-SEARCH * HTTP/1.1\r\n` +
840
+ `HOST: 239.255.255.250:1900\r\n` +
841
+ `MAN: "ssdp:discover"\r\n` +
842
+ `MX: 2\r\n` +
843
+ `ST: ${serviceType}\r\n\r\n`);
844
+ return new Promise((resolve) => {
845
+ sock.on("message", (data) => {
846
+ const text = data.toString("utf-8");
847
+ const locMatch = text.match(/LOCATION:\s*(\S+)/i);
848
+ const stMatch = text.match(/^ST:\s*(\S+)/im);
849
+ if (locMatch && stMatch) {
850
+ hits.push({ location: locMatch[1], st: stMatch[1] });
851
+ }
852
+ });
853
+ sock.on("error", () => {
854
+ sock.close();
855
+ resolve(hits);
856
+ });
857
+ sock.bind(0, () => {
858
+ sock.setBroadcast(true);
859
+ sock.send(msg, 1900, "239.255.255.250", () => undefined);
860
+ });
861
+ setTimeout(() => {
862
+ try {
863
+ sock.close();
864
+ }
865
+ catch { /* no-op */ }
866
+ resolve(hits);
867
+ }, timeoutMs);
868
+ });
869
+ }
870
+ async function fetchFirewallService(deviceUrl, timeoutMs = 3000) {
871
+ let xml;
872
+ try {
873
+ const r = await fetch(deviceUrl, { signal: AbortSignal.timeout(timeoutMs) });
874
+ if (!r.ok)
875
+ return null;
876
+ xml = await r.text();
877
+ }
878
+ catch {
879
+ return null;
880
+ }
881
+ // Crude regex match — full XML parsing not worth the dep here.
882
+ const services = [];
883
+ const re = /<service>([\s\S]*?)<\/service>/gi;
884
+ let m;
885
+ while ((m = re.exec(xml)) !== null) {
886
+ const block = m[1];
887
+ const typeM = block.match(/<serviceType>([^<]+)<\/serviceType>/i);
888
+ const ctrlM = block.match(/<controlURL>([^<]+)<\/controlURL>/i);
889
+ if (typeM && ctrlM)
890
+ services.push({ type: typeM[1].trim(), control: ctrlM[1].trim() });
891
+ }
892
+ const v6 = services.find((s) => s.type.includes("WANIPv6FirewallControl"));
893
+ if (!v6)
894
+ return null;
895
+ // controlURL may be relative
896
+ let controlURL = v6.control;
897
+ if (!/^https?:\/\//i.test(controlURL)) {
898
+ const base = new URL(deviceUrl);
899
+ controlURL = new URL(controlURL, `${base.protocol}//${base.host}`).toString();
900
+ }
901
+ return { controlURL, serviceType: v6.type };
902
+ }
903
+ async function soapCall(controlURL, serviceType, action, args, timeoutMs = 5000) {
904
+ const argXml = Object.entries(args)
905
+ .map(([k, v]) => `<${k}>${String(v)}</${k}>`)
906
+ .join("");
907
+ const body = `<?xml version="1.0"?>\n` +
908
+ `<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" ` +
909
+ `s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">` +
910
+ `<s:Body>` +
911
+ `<u:${action} xmlns:u="${serviceType}">${argXml}</u:${action}>` +
912
+ `</s:Body></s:Envelope>`;
913
+ try {
914
+ const r = await fetch(controlURL, {
915
+ method: "POST",
916
+ headers: {
917
+ "Content-Type": 'text/xml; charset="utf-8"',
918
+ SOAPACTION: `"${serviceType}#${action}"`,
919
+ },
920
+ body,
921
+ signal: AbortSignal.timeout(timeoutMs),
922
+ });
923
+ if (!r.ok)
924
+ return null;
925
+ const text = await r.text();
926
+ const out = {};
927
+ const re = /<([A-Za-z][A-Za-z0-9_]*)>([^<]*)<\/\1>/g;
928
+ let m;
929
+ while ((m = re.exec(text)) !== null) {
930
+ // Skip envelope/body wrappers
931
+ if (["Envelope", "Body"].includes(m[1]))
932
+ continue;
933
+ out[m[1]] = m[2];
934
+ }
935
+ return out;
936
+ }
937
+ catch {
938
+ return null;
939
+ }
940
+ }
941
+ /**
942
+ * ADR-043 §5 (#343) — open an inbound IPv6 firewall pinhole on the IGD.
943
+ * Returns `{ uniqueId, leaseSeconds, inboundAllowed }` on success, null otherwise.
944
+ *
945
+ * Operators close the pinhole on shutdown via `deleteIpv6Pinhole(uniqueId)`.
946
+ */
947
+ async function tryUpnpIpv6Pinhole(internalV6, internalPort, opts = {}) {
948
+ const lease = opts.leaseSeconds ?? 3600;
949
+ const protocol = opts.protocol ?? 6; // TCP
950
+ const timeout = opts.timeoutMs ?? 5000;
951
+ const hits = await ssdpDiscover("urn:schemas-upnp-org:service:WANIPv6FirewallControl:1", timeout);
952
+ for (const hit of hits) {
953
+ const svc = await fetchFirewallService(hit.location, timeout);
954
+ if (!svc)
955
+ continue;
956
+ const status = await soapCall(svc.controlURL, svc.serviceType, "GetFirewallStatus", {}, timeout);
957
+ const inboundAllowed = (status?.InboundPinholeAllowed ?? "0") === "1";
958
+ if (!inboundAllowed)
959
+ return null;
960
+ const result = await soapCall(svc.controlURL, svc.serviceType, "AddPinhole", {
961
+ RemoteHost: "",
962
+ RemotePort: 0,
963
+ InternalClient: internalV6,
964
+ InternalPort: internalPort,
965
+ Protocol: protocol,
966
+ LeaseTime: lease,
967
+ }, timeout);
968
+ if (!result)
969
+ return null;
970
+ const uid = parseInt(result.UniqueID ?? "0", 10);
971
+ return { uniqueId: uid, leaseSeconds: lease, inboundAllowed: true };
972
+ }
973
+ return null;
974
+ }
975
+ async function tryOpenV6PinholeForEndpoint(endpoint, bindPort) {
976
+ const log = [];
977
+ const m = endpoint.match(/^(https?):\/\/\[([0-9a-fA-F:]+)\](?::(\d+))?/);
978
+ if (!m)
979
+ return { pinholeActive: false, detectionLog: log };
980
+ const scheme = m[1];
981
+ const v6Host = m[2];
982
+ const portInUrl = m[3] ? parseInt(m[3], 10) : bindPort;
983
+ // GUA range check
984
+ const first = parseInt(v6Host.split(":")[0] ?? "0", 16);
985
+ if ((first & 0xe000) !== 0x2000) {
986
+ log.push(`v6 pinhole: skip — ${v6Host} is not a GUA (2000::/3 required)`);
987
+ return { pinholeActive: false, detectionLog: log };
988
+ }
989
+ const candidates = [v6Host, ...rankedGlobalIpv6Candidates().filter((c) => c !== v6Host)];
990
+ let chosen = null;
991
+ let chosenResult = null;
992
+ for (const cand of candidates) {
993
+ log.push(`v6 pinhole: attempting AddPinhole for [${cand}]:${portInUrl}`);
994
+ const r = await tryUpnpIpv6Pinhole(cand, portInUrl, { leaseSeconds: 3600 });
995
+ if (r) {
996
+ chosen = cand;
997
+ chosenResult = r;
998
+ break;
999
+ }
1000
+ }
1001
+ if (!chosen || !chosenResult) {
1002
+ log.push("v6 pinhole: not opened on any local GUA — if your router is a " +
1003
+ "FRITZ!Box, enable 'Internet → Filters → IPv6 → Selbständige " +
1004
+ "Portfreigaben durch das Gerät erlauben' (or equivalent). Error 606 " +
1005
+ "from the IGD = router-side ACL block, NOT a SOAP problem.");
1006
+ return { pinholeActive: false, detectionLog: log };
1007
+ }
1008
+ const out = {
1009
+ pinholeActive: true,
1010
+ pinholeUniqueId: chosenResult.uniqueId,
1011
+ pinholeLeaseSeconds: chosenResult.leaseSeconds,
1012
+ pinholeInboundAllowed: chosenResult.inboundAllowed,
1013
+ detectionLog: log,
1014
+ };
1015
+ log.push(`v6 pinhole: AddPinhole OK — uid=${chosenResult.uniqueId} lease=${chosenResult.leaseSeconds}s on [${chosen}]`);
1016
+ if (chosen !== v6Host) {
1017
+ const rewritten = `${scheme}://[${chosen}]:${portInUrl}`;
1018
+ log.push(`v6 pinhole: rewriting public_endpoint ${endpoint} → ${rewritten} ` +
1019
+ `(original v6 rejected by IGD, pinhole opened on different local GUA)`);
1020
+ out.rewrittenEndpoint = rewritten;
1021
+ }
1022
+ return out;
1023
+ }
1024
+ /** ADR-043 §5 — close a previously-opened IPv6 pinhole. Best-effort. */
1025
+ async function deleteIpv6Pinhole(uniqueId, timeoutMs = 5000) {
1026
+ const hits = await ssdpDiscover("urn:schemas-upnp-org:service:WANIPv6FirewallControl:1", timeoutMs);
1027
+ for (const hit of hits) {
1028
+ const svc = await fetchFirewallService(hit.location, timeoutMs);
1029
+ if (!svc)
1030
+ continue;
1031
+ const result = await soapCall(svc.controlURL, svc.serviceType, "DeletePinhole", {
1032
+ UniqueID: uniqueId,
1033
+ }, timeoutMs);
1034
+ if (result !== null)
1035
+ return true;
1036
+ }
1037
+ return false;
1038
+ }
509
1039
  //# sourceMappingURL=nat_detection.js.map