@camstack/system 1.2.115 → 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 (62) 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-hostname.d.ts +15 -0
  25. package/dist/builtins/local-network/local-network.addon.d.ts +59 -21
  26. package/dist/builtins/local-network/local-network.addon.js +369 -50
  27. package/dist/builtins/local-network/local-network.addon.mjs +365 -51
  28. package/dist/builtins/loki-logging/index.js +1 -1
  29. package/dist/builtins/loki-logging/index.mjs +1 -1
  30. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  31. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  32. package/dist/builtins/platform-probe/index.js +1 -1
  33. package/dist/builtins/platform-probe/index.mjs +1 -1
  34. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  35. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  36. package/dist/builtins/snapshot/index.js +1 -1
  37. package/dist/builtins/snapshot/index.mjs +1 -1
  38. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  39. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  40. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +2 -2
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +2 -2
  42. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  44. package/dist/builtins/system-config/system-config.addon.js +1 -1
  45. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  46. package/dist/builtins/winston-logging/index.js +1 -1
  47. package/dist/builtins/winston-logging/index.mjs +1 -1
  48. package/dist/{dist-Drb54SZD.mjs → dist-D4Vmj8iw.mjs} +151 -9
  49. package/dist/{dist-iAwSA2_f.js → dist-DMEGlqg6.js} +151 -9
  50. package/dist/http/lan-http-bind.d.ts +30 -0
  51. package/dist/index.d.ts +1 -0
  52. package/dist/index.js +30 -472
  53. package/dist/index.mjs +11 -468
  54. package/dist/lan-http-bind-DmgpFP6_.js +890 -0
  55. package/dist/lan-http-bind-jKrj6OjQ.mjs +752 -0
  56. package/dist/{retired-settings-keys-Dz5-7gjt.mjs → retired-settings-keys--aM_vGF9.mjs} +1 -1
  57. package/dist/{retired-settings-keys-OfhqQio4.js → retired-settings-keys-DET4YkAU.js} +1 -1
  58. package/dist/tls/cert-manager.d.ts +11 -0
  59. package/dist/tls/index.d.ts +3 -1
  60. package/dist/tls/tls-status.d.ts +16 -0
  61. package/dist/tls/uploaded-cert.d.ts +14 -0
  62. package/package.json +1 -1
@@ -1,5 +1,149 @@
1
- import { Gt as EventCategory, Y as localNetworkCapability, yt as BaseAddon } from "../../dist-Drb54SZD.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
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
2
5
  import * as os from "node:os";
6
+ import { readFileSync } from "node:fs";
7
+ import { isIP } from "node:net";
8
+ //#region src/builtins/local-network/local-hostname.ts
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])$/;
10
+ /**
11
+ * Parse the operator-typed local hostname.
12
+ *
13
+ * Empty / whitespace → `null` (advertise the raw LAN IP). A DNS name is
14
+ * lowercased. A URL, port suffix, path, or bare IP is rejected so it cannot
15
+ * leak into a SAN or a candidate URL.
16
+ */
17
+ function parseLocalHostname(raw) {
18
+ const trimmed = raw.trim();
19
+ if (trimmed === "") return {
20
+ ok: true,
21
+ value: null
22
+ };
23
+ if (/[\s/:?#]/.test(trimmed) || trimmed.includes("\\")) return {
24
+ ok: false,
25
+ error: "Enter a DNS name such as camstack.lan, without a scheme or port."
26
+ };
27
+ const lowered = trimmed.toLowerCase();
28
+ if (isIP(lowered) !== 0) return {
29
+ ok: false,
30
+ error: "Use the address allowlist for IPs — this field is a DNS name."
31
+ };
32
+ if (!DNS_NAME.test(lowered)) return {
33
+ ok: false,
34
+ error: "Enter a DNS name such as camstack.lan, without a scheme or port."
35
+ };
36
+ return {
37
+ ok: true,
38
+ value: lowered
39
+ };
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
3
147
  //#region src/builtins/local-network/local-network.addon.ts
4
148
  /**
5
149
  * local-network — hub-only system cap implementation.
@@ -25,6 +169,8 @@ import * as os from "node:os";
25
169
  */
26
170
  var MESH_PROVIDER_ADDON_IDS = ["tailscale-client"];
27
171
  var POLL_INTERVAL_MS = 3e4;
172
+ /** Drop rotated IPv6 / dead extras from the persisted allowlist. */
173
+ var ALLOWLIST_RECONCILE_INTERVAL_MS = 3600 * 1e3;
28
174
  /**
29
175
  * Compiled-in boot default — mirrors `CONFIG_DEFAULTS.server.port` in
30
176
  * `server/backend/src/boot/boot-config.ts`. Used only when neither the live
@@ -33,6 +179,8 @@ var POLL_INTERVAL_MS = 3e4;
33
179
  var DEFAULT_HUB_PORT = 4443;
34
180
  var LocalNetworkAddon = class extends BaseAddon {
35
181
  pollTimer = null;
182
+ allowlistTimer = null;
183
+ allowlistReconcilePending = false;
36
184
  lastSnapshotKey = "";
37
185
  /** Optional public hostname tracked from `NetworkTunnelStarted`/
38
186
  * `Stopped` events on the bus. */
@@ -48,7 +196,10 @@ var LocalNetworkAddon = class extends BaseAddon {
48
196
  super({
49
197
  allowedAddresses: [],
50
198
  bootSeeded: false,
51
- notificationBaseUrl: ""
199
+ notificationBaseUrl: "",
200
+ httpEnabled: true,
201
+ httpPort: DEFAULT_LAN_HTTP_PORT,
202
+ localHostname: ""
52
203
  });
53
204
  }
54
205
  /**
@@ -75,6 +226,7 @@ var LocalNetworkAddon = class extends BaseAddon {
75
226
  });
76
227
  this.ctx.logger.info("local-network: first-boot auto-seed", { meta: { addresses: seed } });
77
228
  }
229
+ await this.persistReconciledAllowlist();
78
230
  const provider = {
79
231
  list: async () => ({
80
232
  interfaces: this.enumerate(),
@@ -94,8 +246,15 @@ var LocalNetworkAddon = class extends BaseAddon {
94
246
  source: localPort.source
95
247
  } });
96
248
  await this.reconcileMeshEndpoint();
249
+ const httpState = readLanHttpState();
97
250
  return {
98
- endpoints: buildEndpoints(interfaces, localPort.port, includeLoopback, ipv4Only, this.publicHostname, scheme, this.meshEndpoint),
251
+ endpoints: buildEndpoints(interfaces, localPort.port, includeLoopback, ipv4Only, this.publicHostname, scheme, this.meshEndpoint, {
252
+ enabled: this.config.httpEnabled !== false,
253
+ listening: httpState.listening,
254
+ httpPort: this.resolvedHttpPort(),
255
+ httpsPort: localPort.port,
256
+ localHostname: this.config.localHostname || null
257
+ }),
99
258
  localPort
100
259
  };
101
260
  },
@@ -130,11 +289,42 @@ var LocalNetworkAddon = class extends BaseAddon {
130
289
  dropped: addresses.length - cleaned.length
131
290
  } });
132
291
  return { success: true };
292
+ },
293
+ getTlsStatus: async () => this.snapshotTls(false),
294
+ regenerateCertificate: async (_input) => {
295
+ if (readTlsMode(this.tlsDataDir()) === "uploaded") throw new Error("Cannot regenerate an uploaded certificate — revert to generated first.");
296
+ const extraSans = [...readExtraSans(this.tlsDataDir())];
297
+ await reissueTlsLeaf(this.tlsDataDir(), { extraSans });
298
+ return this.snapshotTls(true);
299
+ },
300
+ uploadCertificate: async ({ certPem, keyPem, caPem }) => {
301
+ const valid = validateUploadedTls(certPem, keyPem);
302
+ if (!valid.ok) throw new Error(valid.error);
303
+ const dir = join(this.tlsDataDir(), "tls");
304
+ await mkdir(dir, { recursive: true });
305
+ await writeFile(join(dir, "camstack.crt"), certPem.endsWith("\n") ? certPem : `${certPem}\n`);
306
+ await writeFile(join(dir, "camstack.key"), keyPem.endsWith("\n") ? keyPem : `${keyPem}\n`, { mode: 384 });
307
+ if (caPem && caPem.trim() !== "") await writeFile(join(dir, "camstack-ca.crt"), caPem.endsWith("\n") ? caPem : `${caPem}\n`);
308
+ writeTlsMode(this.tlsDataDir(), "uploaded");
309
+ return this.snapshotTls(true);
310
+ },
311
+ downloadCa: async () => {
312
+ return { pem: this.snapshotTls(false).caCertPem ?? "" };
313
+ },
314
+ revertToGeneratedCertificate: async () => {
315
+ writeTlsMode(this.tlsDataDir(), "generated");
316
+ const extraSans = [...readExtraSans(this.tlsDataDir())];
317
+ await ensureTlsCert(this.tlsDataDir(), { extraSans });
318
+ return this.snapshotTls(true);
133
319
  }
134
320
  };
135
321
  this.lastSnapshotKey = snapshotKey(this.enumerate());
136
322
  this.pollTimer = setInterval(() => this.detectChanges(), POLL_INTERVAL_MS);
137
323
  this.pollTimer.unref?.();
324
+ this.allowlistTimer = setInterval(() => {
325
+ this.persistReconciledAllowlist();
326
+ }, ALLOWLIST_RECONCILE_INTERVAL_MS);
327
+ this.allowlistTimer.unref?.();
138
328
  this.ctx.eventBus?.subscribe({ category: EventCategory.NetworkTunnelStarted }, (event) => {
139
329
  const data = event.data ?? {};
140
330
  if (typeof data.url === "string") try {
@@ -153,16 +343,67 @@ var LocalNetworkAddon = class extends BaseAddon {
153
343
  } : null);
154
344
  });
155
345
  this.ctx.logger.info("local-network initialized", { meta: { interfaceCount: this.enumerate().length } });
346
+ try {
347
+ await this.applyLanAccessFromSettings();
348
+ } catch (err) {
349
+ this.ctx.logger.warn("local-network: LAN HTTP not applied at boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
350
+ }
156
351
  return [{
157
352
  capability: localNetworkCapability,
158
353
  provider
159
354
  }];
160
355
  }
356
+ globalSettingsSchema() {
357
+ return this.schema({ sections: [{
358
+ id: "local-access",
359
+ title: "LAN HTTP",
360
+ description: "Native apps on this Wi-Fi use http:// and skip the private certificate. The tunnel and this admin UI stay on HTTPS. Turn off once a publicly-trusted certificate covers the LAN name.",
361
+ columns: 1,
362
+ fields: [
363
+ this.field({
364
+ type: "boolean",
365
+ key: "httpEnabled",
366
+ label: "Enable HTTP on the LAN",
367
+ style: "switch",
368
+ default: true
369
+ }),
370
+ this.field({
371
+ type: "number",
372
+ key: "httpPort",
373
+ label: "HTTP port",
374
+ description: "Dedicated listener. Must not equal the HTTPS port (4443 by default).",
375
+ min: 1,
376
+ max: 65535,
377
+ step: 1,
378
+ default: DEFAULT_LAN_HTTP_PORT,
379
+ showWhen: {
380
+ field: "httpEnabled",
381
+ equals: true
382
+ }
383
+ }),
384
+ this.field({
385
+ type: "text",
386
+ key: "localHostname",
387
+ label: "Local hostname",
388
+ description: "Advertised to the viewer instead of the raw LAN IP, and added to the certificate SAN. Requires this name to resolve on the LAN (router DNS, mDNS, or a hosts file). Leave empty to keep using the IP.",
389
+ maxLength: 253,
390
+ placeholder: "camstack.lan"
391
+ })
392
+ ]
393
+ }] });
394
+ }
395
+ async onConfigChanged() {
396
+ await this.applyLanAccessFromSettings();
397
+ }
161
398
  async onShutdown() {
162
399
  if (this.pollTimer) {
163
400
  clearInterval(this.pollTimer);
164
401
  this.pollTimer = null;
165
402
  }
403
+ if (this.allowlistTimer) {
404
+ clearInterval(this.allowlistTimer);
405
+ this.allowlistTimer = null;
406
+ }
166
407
  }
167
408
  /**
168
409
  * Other hub addons (e.g. cloudflare-tunnel) signal the active public
@@ -226,6 +467,42 @@ var LocalNetworkAddon = class extends BaseAddon {
226
467
  }
227
468
  }
228
469
  }
470
+ resolvedHttpPort() {
471
+ const port = this.config.httpPort;
472
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : DEFAULT_LAN_HTTP_PORT;
473
+ }
474
+ tlsDataDir() {
475
+ return this.ctx.nodeDataDir ?? this.ctx.dataDir;
476
+ }
477
+ snapshotTls(restartRequired) {
478
+ const status = readTlsAccessStatus(this.tlsDataDir(), restartRequired);
479
+ return {
480
+ ...status,
481
+ sans: [...status.sans]
482
+ };
483
+ }
484
+ /**
485
+ * Bind or unbind the LAN HTTP sibling from the addon's own settings, and
486
+ * keep the leaf SAN in sync with `localHostname`. This is the settings
487
+ * side-effect path — there is no second persist API.
488
+ */
489
+ async applyLanAccessFromSettings() {
490
+ const parsed = parseLocalHostname(this.config.localHostname ?? "");
491
+ if (!parsed.ok) throw new Error(parsed.error);
492
+ const hostname = parsed.value ?? "";
493
+ const httpsPort = (await this.resolveAdvertisedListenPort(null)).port;
494
+ const httpPort = this.resolvedHttpPort();
495
+ if (this.config.httpEnabled !== false && httpPort === httpsPort) throw new Error(`HTTP port ${httpPort} cannot equal the HTTPS listen port`);
496
+ await applyLanHttp({
497
+ enabled: this.config.httpEnabled !== false,
498
+ port: httpPort,
499
+ host: "0.0.0.0"
500
+ });
501
+ if (hostname !== "") {
502
+ writeExtraSans(this.tlsDataDir(), [hostname]);
503
+ if (readTlsMode(this.tlsDataDir()) === "generated") await ensureTlsCert(this.tlsDataDir(), { extraSans: [hostname] });
504
+ } else writeExtraSans(this.tlsDataDir(), []);
505
+ }
229
506
  /**
230
507
  * The port this hub is actually listening on, plus the provenance of the
231
508
  * number. The boot loader merges the `CAMSTACK_PORT` env override into the
@@ -260,7 +537,26 @@ var LocalNetworkAddon = class extends BaseAddon {
260
537
  }
261
538
  }
262
539
  enumerate() {
263
- 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
+ }
264
560
  }
265
561
  detectChanges() {
266
562
  const interfaces = this.enumerate();
@@ -271,6 +567,7 @@ var LocalNetworkAddon = class extends BaseAddon {
271
567
  key
272
568
  } });
273
569
  this.lastSnapshotKey = key;
570
+ this.persistReconciledAllowlist(interfaces);
274
571
  this.ctx.eventBus?.emit({
275
572
  id: `local-network-changed-${Date.now()}`,
276
573
  timestamp: /* @__PURE__ */ new Date(),
@@ -319,7 +616,7 @@ function validPort(value) {
319
616
  if (value === null) return null;
320
617
  return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : null;
321
618
  }
322
- function enumerateOsInterfaces(ifaces) {
619
+ function enumerateOsInterfaces(ifaces, ipv6Flags) {
323
620
  const raw = [];
324
621
  for (const [name, addrs] of Object.entries(ifaces)) {
325
622
  if (!addrs) continue;
@@ -339,8 +636,12 @@ function enumerateOsInterfaces(ifaces) {
339
636
  const classified = raw.map((r) => {
340
637
  const kind = classifyKind(r.name, r.address, r.internal);
341
638
  const reachableKind = kind === "lan" || kind === "wifi";
342
- const plausible = !r.internal && reachableKind && isPlausibleAutoSeed(r.address, r.family);
343
- 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({
344
645
  kind,
345
646
  family: r.family,
346
647
  address: r.address,
@@ -369,13 +670,14 @@ function enumerateOsInterfaces(ifaces) {
369
670
  /**
370
671
  * Filter the interface list by the operator's allowlist. Empty
371
672
  * allowlist = no-op (every interface passes); otherwise only entries
372
- * whose `address` matches an allowlist entry remain. Loopback always
373
- * 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.
374
677
  */
375
678
  function applyAllowlist(interfaces, allowed) {
376
679
  if (allowed.length === 0) return interfaces;
377
- const set = new Set(allowed);
378
- 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));
379
681
  }
380
682
  /**
381
683
  * Rank interfaces and pick the auto-selected outbound one. See the
@@ -399,31 +701,13 @@ function pickPreferred(interfaces) {
399
701
  return prefixLen(b.netmask) - prefixLen(a.netmask);
400
702
  })[0] ?? null;
401
703
  }
402
- /**
403
- * Build the ordered candidate URL list. Priority schema:
404
- * 0 — preferred LAN IPv4
405
- * 10+ — other LAN IPv4
406
- * 100 — public tunnel hostname (always HTTPS)
407
- * 150 — mesh endpoint (Tailscale MagicDNS / 100.x, always HTTPS)
408
- * 200+ — LAN IPv6
409
- * 1000 — loopback (last resort)
410
- *
411
- * The mesh endpoint ranks just below a true public funnel (a Funnel is
412
- * reachable by ANY client; the mesh needs the peer on the same tailnet)
413
- * but above the IPv6 / loopback fallbacks — giving remote, mesh-joined
414
- * clients a working candidate when no public tunnel is up.
415
- *
416
- * `scheme` controls LAN + loopback URLs. Browsers running over HTTPS
417
- * block `http://` candidates as mixed content, so callers loaded over
418
- * HTTPS should pass `scheme: 'https'` even when probing a LAN IP — the
419
- * hub's cert manager already issues a SAN-multi cert covering local
420
- * interfaces. The public tunnel + mesh endpoint always emit `https://`
421
- * (the tunnel edge / the hub itself terminates TLS).
422
- */
423
- function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostname, scheme = "http", meshEndpoint = null) {
704
+ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostname, scheme = "http", meshEndpoint = null, lanHttp = null) {
424
705
  const out = [];
425
706
  let priority = 0;
426
707
  const preferred = pickPreferred(interfaces);
708
+ const httpLan = lanHttp?.enabled === true && lanHttp.listening === true;
709
+ const lanScheme = httpLan ? "http" : scheme;
710
+ const lanPort = httpLan ? lanHttp.httpPort : port;
427
711
  const emit = (iface, kind, label, baseUrl, pri) => {
428
712
  out.push({
429
713
  label,
@@ -435,13 +719,29 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
435
719
  priority: pri
436
720
  });
437
721
  };
438
- if (preferred) emit(preferred, "lan-ipv4", `${formatKind(preferred.kind)} — ${preferred.name}`, `${scheme}://${preferred.address}:${port}`, priority++);
722
+ if (httpLan && lanHttp.localHostname) out.push({
723
+ label: "Local name",
724
+ baseUrl: `${lanScheme}://${lanHttp.localHostname}:${lanPort}`,
725
+ kind: "lan-ipv4",
726
+ interfaceKind: "lan",
727
+ plausible: true,
728
+ plausibleReason: "",
729
+ priority: priority++
730
+ });
731
+ if (preferred) emit(preferred, "lan-ipv4", `${formatKind(preferred.kind)} — ${preferred.name}`, `${lanScheme}://${preferred.address}:${lanPort}`, priority++);
439
732
  for (const iface of interfaces) {
440
733
  if (iface.internal || iface.family !== "IPv4") continue;
441
734
  if (iface.kind === "loopback") continue;
442
735
  if (preferred && iface.name === preferred.name && iface.address === preferred.address) continue;
443
736
  if (iface.address.startsWith("169.254.")) continue;
444
- emit(iface, "lan-ipv4", `${formatKind(iface.kind)} — ${iface.name}`, `${scheme}://${iface.address}:${port}`, 10 + priority++);
737
+ emit(iface, "lan-ipv4", `${formatKind(iface.kind)} — ${iface.name}`, `${lanScheme}://${iface.address}:${lanPort}`, 10 + priority++);
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++);
445
745
  }
446
746
  if (publicHostname) out.push({
447
747
  label: "Public tunnel",
@@ -461,15 +761,6 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
461
761
  plausibleReason: "",
462
762
  priority: 150
463
763
  });
464
- if (!ipv4Only) {
465
- let v6prio = 200;
466
- for (const iface of interfaces) {
467
- if (iface.internal || iface.family !== "IPv6") continue;
468
- if (iface.kind === "loopback") continue;
469
- if (iface.address.startsWith("fe80:")) continue;
470
- emit(iface, "lan-ipv6", `${formatKind(iface.kind)} — ${iface.name} (IPv6)`, `${scheme}://[${iface.address}]:${port}`, v6prio++);
471
- }
472
- }
473
764
  if (includeLoopback) out.push({
474
765
  label: "Loopback",
475
766
  baseUrl: `${scheme}://127.0.0.1:${port}`,
@@ -487,16 +778,14 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
487
778
  *
488
779
  * - **IPv4**: skip link-local (`169.254.*`), keep the rest.
489
780
  * - **IPv6**: skip link-local (`fe80::*`), unspecified, and
490
- * multicast. Keep ULAs (`fc00::/7` `fc??:` / `fd??:`) and Global
491
- * Unicast addresses (`2000::/3` `2???`/`3???`). Privacy-extension
492
- * temporary addresses get included by default; the operator can
493
- * prune them from the Network Addresses tab if the rotating IPs
494
- * 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`).
495
784
  *
496
785
  * Skips docker/vpn/loopback/other entirely — those stay opt-in.
497
786
  */
498
787
  function autoSeedAllowlist(interfaces) {
499
- 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))];
500
789
  }
501
790
  /**
502
791
  * Per-interface tooltip text surfaced on the "Unlikely usable" badge.
@@ -585,5 +874,30 @@ function formatKind(kind) {
585
874
  case "other": return "Other";
586
875
  }
587
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
+ }
588
902
  //#endregion
589
- 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 };
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-iAwSA2_f.js");
6
+ const require_dist = require("../../dist-DMEGlqg6.js");
7
7
  //#region src/builtins/loki-logging/loki-payload.ts
8
8
  /**
9
9
  * Loki rejects a label name that is not a valid Prometheus label
@@ -1,4 +1,4 @@
1
- import { X as logDestinationCapability, yt as BaseAddon } from "../../dist-Drb54SZD.mjs";
1
+ import { X as logDestinationCapability, yt as BaseAddon } from "../../dist-D4Vmj8iw.mjs";
2
2
  //#region src/builtins/loki-logging/loki-payload.ts
3
3
  /**
4
4
  * Loki rejects a label name that is not a valid Prometheus label
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-iAwSA2_f.js");
6
+ const require_dist = require("../../dist-DMEGlqg6.js");
7
7
  let node_child_process = require("node:child_process");
8
8
  let node_util = require("node:util");
9
9
  let node_os = require("node:os");
@@ -1,4 +1,4 @@
1
- import { $ as metricsProviderCapability, Gt as EventCategory, Mt as createEvent, yt as BaseAddon } from "../../dist-Drb54SZD.mjs";
1
+ import { $ as metricsProviderCapability, Gt as EventCategory, Mt as createEvent, yt as BaseAddon } from "../../dist-D4Vmj8iw.mjs";
2
2
  import { execFile, execFileSync } from "node:child_process";
3
3
  import { promisify } from "node:util";
4
4
  import * as os from "node:os";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-iAwSA2_f.js");
6
+ const require_dist = require("../../dist-DMEGlqg6.js");
7
7
  let node_child_process = require("node:child_process");
8
8
  let node_util = require("node:util");
9
9
  let node_os = require("node:os");
@@ -1,5 +1,5 @@
1
1
  import { o as __toESM } from "../../chunk-CNf5ZN-e.mjs";
2
- import { Gt as EventCategory, L as enumerateInferenceDevices, Pt as emitReadiness, ct as scoreRuntimes, rt as platformProbeCapability, vt as errMsg, yt as BaseAddon } from "../../dist-Drb54SZD.mjs";
2
+ import { Gt as EventCategory, L as enumerateInferenceDevices, Pt as emitReadiness, ct as scoreRuntimes, rt as platformProbeCapability, vt as errMsg, yt as BaseAddon } from "../../dist-D4Vmj8iw.mjs";
3
3
  import { execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
5
  import * as os from "node:os";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-iAwSA2_f.js");
6
+ const require_dist = require("../../dist-DMEGlqg6.js");
7
7
  //#region src/builtins/remote-access-orchestrator/enabled-providers-reconcile.ts
8
8
  /**
9
9
  * Reconcile the durable `enabledProviders` set against authoritative
@@ -1,4 +1,4 @@
1
- import { Gt as EventCategory, yt as BaseAddon } from "../../dist-Drb54SZD.mjs";
1
+ import { Gt as EventCategory, yt as BaseAddon } from "../../dist-D4Vmj8iw.mjs";
2
2
  //#region src/builtins/remote-access-orchestrator/enabled-providers-reconcile.ts
3
3
  /**
4
4
  * Reconcile the durable `enabledProviders` set against authoritative
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-iAwSA2_f.js");
6
+ const require_dist = require("../../dist-DMEGlqg6.js");
7
7
  let zod = require("zod");
8
8
  let node_crypto = require("node:crypto");
9
9
  let _camstack_types_node = require("@camstack/types/node");
@@ -1,4 +1,4 @@
1
- import { Ct as DeviceFeature, Gt as EventCategory, M as deriveBatteryPresence, Rt as nodePin, T as batteryCapability, Tt as DeviceType, i as BatteryStatusSchema, mt as streamQualityLabel, ut as snapshotCapability, vt as errMsg, w as bareAddonId, yt as BaseAddon } from "../../dist-Drb54SZD.mjs";
1
+ import { Ct as DeviceFeature, Gt as EventCategory, M as deriveBatteryPresence, Rt as nodePin, T as batteryCapability, Tt as DeviceType, i as BatteryStatusSchema, mt as streamQualityLabel, ut as snapshotCapability, vt as errMsg, w as bareAddonId, yt as BaseAddon } from "../../dist-D4Vmj8iw.mjs";
2
2
  import { z } from "zod";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { signExpiringUrl, verifyExpiringUrl } from "@camstack/types/node";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-iAwSA2_f.js");
6
+ const require_dist = require("../../dist-DMEGlqg6.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let node_fs_promises = require("node:fs/promises");
9
9
  let node_path = require("node:path");