@camstack/system 1.2.116 → 1.2.118

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 (63) hide show
  1. package/dist/addon-utils.d.ts +1 -1
  2. package/dist/addon-utils.js +1 -1
  3. package/dist/addon-utils.mjs +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  8. package/dist/builtins/alerts/alerts.addon.js +1 -1
  9. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  12. package/dist/builtins/console-logging/index.js +1 -1
  13. package/dist/builtins/console-logging/index.mjs +1 -1
  14. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  15. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  16. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  17. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  18. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  19. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  20. package/dist/builtins/hub-forwarder/index.js +1 -1
  21. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  22. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  23. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  24. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  25. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  26. package/dist/builtins/local-network/ipv6-lan.d.ts +26 -0
  27. package/dist/builtins/local-network/local-network.addon.d.ts +50 -14
  28. package/dist/builtins/local-network/local-network.addon.js +254 -38
  29. package/dist/builtins/local-network/local-network.addon.mjs +248 -39
  30. package/dist/builtins/loki-logging/index.js +1 -1
  31. package/dist/builtins/loki-logging/index.mjs +1 -1
  32. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  33. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  34. package/dist/builtins/platform-probe/index.js +1 -1
  35. package/dist/builtins/platform-probe/index.mjs +1 -1
  36. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  37. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  38. package/dist/builtins/snapshot/index.js +1 -1
  39. package/dist/builtins/snapshot/index.mjs +1 -1
  40. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  41. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  42. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +2 -2
  43. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +2 -2
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  45. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  46. package/dist/builtins/system-config/system-config.addon.js +1 -1
  47. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  48. package/dist/builtins/winston-logging/index.js +1 -1
  49. package/dist/builtins/winston-logging/index.mjs +1 -1
  50. package/dist/{dist-Cri60Jd0.js → dist-C2_1HCpW.js} +149 -23
  51. package/dist/{dist-BpzbLPel.mjs → dist-DCTpBXm4.mjs} +149 -23
  52. package/dist/download/model-downloader.d.ts +18 -1
  53. package/dist/{file-data-plane-CuE_hBli.mjs → file-data-plane-BhKdxJgf.mjs} +49 -13
  54. package/dist/{file-data-plane-DUHPHa-Y.js → file-data-plane-DO8KbxCe.js} +49 -13
  55. package/dist/http/lan-http-bind.d.ts +12 -1
  56. package/dist/index.d.ts +1 -1
  57. package/dist/index.js +4 -3
  58. package/dist/index.mjs +4 -4
  59. package/dist/{lan-http-bind-CZ4EBeD_.js → lan-http-bind-DmgpFP6_.js} +53 -11
  60. package/dist/{lan-http-bind-BDJm4d-K.mjs → lan-http-bind-jKrj6OjQ.mjs} +48 -12
  61. package/dist/{retired-settings-keys-SrxMH3OT.mjs → retired-settings-keys-Czd76yLZ.mjs} +1 -1
  62. package/dist/{retired-settings-keys-BVAxT5XL.js → retired-settings-keys-DHRXLMPn.js} +1 -1
  63. 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-C2_1HCpW.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. */
@@ -90,6 +200,7 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
90
200
  allowedAddresses: [],
91
201
  bootSeeded: false,
92
202
  notificationBaseUrl: "",
203
+ viewerBaseUrls: [],
93
204
  httpEnabled: true,
94
205
  httpPort: require_lan_http_bind.DEFAULT_LAN_HTTP_PORT,
95
206
  localHostname: ""
@@ -110,6 +221,31 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
110
221
  if (mesh !== null && mesh.host !== "") return `https://${mesh.host}:${mesh.port}`;
111
222
  return null;
112
223
  }
224
+ async snapshotViewerEndpoints() {
225
+ const selected = selectViewerEndpoints(await this.buildAdvertisedEndpoints(false, false, "http", (await this.resolveAdvertisedListenPort(null)).port), this.config.viewerBaseUrls);
226
+ return {
227
+ baseUrls: this.config.viewerBaseUrls,
228
+ resolved: selected.map((e) => e.baseUrl)
229
+ };
230
+ }
231
+ /**
232
+ * Palette shown in the Network tab: LAN IPv4, stable LAN IPv6, public,
233
+ * mesh. The viewer HTTP/WS race is a subset (`selectViewerEndpoints`) —
234
+ * IPv6 stays in this list because ICE/WebRTC gathers it regardless.
235
+ */
236
+ async buildAdvertisedEndpoints(includeLoopback, ipv4Only, scheme, httpsPort) {
237
+ const allow = this.config.allowedAddresses;
238
+ const interfaces = applyAllowlist(this.enumerate(), allow);
239
+ await this.reconcileMeshEndpoint();
240
+ const httpState = require_lan_http_bind.readLanHttpState();
241
+ return buildEndpoints(interfaces, httpsPort, includeLoopback, ipv4Only, this.publicHostname, scheme, this.meshEndpoint, {
242
+ enabled: this.config.httpEnabled !== false,
243
+ listening: httpState.listening,
244
+ httpPort: this.resolvedHttpPort(),
245
+ httpsPort,
246
+ localHostname: this.config.localHostname || null
247
+ });
248
+ }
113
249
  async onInitialize() {
114
250
  if (!this.config.bootSeeded) {
115
251
  const seed = autoSeedAllowlist(this.enumerate());
@@ -119,6 +255,7 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
119
255
  });
120
256
  this.ctx.logger.info("local-network: first-boot auto-seed", { meta: { addresses: seed } });
121
257
  }
258
+ await this.persistReconciledAllowlist();
122
259
  const provider = {
123
260
  list: async () => ({
124
261
  interfaces: this.enumerate(),
@@ -126,27 +263,16 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
126
263
  }),
127
264
  getPreferred: async () => pickPreferred(applyAllowlist(this.enumerate(), this.config.allowedAddresses)),
128
265
  getConnectionEndpoints: async (input) => {
129
- const includeLoopback = input.includeLoopback ?? true;
130
- const ipv4Only = input.ipv4Only ?? false;
266
+ const includeLoopback = input.includeLoopback ?? false;
131
267
  const scheme = input.scheme ?? "http";
132
- const allow = this.config.allowedAddresses;
133
- const interfaces = applyAllowlist(this.enumerate(), allow);
134
268
  const localPort = await this.resolveAdvertisedListenPort(input.port ?? null);
135
269
  if (input.port !== void 0 && input.port !== localPort.port) this.ctx.logger.info("local-network: caller port hint overridden", { meta: {
136
270
  hint: input.port,
137
271
  advertised: localPort.port,
138
272
  source: localPort.source
139
273
  } });
140
- await this.reconcileMeshEndpoint();
141
- const httpState = require_lan_http_bind.readLanHttpState();
142
274
  return {
143
- endpoints: buildEndpoints(interfaces, localPort.port, includeLoopback, ipv4Only, this.publicHostname, scheme, this.meshEndpoint, {
144
- enabled: this.config.httpEnabled !== false,
145
- listening: httpState.listening,
146
- httpPort: this.resolvedHttpPort(),
147
- httpsPort: localPort.port,
148
- localHostname: this.config.localHostname || null
149
- }),
275
+ endpoints: await this.buildAdvertisedEndpoints(includeLoopback, input.ipv4Only ?? false, scheme, localPort.port),
150
276
  localPort
151
277
  };
152
278
  },
@@ -162,6 +288,16 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
162
288
  resolved: this.resolveNotificationEndpoint()
163
289
  };
164
290
  },
291
+ getViewerEndpoints: async () => this.snapshotViewerEndpoints(),
292
+ setViewerEndpoints: async ({ baseUrls }) => {
293
+ const cleaned = [...new Set(baseUrls.filter((u) => u.length > 0))];
294
+ await this.updateGlobalSettings({ viewerBaseUrls: cleaned });
295
+ this.ctx.logger.info("local-network: viewer endpoints set", { meta: {
296
+ count: cleaned.length,
297
+ auto: cleaned.length === 0
298
+ } });
299
+ return this.snapshotViewerEndpoints();
300
+ },
165
301
  getAllowedAddresses: async () => ({ addresses: this.config.allowedAddresses }),
166
302
  resetAllowlistToBestMatch: async () => {
167
303
  const seed = autoSeedAllowlist(this.enumerate());
@@ -213,6 +349,10 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
213
349
  this.lastSnapshotKey = snapshotKey(this.enumerate());
214
350
  this.pollTimer = setInterval(() => this.detectChanges(), POLL_INTERVAL_MS);
215
351
  this.pollTimer.unref?.();
352
+ this.allowlistTimer = setInterval(() => {
353
+ this.persistReconciledAllowlist();
354
+ }, ALLOWLIST_RECONCILE_INTERVAL_MS);
355
+ this.allowlistTimer.unref?.();
216
356
  this.ctx.eventBus?.subscribe({ category: require_dist.EventCategory.NetworkTunnelStarted }, (event) => {
217
357
  const data = event.data ?? {};
218
358
  if (typeof data.url === "string") try {
@@ -288,6 +428,10 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
288
428
  clearInterval(this.pollTimer);
289
429
  this.pollTimer = null;
290
430
  }
431
+ if (this.allowlistTimer) {
432
+ clearInterval(this.allowlistTimer);
433
+ this.allowlistTimer = null;
434
+ }
291
435
  }
292
436
  /**
293
437
  * Other hub addons (e.g. cloudflare-tunnel) signal the active public
@@ -421,7 +565,26 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
421
565
  }
422
566
  }
423
567
  enumerate() {
424
- return enumerateOsInterfaces(node_os.networkInterfaces());
568
+ return enumerateOsInterfaces(node_os.networkInterfaces(), readLinuxIpv6Flags());
569
+ }
570
+ /**
571
+ * Replace rotated IPv6 and drop addresses that no longer exist, without
572
+ * clearing operator extras that are still live (docker/vpn opt-in).
573
+ */
574
+ async persistReconciledAllowlist(interfaces = this.enumerate()) {
575
+ if (this.allowlistReconcilePending) return;
576
+ this.allowlistReconcilePending = true;
577
+ try {
578
+ const next = reconcileAllowlist(this.config.allowedAddresses, interfaces);
579
+ if (sameAddressSet(this.config.allowedAddresses, next)) return;
580
+ await this.updateGlobalSettings({
581
+ allowedAddresses: next,
582
+ bootSeeded: true
583
+ });
584
+ this.ctx.logger.info("local-network: allowlist reconciled", { meta: { addresses: next } });
585
+ } finally {
586
+ this.allowlistReconcilePending = false;
587
+ }
425
588
  }
426
589
  detectChanges() {
427
590
  const interfaces = this.enumerate();
@@ -432,6 +595,7 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
432
595
  key
433
596
  } });
434
597
  this.lastSnapshotKey = key;
598
+ this.persistReconciledAllowlist(interfaces);
435
599
  this.ctx.eventBus?.emit({
436
600
  id: `local-network-changed-${Date.now()}`,
437
601
  timestamp: /* @__PURE__ */ new Date(),
@@ -480,7 +644,7 @@ function validPort(value) {
480
644
  if (value === null) return null;
481
645
  return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : null;
482
646
  }
483
- function enumerateOsInterfaces(ifaces) {
647
+ function enumerateOsInterfaces(ifaces, ipv6Flags) {
484
648
  const raw = [];
485
649
  for (const [name, addrs] of Object.entries(ifaces)) {
486
650
  if (!addrs) continue;
@@ -500,8 +664,12 @@ function enumerateOsInterfaces(ifaces) {
500
664
  const classified = raw.map((r) => {
501
665
  const kind = classifyKind(r.name, r.address, r.internal);
502
666
  const reachableKind = kind === "lan" || kind === "wifi";
503
- const plausible = !r.internal && reachableKind && isPlausibleAutoSeed(r.address, r.family);
504
- const plausibleReason = plausible ? "" : explainNonPlausible({
667
+ const hex = r.family === "IPv6" ? ipv6ToHex32(r.address) : null;
668
+ const flags = hex !== null ? ipv6Flags?.get(hex) : void 0;
669
+ const rangeOk = isPlausibleAutoSeed(r.address, r.family);
670
+ const stableV6 = r.family !== "IPv6" || shouldAdvertiseIpv6(r.address, flags);
671
+ const plausible = !r.internal && reachableKind && rangeOk && stableV6;
672
+ const plausibleReason = plausible ? "" : r.family === "IPv6" && rangeOk && !stableV6 ? "IPv6 temporary / privacy address — rotates and is not advertised." : explainNonPlausible({
505
673
  kind,
506
674
  family: r.family,
507
675
  address: r.address,
@@ -530,13 +698,14 @@ function enumerateOsInterfaces(ifaces) {
530
698
  /**
531
699
  * Filter the interface list by the operator's allowlist. Empty
532
700
  * 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.
701
+ * whose `address` matches an allowlist entry remain. IPv6 matches the
702
+ * allowlisted /64 so a rotated privacy address does not hide the live
703
+ * GUA. Loopback is not an advertised client route and is not forced
704
+ * through.
535
705
  */
536
706
  function applyAllowlist(interfaces, allowed) {
537
707
  if (allowed.length === 0) return interfaces;
538
- const set = new Set(allowed);
539
- return interfaces.filter((i) => i.kind === "loopback" || set.has(i.address));
708
+ return interfaces.filter((i) => ipv6AllowlistMatches(i.address, allowed) || allowed.includes(i.address));
540
709
  }
541
710
  /**
542
711
  * Rank interfaces and pick the auto-selected outbound one. See the
@@ -595,6 +764,13 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
595
764
  if (iface.address.startsWith("169.254.")) continue;
596
765
  emit(iface, "lan-ipv4", `${formatKind(iface.kind)} — ${iface.name}`, `${lanScheme}://${iface.address}:${lanPort}`, 10 + priority++);
597
766
  }
767
+ if (!ipv4Only) for (const iface of interfaces) {
768
+ if (iface.internal || iface.family !== "IPv6") continue;
769
+ if (iface.kind === "loopback") continue;
770
+ if (iface.address.toLowerCase().startsWith("fe80:")) continue;
771
+ if (!iface.plausible) continue;
772
+ emit(iface, "lan-ipv6", `${formatKind(iface.kind)} — ${iface.name} (IPv6)`, `${lanScheme}://[${iface.address}]:${lanPort}`, 10 + priority++);
773
+ }
598
774
  if (publicHostname) out.push({
599
775
  label: "Public tunnel",
600
776
  baseUrl: `https://${publicHostname}`,
@@ -613,15 +789,6 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
613
789
  plausibleReason: "",
614
790
  priority: 150
615
791
  });
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
792
  if (includeLoopback) out.push({
626
793
  label: "Loopback",
627
794
  baseUrl: `${scheme}://127.0.0.1:${port}`,
@@ -633,22 +800,39 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
633
800
  });
634
801
  return out.toSorted((a, b) => a.priority - b.priority);
635
802
  }
803
+ /** AUTO viewer race: LAN IPv4 + the public tunnel. Never IPv6, mesh, or loopback. */
804
+ function isDefaultViewerEndpoint(endpoint) {
805
+ if (endpoint.kind === "lan-ipv4") return true;
806
+ return endpoint.kind === "public" && endpoint.interfaceKind === "public";
807
+ }
808
+ /**
809
+ * The URLs the SDK should race for API / viewer access.
810
+ *
811
+ * Empty `viewerBaseUrls` = AUTO (IPv4 + public tunnel). A non-empty list is
812
+ * the operator's pin; if none of those URLs are in the current palette,
813
+ * fall back to AUTO rather than racing an empty set.
814
+ */
815
+ function selectViewerEndpoints(endpoints, viewerBaseUrls) {
816
+ const auto = endpoints.filter(isDefaultViewerEndpoint);
817
+ if (viewerBaseUrls.length === 0) return auto;
818
+ const wanted = new Set(viewerBaseUrls);
819
+ const explicit = endpoints.filter((e) => wanted.has(e.baseUrl));
820
+ return explicit.length > 0 ? explicit : auto;
821
+ }
636
822
  /**
637
823
  * First-boot heuristic: which addresses should the allowlist start
638
824
  * with? Includes LAN + Wi-Fi IPv4 addresses + plausible IPv6:
639
825
  *
640
826
  * - **IPv4**: skip link-local (`169.254.*`), keep the rest.
641
827
  * - **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.
828
+ * multicast. Keep ULAs (`fc00::/7`) and Global Unicast (`2000::/3`).
829
+ * Privacy-extension temporary addresses are skipped when Linux flags
830
+ * mark them non-permanent (`plausible === false`).
647
831
  *
648
832
  * Skips docker/vpn/loopback/other entirely — those stay opt-in.
649
833
  */
650
834
  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))];
835
+ 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
836
  }
653
837
  /**
654
838
  * Per-interface tooltip text surfaced on the "Unlikely usable" badge.
@@ -737,7 +921,33 @@ function formatKind(kind) {
737
921
  case "other": return "Other";
738
922
  }
739
923
  }
924
+ /**
925
+ * Rewrite a persisted allowlist against the live interface set:
926
+ * current auto-seed (LAN/Wi-Fi, stable IPv6) plus still-live operator
927
+ * extras (docker/vpn). Empty stays empty — that is "unrestricted".
928
+ */
929
+ function reconcileAllowlist(current, interfaces) {
930
+ if (current.length === 0) return [];
931
+ const live = new Set(interfaces.map((i) => i.address));
932
+ const seed = autoSeedAllowlist(interfaces);
933
+ const seedSet = new Set(seed);
934
+ const extras = current.filter((a) => live.has(a) && !seedSet.has(a) && !a.includes(":"));
935
+ return [...new Set([...seed, ...extras])];
936
+ }
937
+ function sameAddressSet(a, b) {
938
+ if (a.length !== b.length) return false;
939
+ const set = new Set(a);
940
+ return b.every((addr) => set.has(addr));
941
+ }
942
+ function readLinuxIpv6Flags() {
943
+ try {
944
+ return parseProcIfInet6((0, node_fs.readFileSync)("/proc/net/if_inet6", "utf8"));
945
+ } catch {
946
+ return;
947
+ }
948
+ }
740
949
  //#endregion
950
+ exports.ALLOWLIST_RECONCILE_INTERVAL_MS = ALLOWLIST_RECONCILE_INTERVAL_MS;
741
951
  exports.LocalNetworkAddon = LocalNetworkAddon;
742
952
  exports.applyAllowlist = applyAllowlist;
743
953
  exports.autoSeedAllowlist = autoSeedAllowlist;
@@ -745,7 +955,13 @@ exports.buildEndpoints = buildEndpoints;
745
955
  exports.classifyKind = classifyKind;
746
956
  exports.enumerateOsInterfaces = enumerateOsInterfaces;
747
957
  exports.explainNonPlausible = explainNonPlausible;
958
+ exports.ipv6Prefix64Key = ipv6Prefix64Key;
959
+ exports.isDefaultViewerEndpoint = isDefaultViewerEndpoint;
748
960
  exports.isPlausibleAutoSeed = isPlausibleAutoSeed;
961
+ exports.parseProcIfInet6 = parseProcIfInet6;
749
962
  exports.pickPreferred = pickPreferred;
750
963
  exports.prefixLen = prefixLen;
964
+ exports.reconcileAllowlist = reconcileAllowlist;
751
965
  exports.resolveAdvertisedPort = resolveAdvertisedPort;
966
+ exports.selectViewerEndpoints = selectViewerEndpoints;
967
+ exports.shouldAdvertiseIpv6 = shouldAdvertiseIpv6;