@camstack/system 1.2.116 → 1.2.117

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.
Files changed (57) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  9. package/dist/builtins/console-logging/index.js +1 -1
  10. package/dist/builtins/console-logging/index.mjs +1 -1
  11. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  12. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  13. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  14. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  15. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  17. package/dist/builtins/hub-forwarder/index.js +1 -1
  18. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  19. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  20. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  21. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  22. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  23. package/dist/builtins/local-network/ipv6-lan.d.ts +26 -0
  24. package/dist/builtins/local-network/local-network.addon.d.ts +28 -14
  25. package/dist/builtins/local-network/local-network.addon.js +195 -25
  26. package/dist/builtins/local-network/local-network.addon.mjs +191 -26
  27. package/dist/builtins/loki-logging/index.js +1 -1
  28. package/dist/builtins/loki-logging/index.mjs +1 -1
  29. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  30. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  31. package/dist/builtins/platform-probe/index.js +1 -1
  32. package/dist/builtins/platform-probe/index.mjs +1 -1
  33. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  34. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  35. package/dist/builtins/snapshot/index.js +1 -1
  36. package/dist/builtins/snapshot/index.mjs +1 -1
  37. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  38. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  39. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +2 -2
  40. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +2 -2
  41. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  42. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  43. package/dist/builtins/system-config/system-config.addon.js +1 -1
  44. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  45. package/dist/builtins/winston-logging/index.js +1 -1
  46. package/dist/builtins/winston-logging/index.mjs +1 -1
  47. package/dist/{dist-BpzbLPel.mjs → dist-D4Vmj8iw.mjs} +16 -8
  48. package/dist/{dist-Cri60Jd0.js → dist-DMEGlqg6.js} +16 -8
  49. package/dist/http/lan-http-bind.d.ts +12 -1
  50. package/dist/index.d.ts +1 -1
  51. package/dist/index.js +3 -2
  52. package/dist/index.mjs +3 -3
  53. package/dist/{lan-http-bind-CZ4EBeD_.js → lan-http-bind-DmgpFP6_.js} +53 -11
  54. package/dist/{lan-http-bind-BDJm4d-K.mjs → lan-http-bind-jKrj6OjQ.mjs} +48 -12
  55. package/dist/{retired-settings-keys-SrxMH3OT.mjs → retired-settings-keys--aM_vGF9.mjs} +1 -1
  56. package/dist/{retired-settings-keys-BVAxT5XL.js → retired-settings-keys-DET4YkAU.js} +1 -1
  57. package/package.json +1 -1
@@ -1,11 +1,12 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../../chunk-Cek0wNdY.js");
3
- const require_dist = require("../../dist-Cri60Jd0.js");
4
- const require_lan_http_bind = require("../../lan-http-bind-CZ4EBeD_.js");
3
+ const require_dist = require("../../dist-DMEGlqg6.js");
4
+ const require_lan_http_bind = require("../../lan-http-bind-DmgpFP6_.js");
5
5
  let node_fs_promises = require("node:fs/promises");
6
6
  let node_path = require("node:path");
7
7
  let node_os = require("node:os");
8
8
  node_os = require_chunk.__toESM(node_os);
9
+ let node_fs = require("node:fs");
9
10
  let node_net = require("node:net");
10
11
  //#region src/builtins/local-network/local-hostname.ts
11
12
  var DNS_NAME = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])$/;
@@ -41,6 +42,111 @@ function parseLocalHostname(raw) {
41
42
  };
42
43
  }
43
44
  //#endregion
45
+ //#region src/builtins/local-network/ipv6-lan.ts
46
+ /**
47
+ * IPv6 helpers for LAN advertisement: prefix matching (privacy addresses
48
+ * rotate inside a /64), Linux `/proc/net/if_inet6` flags, and the gate
49
+ * that keeps temporary GUA off the connection candidate list.
50
+ */
51
+ var IFA_F_TEMPORARY = 1;
52
+ var IFA_F_DEPRECATED = 32;
53
+ var IFA_F_TENTATIVE = 64;
54
+ /** First 64 bits, canonical hex groups, or null if the address is not IPv6. */
55
+ function ipv6Prefix64Key(address) {
56
+ const groups = parseIpv6Groups(address);
57
+ if (groups === null) return null;
58
+ return groups.slice(0, 4).map((g) => g.toString(16).padStart(4, "0")).join(":");
59
+ }
60
+ /** 32-char lowercase hex (same encoding as `/proc/net/if_inet6`). */
61
+ function ipv6ToHex32(address) {
62
+ const groups = parseIpv6Groups(address);
63
+ if (groups === null) return null;
64
+ return groups.map((g) => g.toString(16).padStart(4, "0")).join("");
65
+ }
66
+ /**
67
+ * Parse Linux `if_inet6` into hex32 → flags. Unknown / malformed lines
68
+ * are skipped. Keys match {@link ipv6ToHex32}.
69
+ */
70
+ function parseProcIfInet6(text) {
71
+ const out = /* @__PURE__ */ new Map();
72
+ for (const line of text.split("\n")) {
73
+ const trimmed = line.trim();
74
+ if (trimmed === "") continue;
75
+ const parts = trimmed.split(/\s+/);
76
+ const hex = parts[0]?.toLowerCase();
77
+ const flagsRaw = parts[4];
78
+ if (!hex || hex.length !== 32 || flagsRaw === void 0) continue;
79
+ const flags = Number.parseInt(flagsRaw, 16);
80
+ if (!Number.isFinite(flags)) continue;
81
+ out.set(hex, flags);
82
+ }
83
+ return out;
84
+ }
85
+ /**
86
+ * Whether this IPv6 should be auto-seeded / advertised.
87
+ * `flags === undefined` means the platform did not tell us (macOS, tests
88
+ * without a proc snapshot) — keep every non-link-local plausible address.
89
+ *
90
+ * On Linux, drop addresses marked TEMPORARY / DEPRECATED / TENTATIVE, and
91
+ * `flags === 0` which is how this kernel reports RFC 4941 privacy temps
92
+ * (the stable GUA is PERMANENT|NODAD). Do **not** require PERMANENT —
93
+ * stateful DHCPv6 GUA often has only NODAD (0x02).
94
+ */
95
+ function shouldAdvertiseIpv6(address, flags) {
96
+ const a = address.toLowerCase().split("%")[0] ?? address;
97
+ if (a === "::" || a === "::1") return false;
98
+ if (a.startsWith("fe80:")) return false;
99
+ if (a.startsWith("ff")) return false;
100
+ if (flags === void 0) return true;
101
+ if ((flags & IFA_F_TEMPORARY) !== 0) return false;
102
+ if ((flags & IFA_F_DEPRECATED) !== 0) return false;
103
+ if ((flags & IFA_F_TENTATIVE) !== 0) return false;
104
+ if (flags === 0) return false;
105
+ return true;
106
+ }
107
+ function ipv6AllowlistMatches(address, allowed) {
108
+ if (allowed.includes(address)) return true;
109
+ const key = ipv6Prefix64Key(address);
110
+ if (key === null) return false;
111
+ for (const entry of allowed) {
112
+ if (!entry.includes(":")) continue;
113
+ if (ipv6Prefix64Key(entry) === key) return true;
114
+ }
115
+ return false;
116
+ }
117
+ function parseIpv6Groups(address) {
118
+ const raw = address.toLowerCase().split("%")[0];
119
+ if (!raw || raw.includes(".")) return null;
120
+ const sides = raw.split("::");
121
+ if (sides.length > 2) return null;
122
+ const parsePart = (part) => {
123
+ if (part === void 0 || part === "") return [];
124
+ const groups = part.split(":");
125
+ const out = [];
126
+ for (const g of groups) {
127
+ if (g === "") return null;
128
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
129
+ out.push(Number.parseInt(g, 16));
130
+ }
131
+ return out;
132
+ };
133
+ if (sides.length === 1) {
134
+ const groups = parsePart(sides[0]);
135
+ if (groups === null || groups.length !== 8) return null;
136
+ return groups;
137
+ }
138
+ const left = parsePart(sides[0]);
139
+ const right = parsePart(sides[1]);
140
+ if (left === null || right === null) return null;
141
+ const fill = 8 - left.length - right.length;
142
+ if (fill < 0) return null;
143
+ return [
144
+ ...left,
145
+ ...Array(fill).fill(0),
146
+ ...right
147
+ ];
148
+ }
149
+ //#endregion
44
150
  //#region src/builtins/local-network/local-network.addon.ts
45
151
  /**
46
152
  * local-network — hub-only system cap implementation.
@@ -66,6 +172,8 @@ function parseLocalHostname(raw) {
66
172
  */
67
173
  var MESH_PROVIDER_ADDON_IDS = ["tailscale-client"];
68
174
  var POLL_INTERVAL_MS = 3e4;
175
+ /** Drop rotated IPv6 / dead extras from the persisted allowlist. */
176
+ var ALLOWLIST_RECONCILE_INTERVAL_MS = 3600 * 1e3;
69
177
  /**
70
178
  * Compiled-in boot default — mirrors `CONFIG_DEFAULTS.server.port` in
71
179
  * `server/backend/src/boot/boot-config.ts`. Used only when neither the live
@@ -74,6 +182,8 @@ var POLL_INTERVAL_MS = 3e4;
74
182
  var DEFAULT_HUB_PORT = 4443;
75
183
  var LocalNetworkAddon = class extends require_dist.BaseAddon {
76
184
  pollTimer = null;
185
+ allowlistTimer = null;
186
+ allowlistReconcilePending = false;
77
187
  lastSnapshotKey = "";
78
188
  /** Optional public hostname tracked from `NetworkTunnelStarted`/
79
189
  * `Stopped` events on the bus. */
@@ -119,6 +229,7 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
119
229
  });
120
230
  this.ctx.logger.info("local-network: first-boot auto-seed", { meta: { addresses: seed } });
121
231
  }
232
+ await this.persistReconciledAllowlist();
122
233
  const provider = {
123
234
  list: async () => ({
124
235
  interfaces: this.enumerate(),
@@ -213,6 +324,10 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
213
324
  this.lastSnapshotKey = snapshotKey(this.enumerate());
214
325
  this.pollTimer = setInterval(() => this.detectChanges(), POLL_INTERVAL_MS);
215
326
  this.pollTimer.unref?.();
327
+ this.allowlistTimer = setInterval(() => {
328
+ this.persistReconciledAllowlist();
329
+ }, ALLOWLIST_RECONCILE_INTERVAL_MS);
330
+ this.allowlistTimer.unref?.();
216
331
  this.ctx.eventBus?.subscribe({ category: require_dist.EventCategory.NetworkTunnelStarted }, (event) => {
217
332
  const data = event.data ?? {};
218
333
  if (typeof data.url === "string") try {
@@ -288,6 +403,10 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
288
403
  clearInterval(this.pollTimer);
289
404
  this.pollTimer = null;
290
405
  }
406
+ if (this.allowlistTimer) {
407
+ clearInterval(this.allowlistTimer);
408
+ this.allowlistTimer = null;
409
+ }
291
410
  }
292
411
  /**
293
412
  * Other hub addons (e.g. cloudflare-tunnel) signal the active public
@@ -421,7 +540,26 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
421
540
  }
422
541
  }
423
542
  enumerate() {
424
- return enumerateOsInterfaces(node_os.networkInterfaces());
543
+ return enumerateOsInterfaces(node_os.networkInterfaces(), readLinuxIpv6Flags());
544
+ }
545
+ /**
546
+ * Replace rotated IPv6 and drop addresses that no longer exist, without
547
+ * clearing operator extras that are still live (docker/vpn opt-in).
548
+ */
549
+ async persistReconciledAllowlist(interfaces = this.enumerate()) {
550
+ if (this.allowlistReconcilePending) return;
551
+ this.allowlistReconcilePending = true;
552
+ try {
553
+ const next = reconcileAllowlist(this.config.allowedAddresses, interfaces);
554
+ if (sameAddressSet(this.config.allowedAddresses, next)) return;
555
+ await this.updateGlobalSettings({
556
+ allowedAddresses: next,
557
+ bootSeeded: true
558
+ });
559
+ this.ctx.logger.info("local-network: allowlist reconciled", { meta: { addresses: next } });
560
+ } finally {
561
+ this.allowlistReconcilePending = false;
562
+ }
425
563
  }
426
564
  detectChanges() {
427
565
  const interfaces = this.enumerate();
@@ -432,6 +570,7 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
432
570
  key
433
571
  } });
434
572
  this.lastSnapshotKey = key;
573
+ this.persistReconciledAllowlist(interfaces);
435
574
  this.ctx.eventBus?.emit({
436
575
  id: `local-network-changed-${Date.now()}`,
437
576
  timestamp: /* @__PURE__ */ new Date(),
@@ -480,7 +619,7 @@ function validPort(value) {
480
619
  if (value === null) return null;
481
620
  return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : null;
482
621
  }
483
- function enumerateOsInterfaces(ifaces) {
622
+ function enumerateOsInterfaces(ifaces, ipv6Flags) {
484
623
  const raw = [];
485
624
  for (const [name, addrs] of Object.entries(ifaces)) {
486
625
  if (!addrs) continue;
@@ -500,8 +639,12 @@ function enumerateOsInterfaces(ifaces) {
500
639
  const classified = raw.map((r) => {
501
640
  const kind = classifyKind(r.name, r.address, r.internal);
502
641
  const reachableKind = kind === "lan" || kind === "wifi";
503
- const plausible = !r.internal && reachableKind && isPlausibleAutoSeed(r.address, r.family);
504
- const plausibleReason = plausible ? "" : explainNonPlausible({
642
+ const hex = r.family === "IPv6" ? ipv6ToHex32(r.address) : null;
643
+ const flags = hex !== null ? ipv6Flags?.get(hex) : void 0;
644
+ const rangeOk = isPlausibleAutoSeed(r.address, r.family);
645
+ const stableV6 = r.family !== "IPv6" || shouldAdvertiseIpv6(r.address, flags);
646
+ const plausible = !r.internal && reachableKind && rangeOk && stableV6;
647
+ const plausibleReason = plausible ? "" : r.family === "IPv6" && rangeOk && !stableV6 ? "IPv6 temporary / privacy address — rotates and is not advertised." : explainNonPlausible({
505
648
  kind,
506
649
  family: r.family,
507
650
  address: r.address,
@@ -530,13 +673,14 @@ function enumerateOsInterfaces(ifaces) {
530
673
  /**
531
674
  * Filter the interface list by the operator's allowlist. Empty
532
675
  * allowlist = no-op (every interface passes); otherwise only entries
533
- * whose `address` matches an allowlist entry remain. Loopback always
534
- * survives so the SDK keeps `127.0.0.1` as the last-resort fallback.
676
+ * whose `address` matches an allowlist entry remain. IPv6 matches the
677
+ * allowlisted /64 so a rotated privacy address does not hide the live
678
+ * GUA. Loopback always survives so the SDK keeps `127.0.0.1` as the
679
+ * last-resort fallback.
535
680
  */
536
681
  function applyAllowlist(interfaces, allowed) {
537
682
  if (allowed.length === 0) return interfaces;
538
- const set = new Set(allowed);
539
- return interfaces.filter((i) => i.kind === "loopback" || set.has(i.address));
683
+ return interfaces.filter((i) => i.kind === "loopback" || ipv6AllowlistMatches(i.address, allowed) || allowed.includes(i.address));
540
684
  }
541
685
  /**
542
686
  * Rank interfaces and pick the auto-selected outbound one. See the
@@ -595,6 +739,13 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
595
739
  if (iface.address.startsWith("169.254.")) continue;
596
740
  emit(iface, "lan-ipv4", `${formatKind(iface.kind)} — ${iface.name}`, `${lanScheme}://${iface.address}:${lanPort}`, 10 + priority++);
597
741
  }
742
+ if (!ipv4Only) for (const iface of interfaces) {
743
+ if (iface.internal || iface.family !== "IPv6") continue;
744
+ if (iface.kind === "loopback") continue;
745
+ if (iface.address.toLowerCase().startsWith("fe80:")) continue;
746
+ if (!iface.plausible) continue;
747
+ emit(iface, "lan-ipv6", `${formatKind(iface.kind)} — ${iface.name} (IPv6)`, `${lanScheme}://[${iface.address}]:${lanPort}`, 10 + priority++);
748
+ }
598
749
  if (publicHostname) out.push({
599
750
  label: "Public tunnel",
600
751
  baseUrl: `https://${publicHostname}`,
@@ -613,15 +764,6 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
613
764
  plausibleReason: "",
614
765
  priority: 150
615
766
  });
616
- if (!ipv4Only) {
617
- let v6prio = 200;
618
- for (const iface of interfaces) {
619
- if (iface.internal || iface.family !== "IPv6") continue;
620
- if (iface.kind === "loopback") continue;
621
- if (iface.address.startsWith("fe80:")) continue;
622
- emit(iface, "lan-ipv6", `${formatKind(iface.kind)} — ${iface.name} (IPv6)`, `${lanScheme}://[${iface.address}]:${lanPort}`, v6prio++);
623
- }
624
- }
625
767
  if (includeLoopback) out.push({
626
768
  label: "Loopback",
627
769
  baseUrl: `${scheme}://127.0.0.1:${port}`,
@@ -639,16 +781,14 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
639
781
  *
640
782
  * - **IPv4**: skip link-local (`169.254.*`), keep the rest.
641
783
  * - **IPv6**: skip link-local (`fe80::*`), unspecified, and
642
- * multicast. Keep ULAs (`fc00::/7` `fc??:` / `fd??:`) and Global
643
- * Unicast addresses (`2000::/3` `2???`/`3???`). Privacy-extension
644
- * temporary addresses get included by default; the operator can
645
- * prune them from the Network Addresses tab if the rotating IPs
646
- * become a nuisance.
784
+ * multicast. Keep ULAs (`fc00::/7`) and Global Unicast (`2000::/3`).
785
+ * Privacy-extension temporary addresses are skipped when Linux flags
786
+ * mark them non-permanent (`plausible === false`).
647
787
  *
648
788
  * Skips docker/vpn/loopback/other entirely — those stay opt-in.
649
789
  */
650
790
  function autoSeedAllowlist(interfaces) {
651
- return [...new Set(interfaces.filter((i) => !i.internal && (i.kind === "lan" || i.kind === "wifi") && isPlausibleAutoSeed(i.address, i.family)).map((i) => i.address))];
791
+ return [...new Set(interfaces.filter((i) => !i.internal && (i.kind === "lan" || i.kind === "wifi") && isPlausibleAutoSeed(i.address, i.family) && (i.family !== "IPv6" || i.plausible)).map((i) => i.address))];
652
792
  }
653
793
  /**
654
794
  * Per-interface tooltip text surfaced on the "Unlikely usable" badge.
@@ -737,7 +877,33 @@ function formatKind(kind) {
737
877
  case "other": return "Other";
738
878
  }
739
879
  }
880
+ /**
881
+ * Rewrite a persisted allowlist against the live interface set:
882
+ * current auto-seed (LAN/Wi-Fi, stable IPv6) plus still-live operator
883
+ * extras (docker/vpn). Empty stays empty — that is "unrestricted".
884
+ */
885
+ function reconcileAllowlist(current, interfaces) {
886
+ if (current.length === 0) return [];
887
+ const live = new Set(interfaces.map((i) => i.address));
888
+ const seed = autoSeedAllowlist(interfaces);
889
+ const seedSet = new Set(seed);
890
+ const extras = current.filter((a) => live.has(a) && !seedSet.has(a) && !a.includes(":"));
891
+ return [...new Set([...seed, ...extras])];
892
+ }
893
+ function sameAddressSet(a, b) {
894
+ if (a.length !== b.length) return false;
895
+ const set = new Set(a);
896
+ return b.every((addr) => set.has(addr));
897
+ }
898
+ function readLinuxIpv6Flags() {
899
+ try {
900
+ return parseProcIfInet6((0, node_fs.readFileSync)("/proc/net/if_inet6", "utf8"));
901
+ } catch {
902
+ return;
903
+ }
904
+ }
740
905
  //#endregion
906
+ exports.ALLOWLIST_RECONCILE_INTERVAL_MS = ALLOWLIST_RECONCILE_INTERVAL_MS;
741
907
  exports.LocalNetworkAddon = LocalNetworkAddon;
742
908
  exports.applyAllowlist = applyAllowlist;
743
909
  exports.autoSeedAllowlist = autoSeedAllowlist;
@@ -745,7 +911,11 @@ exports.buildEndpoints = buildEndpoints;
745
911
  exports.classifyKind = classifyKind;
746
912
  exports.enumerateOsInterfaces = enumerateOsInterfaces;
747
913
  exports.explainNonPlausible = explainNonPlausible;
914
+ exports.ipv6Prefix64Key = ipv6Prefix64Key;
748
915
  exports.isPlausibleAutoSeed = isPlausibleAutoSeed;
916
+ exports.parseProcIfInet6 = parseProcIfInet6;
749
917
  exports.pickPreferred = pickPreferred;
750
918
  exports.prefixLen = prefixLen;
919
+ exports.reconcileAllowlist = reconcileAllowlist;
751
920
  exports.resolveAdvertisedPort = resolveAdvertisedPort;
921
+ exports.shouldAdvertiseIpv6 = shouldAdvertiseIpv6;
@@ -1,8 +1,9 @@
1
- import { Gt as EventCategory, Y as localNetworkCapability, yt as BaseAddon } from "../../dist-BpzbLPel.mjs";
2
- import { a as readLanHttpState, c as readTlsAccessStatus, d as writeTlsMode, f as validateUploadedTls, h as reissueTlsLeaf, l as readTlsMode, n as applyLanHttp, p as ensureTlsCert, s as readExtraSans, t as DEFAULT_LAN_HTTP_PORT, u as writeExtraSans } from "../../lan-http-bind-BDJm4d-K.mjs";
1
+ import { Gt as EventCategory, Y as localNetworkCapability, yt as BaseAddon } from "../../dist-D4Vmj8iw.mjs";
2
+ import { c as readExtraSans, d as writeExtraSans, f as writeTlsMode, g as reissueTlsLeaf, l as readTlsAccessStatus, m as ensureTlsCert, o as readLanHttpState, p as validateUploadedTls, r as applyLanHttp, t as DEFAULT_LAN_HTTP_PORT, u as readTlsMode } from "../../lan-http-bind-jKrj6OjQ.mjs";
3
3
  import { mkdir, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import * as os from "node:os";
6
+ import { readFileSync } from "node:fs";
6
7
  import { isIP } from "node:net";
7
8
  //#region src/builtins/local-network/local-hostname.ts
8
9
  var DNS_NAME = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])$/;
@@ -38,6 +39,111 @@ function parseLocalHostname(raw) {
38
39
  };
39
40
  }
40
41
  //#endregion
42
+ //#region src/builtins/local-network/ipv6-lan.ts
43
+ /**
44
+ * IPv6 helpers for LAN advertisement: prefix matching (privacy addresses
45
+ * rotate inside a /64), Linux `/proc/net/if_inet6` flags, and the gate
46
+ * that keeps temporary GUA off the connection candidate list.
47
+ */
48
+ var IFA_F_TEMPORARY = 1;
49
+ var IFA_F_DEPRECATED = 32;
50
+ var IFA_F_TENTATIVE = 64;
51
+ /** First 64 bits, canonical hex groups, or null if the address is not IPv6. */
52
+ function ipv6Prefix64Key(address) {
53
+ const groups = parseIpv6Groups(address);
54
+ if (groups === null) return null;
55
+ return groups.slice(0, 4).map((g) => g.toString(16).padStart(4, "0")).join(":");
56
+ }
57
+ /** 32-char lowercase hex (same encoding as `/proc/net/if_inet6`). */
58
+ function ipv6ToHex32(address) {
59
+ const groups = parseIpv6Groups(address);
60
+ if (groups === null) return null;
61
+ return groups.map((g) => g.toString(16).padStart(4, "0")).join("");
62
+ }
63
+ /**
64
+ * Parse Linux `if_inet6` into hex32 → flags. Unknown / malformed lines
65
+ * are skipped. Keys match {@link ipv6ToHex32}.
66
+ */
67
+ function parseProcIfInet6(text) {
68
+ const out = /* @__PURE__ */ new Map();
69
+ for (const line of text.split("\n")) {
70
+ const trimmed = line.trim();
71
+ if (trimmed === "") continue;
72
+ const parts = trimmed.split(/\s+/);
73
+ const hex = parts[0]?.toLowerCase();
74
+ const flagsRaw = parts[4];
75
+ if (!hex || hex.length !== 32 || flagsRaw === void 0) continue;
76
+ const flags = Number.parseInt(flagsRaw, 16);
77
+ if (!Number.isFinite(flags)) continue;
78
+ out.set(hex, flags);
79
+ }
80
+ return out;
81
+ }
82
+ /**
83
+ * Whether this IPv6 should be auto-seeded / advertised.
84
+ * `flags === undefined` means the platform did not tell us (macOS, tests
85
+ * without a proc snapshot) — keep every non-link-local plausible address.
86
+ *
87
+ * On Linux, drop addresses marked TEMPORARY / DEPRECATED / TENTATIVE, and
88
+ * `flags === 0` which is how this kernel reports RFC 4941 privacy temps
89
+ * (the stable GUA is PERMANENT|NODAD). Do **not** require PERMANENT —
90
+ * stateful DHCPv6 GUA often has only NODAD (0x02).
91
+ */
92
+ function shouldAdvertiseIpv6(address, flags) {
93
+ const a = address.toLowerCase().split("%")[0] ?? address;
94
+ if (a === "::" || a === "::1") return false;
95
+ if (a.startsWith("fe80:")) return false;
96
+ if (a.startsWith("ff")) return false;
97
+ if (flags === void 0) return true;
98
+ if ((flags & IFA_F_TEMPORARY) !== 0) return false;
99
+ if ((flags & IFA_F_DEPRECATED) !== 0) return false;
100
+ if ((flags & IFA_F_TENTATIVE) !== 0) return false;
101
+ if (flags === 0) return false;
102
+ return true;
103
+ }
104
+ function ipv6AllowlistMatches(address, allowed) {
105
+ if (allowed.includes(address)) return true;
106
+ const key = ipv6Prefix64Key(address);
107
+ if (key === null) return false;
108
+ for (const entry of allowed) {
109
+ if (!entry.includes(":")) continue;
110
+ if (ipv6Prefix64Key(entry) === key) return true;
111
+ }
112
+ return false;
113
+ }
114
+ function parseIpv6Groups(address) {
115
+ const raw = address.toLowerCase().split("%")[0];
116
+ if (!raw || raw.includes(".")) return null;
117
+ const sides = raw.split("::");
118
+ if (sides.length > 2) return null;
119
+ const parsePart = (part) => {
120
+ if (part === void 0 || part === "") return [];
121
+ const groups = part.split(":");
122
+ const out = [];
123
+ for (const g of groups) {
124
+ if (g === "") return null;
125
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
126
+ out.push(Number.parseInt(g, 16));
127
+ }
128
+ return out;
129
+ };
130
+ if (sides.length === 1) {
131
+ const groups = parsePart(sides[0]);
132
+ if (groups === null || groups.length !== 8) return null;
133
+ return groups;
134
+ }
135
+ const left = parsePart(sides[0]);
136
+ const right = parsePart(sides[1]);
137
+ if (left === null || right === null) return null;
138
+ const fill = 8 - left.length - right.length;
139
+ if (fill < 0) return null;
140
+ return [
141
+ ...left,
142
+ ...Array(fill).fill(0),
143
+ ...right
144
+ ];
145
+ }
146
+ //#endregion
41
147
  //#region src/builtins/local-network/local-network.addon.ts
42
148
  /**
43
149
  * local-network — hub-only system cap implementation.
@@ -63,6 +169,8 @@ function parseLocalHostname(raw) {
63
169
  */
64
170
  var MESH_PROVIDER_ADDON_IDS = ["tailscale-client"];
65
171
  var POLL_INTERVAL_MS = 3e4;
172
+ /** Drop rotated IPv6 / dead extras from the persisted allowlist. */
173
+ var ALLOWLIST_RECONCILE_INTERVAL_MS = 3600 * 1e3;
66
174
  /**
67
175
  * Compiled-in boot default — mirrors `CONFIG_DEFAULTS.server.port` in
68
176
  * `server/backend/src/boot/boot-config.ts`. Used only when neither the live
@@ -71,6 +179,8 @@ var POLL_INTERVAL_MS = 3e4;
71
179
  var DEFAULT_HUB_PORT = 4443;
72
180
  var LocalNetworkAddon = class extends BaseAddon {
73
181
  pollTimer = null;
182
+ allowlistTimer = null;
183
+ allowlistReconcilePending = false;
74
184
  lastSnapshotKey = "";
75
185
  /** Optional public hostname tracked from `NetworkTunnelStarted`/
76
186
  * `Stopped` events on the bus. */
@@ -116,6 +226,7 @@ var LocalNetworkAddon = class extends BaseAddon {
116
226
  });
117
227
  this.ctx.logger.info("local-network: first-boot auto-seed", { meta: { addresses: seed } });
118
228
  }
229
+ await this.persistReconciledAllowlist();
119
230
  const provider = {
120
231
  list: async () => ({
121
232
  interfaces: this.enumerate(),
@@ -210,6 +321,10 @@ var LocalNetworkAddon = class extends BaseAddon {
210
321
  this.lastSnapshotKey = snapshotKey(this.enumerate());
211
322
  this.pollTimer = setInterval(() => this.detectChanges(), POLL_INTERVAL_MS);
212
323
  this.pollTimer.unref?.();
324
+ this.allowlistTimer = setInterval(() => {
325
+ this.persistReconciledAllowlist();
326
+ }, ALLOWLIST_RECONCILE_INTERVAL_MS);
327
+ this.allowlistTimer.unref?.();
213
328
  this.ctx.eventBus?.subscribe({ category: EventCategory.NetworkTunnelStarted }, (event) => {
214
329
  const data = event.data ?? {};
215
330
  if (typeof data.url === "string") try {
@@ -285,6 +400,10 @@ var LocalNetworkAddon = class extends BaseAddon {
285
400
  clearInterval(this.pollTimer);
286
401
  this.pollTimer = null;
287
402
  }
403
+ if (this.allowlistTimer) {
404
+ clearInterval(this.allowlistTimer);
405
+ this.allowlistTimer = null;
406
+ }
288
407
  }
289
408
  /**
290
409
  * Other hub addons (e.g. cloudflare-tunnel) signal the active public
@@ -418,7 +537,26 @@ var LocalNetworkAddon = class extends BaseAddon {
418
537
  }
419
538
  }
420
539
  enumerate() {
421
- return enumerateOsInterfaces(os.networkInterfaces());
540
+ return enumerateOsInterfaces(os.networkInterfaces(), readLinuxIpv6Flags());
541
+ }
542
+ /**
543
+ * Replace rotated IPv6 and drop addresses that no longer exist, without
544
+ * clearing operator extras that are still live (docker/vpn opt-in).
545
+ */
546
+ async persistReconciledAllowlist(interfaces = this.enumerate()) {
547
+ if (this.allowlistReconcilePending) return;
548
+ this.allowlistReconcilePending = true;
549
+ try {
550
+ const next = reconcileAllowlist(this.config.allowedAddresses, interfaces);
551
+ if (sameAddressSet(this.config.allowedAddresses, next)) return;
552
+ await this.updateGlobalSettings({
553
+ allowedAddresses: next,
554
+ bootSeeded: true
555
+ });
556
+ this.ctx.logger.info("local-network: allowlist reconciled", { meta: { addresses: next } });
557
+ } finally {
558
+ this.allowlistReconcilePending = false;
559
+ }
422
560
  }
423
561
  detectChanges() {
424
562
  const interfaces = this.enumerate();
@@ -429,6 +567,7 @@ var LocalNetworkAddon = class extends BaseAddon {
429
567
  key
430
568
  } });
431
569
  this.lastSnapshotKey = key;
570
+ this.persistReconciledAllowlist(interfaces);
432
571
  this.ctx.eventBus?.emit({
433
572
  id: `local-network-changed-${Date.now()}`,
434
573
  timestamp: /* @__PURE__ */ new Date(),
@@ -477,7 +616,7 @@ function validPort(value) {
477
616
  if (value === null) return null;
478
617
  return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : null;
479
618
  }
480
- function enumerateOsInterfaces(ifaces) {
619
+ function enumerateOsInterfaces(ifaces, ipv6Flags) {
481
620
  const raw = [];
482
621
  for (const [name, addrs] of Object.entries(ifaces)) {
483
622
  if (!addrs) continue;
@@ -497,8 +636,12 @@ function enumerateOsInterfaces(ifaces) {
497
636
  const classified = raw.map((r) => {
498
637
  const kind = classifyKind(r.name, r.address, r.internal);
499
638
  const reachableKind = kind === "lan" || kind === "wifi";
500
- const plausible = !r.internal && reachableKind && isPlausibleAutoSeed(r.address, r.family);
501
- const plausibleReason = plausible ? "" : explainNonPlausible({
639
+ const hex = r.family === "IPv6" ? ipv6ToHex32(r.address) : null;
640
+ const flags = hex !== null ? ipv6Flags?.get(hex) : void 0;
641
+ const rangeOk = isPlausibleAutoSeed(r.address, r.family);
642
+ const stableV6 = r.family !== "IPv6" || shouldAdvertiseIpv6(r.address, flags);
643
+ const plausible = !r.internal && reachableKind && rangeOk && stableV6;
644
+ const plausibleReason = plausible ? "" : r.family === "IPv6" && rangeOk && !stableV6 ? "IPv6 temporary / privacy address — rotates and is not advertised." : explainNonPlausible({
502
645
  kind,
503
646
  family: r.family,
504
647
  address: r.address,
@@ -527,13 +670,14 @@ function enumerateOsInterfaces(ifaces) {
527
670
  /**
528
671
  * Filter the interface list by the operator's allowlist. Empty
529
672
  * allowlist = no-op (every interface passes); otherwise only entries
530
- * whose `address` matches an allowlist entry remain. Loopback always
531
- * survives so the SDK keeps `127.0.0.1` as the last-resort fallback.
673
+ * whose `address` matches an allowlist entry remain. IPv6 matches the
674
+ * allowlisted /64 so a rotated privacy address does not hide the live
675
+ * GUA. Loopback always survives so the SDK keeps `127.0.0.1` as the
676
+ * last-resort fallback.
532
677
  */
533
678
  function applyAllowlist(interfaces, allowed) {
534
679
  if (allowed.length === 0) return interfaces;
535
- const set = new Set(allowed);
536
- return interfaces.filter((i) => i.kind === "loopback" || set.has(i.address));
680
+ return interfaces.filter((i) => i.kind === "loopback" || ipv6AllowlistMatches(i.address, allowed) || allowed.includes(i.address));
537
681
  }
538
682
  /**
539
683
  * Rank interfaces and pick the auto-selected outbound one. See the
@@ -592,6 +736,13 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
592
736
  if (iface.address.startsWith("169.254.")) continue;
593
737
  emit(iface, "lan-ipv4", `${formatKind(iface.kind)} — ${iface.name}`, `${lanScheme}://${iface.address}:${lanPort}`, 10 + priority++);
594
738
  }
739
+ if (!ipv4Only) for (const iface of interfaces) {
740
+ if (iface.internal || iface.family !== "IPv6") continue;
741
+ if (iface.kind === "loopback") continue;
742
+ if (iface.address.toLowerCase().startsWith("fe80:")) continue;
743
+ if (!iface.plausible) continue;
744
+ emit(iface, "lan-ipv6", `${formatKind(iface.kind)} — ${iface.name} (IPv6)`, `${lanScheme}://[${iface.address}]:${lanPort}`, 10 + priority++);
745
+ }
595
746
  if (publicHostname) out.push({
596
747
  label: "Public tunnel",
597
748
  baseUrl: `https://${publicHostname}`,
@@ -610,15 +761,6 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
610
761
  plausibleReason: "",
611
762
  priority: 150
612
763
  });
613
- if (!ipv4Only) {
614
- let v6prio = 200;
615
- for (const iface of interfaces) {
616
- if (iface.internal || iface.family !== "IPv6") continue;
617
- if (iface.kind === "loopback") continue;
618
- if (iface.address.startsWith("fe80:")) continue;
619
- emit(iface, "lan-ipv6", `${formatKind(iface.kind)} — ${iface.name} (IPv6)`, `${lanScheme}://[${iface.address}]:${lanPort}`, v6prio++);
620
- }
621
- }
622
764
  if (includeLoopback) out.push({
623
765
  label: "Loopback",
624
766
  baseUrl: `${scheme}://127.0.0.1:${port}`,
@@ -636,16 +778,14 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
636
778
  *
637
779
  * - **IPv4**: skip link-local (`169.254.*`), keep the rest.
638
780
  * - **IPv6**: skip link-local (`fe80::*`), unspecified, and
639
- * multicast. Keep ULAs (`fc00::/7` `fc??:` / `fd??:`) and Global
640
- * Unicast addresses (`2000::/3` `2???`/`3???`). Privacy-extension
641
- * temporary addresses get included by default; the operator can
642
- * prune them from the Network Addresses tab if the rotating IPs
643
- * become a nuisance.
781
+ * multicast. Keep ULAs (`fc00::/7`) and Global Unicast (`2000::/3`).
782
+ * Privacy-extension temporary addresses are skipped when Linux flags
783
+ * mark them non-permanent (`plausible === false`).
644
784
  *
645
785
  * Skips docker/vpn/loopback/other entirely — those stay opt-in.
646
786
  */
647
787
  function autoSeedAllowlist(interfaces) {
648
- return [...new Set(interfaces.filter((i) => !i.internal && (i.kind === "lan" || i.kind === "wifi") && isPlausibleAutoSeed(i.address, i.family)).map((i) => i.address))];
788
+ return [...new Set(interfaces.filter((i) => !i.internal && (i.kind === "lan" || i.kind === "wifi") && isPlausibleAutoSeed(i.address, i.family) && (i.family !== "IPv6" || i.plausible)).map((i) => i.address))];
649
789
  }
650
790
  /**
651
791
  * Per-interface tooltip text surfaced on the "Unlikely usable" badge.
@@ -734,5 +874,30 @@ function formatKind(kind) {
734
874
  case "other": return "Other";
735
875
  }
736
876
  }
877
+ /**
878
+ * Rewrite a persisted allowlist against the live interface set:
879
+ * current auto-seed (LAN/Wi-Fi, stable IPv6) plus still-live operator
880
+ * extras (docker/vpn). Empty stays empty — that is "unrestricted".
881
+ */
882
+ function reconcileAllowlist(current, interfaces) {
883
+ if (current.length === 0) return [];
884
+ const live = new Set(interfaces.map((i) => i.address));
885
+ const seed = autoSeedAllowlist(interfaces);
886
+ const seedSet = new Set(seed);
887
+ const extras = current.filter((a) => live.has(a) && !seedSet.has(a) && !a.includes(":"));
888
+ return [...new Set([...seed, ...extras])];
889
+ }
890
+ function sameAddressSet(a, b) {
891
+ if (a.length !== b.length) return false;
892
+ const set = new Set(a);
893
+ return b.every((addr) => set.has(addr));
894
+ }
895
+ function readLinuxIpv6Flags() {
896
+ try {
897
+ return parseProcIfInet6(readFileSync("/proc/net/if_inet6", "utf8"));
898
+ } catch {
899
+ return;
900
+ }
901
+ }
737
902
  //#endregion
738
- export { LocalNetworkAddon, applyAllowlist, autoSeedAllowlist, buildEndpoints, classifyKind, enumerateOsInterfaces, explainNonPlausible, isPlausibleAutoSeed, pickPreferred, prefixLen, resolveAdvertisedPort };
903
+ export { ALLOWLIST_RECONCILE_INTERVAL_MS, LocalNetworkAddon, applyAllowlist, autoSeedAllowlist, buildEndpoints, classifyKind, enumerateOsInterfaces, explainNonPlausible, ipv6Prefix64Key, isPlausibleAutoSeed, parseProcIfInet6, pickPreferred, prefixLen, reconcileAllowlist, resolveAdvertisedPort, shouldAdvertiseIpv6 };