@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,8 +1,152 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../../chunk-Cek0wNdY.js");
3
- const require_dist = require("../../dist-iAwSA2_f.js");
3
+ const require_dist = require("../../dist-DMEGlqg6.js");
4
+ const require_lan_http_bind = require("../../lan-http-bind-DmgpFP6_.js");
5
+ let node_fs_promises = require("node:fs/promises");
6
+ let node_path = require("node:path");
4
7
  let node_os = require("node:os");
5
8
  node_os = require_chunk.__toESM(node_os);
9
+ let node_fs = require("node:fs");
10
+ let node_net = require("node:net");
11
+ //#region src/builtins/local-network/local-hostname.ts
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])$/;
13
+ /**
14
+ * Parse the operator-typed local hostname.
15
+ *
16
+ * Empty / whitespace → `null` (advertise the raw LAN IP). A DNS name is
17
+ * lowercased. A URL, port suffix, path, or bare IP is rejected so it cannot
18
+ * leak into a SAN or a candidate URL.
19
+ */
20
+ function parseLocalHostname(raw) {
21
+ const trimmed = raw.trim();
22
+ if (trimmed === "") return {
23
+ ok: true,
24
+ value: null
25
+ };
26
+ if (/[\s/:?#]/.test(trimmed) || trimmed.includes("\\")) return {
27
+ ok: false,
28
+ error: "Enter a DNS name such as camstack.lan, without a scheme or port."
29
+ };
30
+ const lowered = trimmed.toLowerCase();
31
+ if ((0, node_net.isIP)(lowered) !== 0) return {
32
+ ok: false,
33
+ error: "Use the address allowlist for IPs — this field is a DNS name."
34
+ };
35
+ if (!DNS_NAME.test(lowered)) return {
36
+ ok: false,
37
+ error: "Enter a DNS name such as camstack.lan, without a scheme or port."
38
+ };
39
+ return {
40
+ ok: true,
41
+ value: lowered
42
+ };
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
6
150
  //#region src/builtins/local-network/local-network.addon.ts
7
151
  /**
8
152
  * local-network — hub-only system cap implementation.
@@ -28,6 +172,8 @@ node_os = require_chunk.__toESM(node_os);
28
172
  */
29
173
  var MESH_PROVIDER_ADDON_IDS = ["tailscale-client"];
30
174
  var POLL_INTERVAL_MS = 3e4;
175
+ /** Drop rotated IPv6 / dead extras from the persisted allowlist. */
176
+ var ALLOWLIST_RECONCILE_INTERVAL_MS = 3600 * 1e3;
31
177
  /**
32
178
  * Compiled-in boot default — mirrors `CONFIG_DEFAULTS.server.port` in
33
179
  * `server/backend/src/boot/boot-config.ts`. Used only when neither the live
@@ -36,6 +182,8 @@ var POLL_INTERVAL_MS = 3e4;
36
182
  var DEFAULT_HUB_PORT = 4443;
37
183
  var LocalNetworkAddon = class extends require_dist.BaseAddon {
38
184
  pollTimer = null;
185
+ allowlistTimer = null;
186
+ allowlistReconcilePending = false;
39
187
  lastSnapshotKey = "";
40
188
  /** Optional public hostname tracked from `NetworkTunnelStarted`/
41
189
  * `Stopped` events on the bus. */
@@ -51,7 +199,10 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
51
199
  super({
52
200
  allowedAddresses: [],
53
201
  bootSeeded: false,
54
- notificationBaseUrl: ""
202
+ notificationBaseUrl: "",
203
+ httpEnabled: true,
204
+ httpPort: require_lan_http_bind.DEFAULT_LAN_HTTP_PORT,
205
+ localHostname: ""
55
206
  });
56
207
  }
57
208
  /**
@@ -78,6 +229,7 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
78
229
  });
79
230
  this.ctx.logger.info("local-network: first-boot auto-seed", { meta: { addresses: seed } });
80
231
  }
232
+ await this.persistReconciledAllowlist();
81
233
  const provider = {
82
234
  list: async () => ({
83
235
  interfaces: this.enumerate(),
@@ -97,8 +249,15 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
97
249
  source: localPort.source
98
250
  } });
99
251
  await this.reconcileMeshEndpoint();
252
+ const httpState = require_lan_http_bind.readLanHttpState();
100
253
  return {
101
- endpoints: buildEndpoints(interfaces, localPort.port, includeLoopback, ipv4Only, this.publicHostname, scheme, this.meshEndpoint),
254
+ endpoints: buildEndpoints(interfaces, localPort.port, includeLoopback, ipv4Only, this.publicHostname, scheme, this.meshEndpoint, {
255
+ enabled: this.config.httpEnabled !== false,
256
+ listening: httpState.listening,
257
+ httpPort: this.resolvedHttpPort(),
258
+ httpsPort: localPort.port,
259
+ localHostname: this.config.localHostname || null
260
+ }),
102
261
  localPort
103
262
  };
104
263
  },
@@ -133,11 +292,42 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
133
292
  dropped: addresses.length - cleaned.length
134
293
  } });
135
294
  return { success: true };
295
+ },
296
+ getTlsStatus: async () => this.snapshotTls(false),
297
+ regenerateCertificate: async (_input) => {
298
+ if (require_lan_http_bind.readTlsMode(this.tlsDataDir()) === "uploaded") throw new Error("Cannot regenerate an uploaded certificate — revert to generated first.");
299
+ const extraSans = [...require_lan_http_bind.readExtraSans(this.tlsDataDir())];
300
+ await require_lan_http_bind.reissueTlsLeaf(this.tlsDataDir(), { extraSans });
301
+ return this.snapshotTls(true);
302
+ },
303
+ uploadCertificate: async ({ certPem, keyPem, caPem }) => {
304
+ const valid = require_lan_http_bind.validateUploadedTls(certPem, keyPem);
305
+ if (!valid.ok) throw new Error(valid.error);
306
+ const dir = (0, node_path.join)(this.tlsDataDir(), "tls");
307
+ await (0, node_fs_promises.mkdir)(dir, { recursive: true });
308
+ await (0, node_fs_promises.writeFile)((0, node_path.join)(dir, "camstack.crt"), certPem.endsWith("\n") ? certPem : `${certPem}\n`);
309
+ await (0, node_fs_promises.writeFile)((0, node_path.join)(dir, "camstack.key"), keyPem.endsWith("\n") ? keyPem : `${keyPem}\n`, { mode: 384 });
310
+ if (caPem && caPem.trim() !== "") await (0, node_fs_promises.writeFile)((0, node_path.join)(dir, "camstack-ca.crt"), caPem.endsWith("\n") ? caPem : `${caPem}\n`);
311
+ require_lan_http_bind.writeTlsMode(this.tlsDataDir(), "uploaded");
312
+ return this.snapshotTls(true);
313
+ },
314
+ downloadCa: async () => {
315
+ return { pem: this.snapshotTls(false).caCertPem ?? "" };
316
+ },
317
+ revertToGeneratedCertificate: async () => {
318
+ require_lan_http_bind.writeTlsMode(this.tlsDataDir(), "generated");
319
+ const extraSans = [...require_lan_http_bind.readExtraSans(this.tlsDataDir())];
320
+ await require_lan_http_bind.ensureTlsCert(this.tlsDataDir(), { extraSans });
321
+ return this.snapshotTls(true);
136
322
  }
137
323
  };
138
324
  this.lastSnapshotKey = snapshotKey(this.enumerate());
139
325
  this.pollTimer = setInterval(() => this.detectChanges(), POLL_INTERVAL_MS);
140
326
  this.pollTimer.unref?.();
327
+ this.allowlistTimer = setInterval(() => {
328
+ this.persistReconciledAllowlist();
329
+ }, ALLOWLIST_RECONCILE_INTERVAL_MS);
330
+ this.allowlistTimer.unref?.();
141
331
  this.ctx.eventBus?.subscribe({ category: require_dist.EventCategory.NetworkTunnelStarted }, (event) => {
142
332
  const data = event.data ?? {};
143
333
  if (typeof data.url === "string") try {
@@ -156,16 +346,67 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
156
346
  } : null);
157
347
  });
158
348
  this.ctx.logger.info("local-network initialized", { meta: { interfaceCount: this.enumerate().length } });
349
+ try {
350
+ await this.applyLanAccessFromSettings();
351
+ } catch (err) {
352
+ this.ctx.logger.warn("local-network: LAN HTTP not applied at boot", { meta: { error: err instanceof Error ? err.message : String(err) } });
353
+ }
159
354
  return [{
160
355
  capability: require_dist.localNetworkCapability,
161
356
  provider
162
357
  }];
163
358
  }
359
+ globalSettingsSchema() {
360
+ return this.schema({ sections: [{
361
+ id: "local-access",
362
+ title: "LAN HTTP",
363
+ 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.",
364
+ columns: 1,
365
+ fields: [
366
+ this.field({
367
+ type: "boolean",
368
+ key: "httpEnabled",
369
+ label: "Enable HTTP on the LAN",
370
+ style: "switch",
371
+ default: true
372
+ }),
373
+ this.field({
374
+ type: "number",
375
+ key: "httpPort",
376
+ label: "HTTP port",
377
+ description: "Dedicated listener. Must not equal the HTTPS port (4443 by default).",
378
+ min: 1,
379
+ max: 65535,
380
+ step: 1,
381
+ default: require_lan_http_bind.DEFAULT_LAN_HTTP_PORT,
382
+ showWhen: {
383
+ field: "httpEnabled",
384
+ equals: true
385
+ }
386
+ }),
387
+ this.field({
388
+ type: "text",
389
+ key: "localHostname",
390
+ label: "Local hostname",
391
+ 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.",
392
+ maxLength: 253,
393
+ placeholder: "camstack.lan"
394
+ })
395
+ ]
396
+ }] });
397
+ }
398
+ async onConfigChanged() {
399
+ await this.applyLanAccessFromSettings();
400
+ }
164
401
  async onShutdown() {
165
402
  if (this.pollTimer) {
166
403
  clearInterval(this.pollTimer);
167
404
  this.pollTimer = null;
168
405
  }
406
+ if (this.allowlistTimer) {
407
+ clearInterval(this.allowlistTimer);
408
+ this.allowlistTimer = null;
409
+ }
169
410
  }
170
411
  /**
171
412
  * Other hub addons (e.g. cloudflare-tunnel) signal the active public
@@ -229,6 +470,42 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
229
470
  }
230
471
  }
231
472
  }
473
+ resolvedHttpPort() {
474
+ const port = this.config.httpPort;
475
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : require_lan_http_bind.DEFAULT_LAN_HTTP_PORT;
476
+ }
477
+ tlsDataDir() {
478
+ return this.ctx.nodeDataDir ?? this.ctx.dataDir;
479
+ }
480
+ snapshotTls(restartRequired) {
481
+ const status = require_lan_http_bind.readTlsAccessStatus(this.tlsDataDir(), restartRequired);
482
+ return {
483
+ ...status,
484
+ sans: [...status.sans]
485
+ };
486
+ }
487
+ /**
488
+ * Bind or unbind the LAN HTTP sibling from the addon's own settings, and
489
+ * keep the leaf SAN in sync with `localHostname`. This is the settings
490
+ * side-effect path — there is no second persist API.
491
+ */
492
+ async applyLanAccessFromSettings() {
493
+ const parsed = parseLocalHostname(this.config.localHostname ?? "");
494
+ if (!parsed.ok) throw new Error(parsed.error);
495
+ const hostname = parsed.value ?? "";
496
+ const httpsPort = (await this.resolveAdvertisedListenPort(null)).port;
497
+ const httpPort = this.resolvedHttpPort();
498
+ if (this.config.httpEnabled !== false && httpPort === httpsPort) throw new Error(`HTTP port ${httpPort} cannot equal the HTTPS listen port`);
499
+ await require_lan_http_bind.applyLanHttp({
500
+ enabled: this.config.httpEnabled !== false,
501
+ port: httpPort,
502
+ host: "0.0.0.0"
503
+ });
504
+ if (hostname !== "") {
505
+ require_lan_http_bind.writeExtraSans(this.tlsDataDir(), [hostname]);
506
+ if (require_lan_http_bind.readTlsMode(this.tlsDataDir()) === "generated") await require_lan_http_bind.ensureTlsCert(this.tlsDataDir(), { extraSans: [hostname] });
507
+ } else require_lan_http_bind.writeExtraSans(this.tlsDataDir(), []);
508
+ }
232
509
  /**
233
510
  * The port this hub is actually listening on, plus the provenance of the
234
511
  * number. The boot loader merges the `CAMSTACK_PORT` env override into the
@@ -263,7 +540,26 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
263
540
  }
264
541
  }
265
542
  enumerate() {
266
- 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
+ }
267
563
  }
268
564
  detectChanges() {
269
565
  const interfaces = this.enumerate();
@@ -274,6 +570,7 @@ var LocalNetworkAddon = class extends require_dist.BaseAddon {
274
570
  key
275
571
  } });
276
572
  this.lastSnapshotKey = key;
573
+ this.persistReconciledAllowlist(interfaces);
277
574
  this.ctx.eventBus?.emit({
278
575
  id: `local-network-changed-${Date.now()}`,
279
576
  timestamp: /* @__PURE__ */ new Date(),
@@ -322,7 +619,7 @@ function validPort(value) {
322
619
  if (value === null) return null;
323
620
  return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : null;
324
621
  }
325
- function enumerateOsInterfaces(ifaces) {
622
+ function enumerateOsInterfaces(ifaces, ipv6Flags) {
326
623
  const raw = [];
327
624
  for (const [name, addrs] of Object.entries(ifaces)) {
328
625
  if (!addrs) continue;
@@ -342,8 +639,12 @@ function enumerateOsInterfaces(ifaces) {
342
639
  const classified = raw.map((r) => {
343
640
  const kind = classifyKind(r.name, r.address, r.internal);
344
641
  const reachableKind = kind === "lan" || kind === "wifi";
345
- const plausible = !r.internal && reachableKind && isPlausibleAutoSeed(r.address, r.family);
346
- 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({
347
648
  kind,
348
649
  family: r.family,
349
650
  address: r.address,
@@ -372,13 +673,14 @@ function enumerateOsInterfaces(ifaces) {
372
673
  /**
373
674
  * Filter the interface list by the operator's allowlist. Empty
374
675
  * allowlist = no-op (every interface passes); otherwise only entries
375
- * whose `address` matches an allowlist entry remain. Loopback always
376
- * 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.
377
680
  */
378
681
  function applyAllowlist(interfaces, allowed) {
379
682
  if (allowed.length === 0) return interfaces;
380
- const set = new Set(allowed);
381
- 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));
382
684
  }
383
685
  /**
384
686
  * Rank interfaces and pick the auto-selected outbound one. See the
@@ -402,31 +704,13 @@ function pickPreferred(interfaces) {
402
704
  return prefixLen(b.netmask) - prefixLen(a.netmask);
403
705
  })[0] ?? null;
404
706
  }
405
- /**
406
- * Build the ordered candidate URL list. Priority schema:
407
- * 0 — preferred LAN IPv4
408
- * 10+ — other LAN IPv4
409
- * 100 — public tunnel hostname (always HTTPS)
410
- * 150 — mesh endpoint (Tailscale MagicDNS / 100.x, always HTTPS)
411
- * 200+ — LAN IPv6
412
- * 1000 — loopback (last resort)
413
- *
414
- * The mesh endpoint ranks just below a true public funnel (a Funnel is
415
- * reachable by ANY client; the mesh needs the peer on the same tailnet)
416
- * but above the IPv6 / loopback fallbacks — giving remote, mesh-joined
417
- * clients a working candidate when no public tunnel is up.
418
- *
419
- * `scheme` controls LAN + loopback URLs. Browsers running over HTTPS
420
- * block `http://` candidates as mixed content, so callers loaded over
421
- * HTTPS should pass `scheme: 'https'` even when probing a LAN IP — the
422
- * hub's cert manager already issues a SAN-multi cert covering local
423
- * interfaces. The public tunnel + mesh endpoint always emit `https://`
424
- * (the tunnel edge / the hub itself terminates TLS).
425
- */
426
- function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostname, scheme = "http", meshEndpoint = null) {
707
+ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostname, scheme = "http", meshEndpoint = null, lanHttp = null) {
427
708
  const out = [];
428
709
  let priority = 0;
429
710
  const preferred = pickPreferred(interfaces);
711
+ const httpLan = lanHttp?.enabled === true && lanHttp.listening === true;
712
+ const lanScheme = httpLan ? "http" : scheme;
713
+ const lanPort = httpLan ? lanHttp.httpPort : port;
430
714
  const emit = (iface, kind, label, baseUrl, pri) => {
431
715
  out.push({
432
716
  label,
@@ -438,13 +722,29 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
438
722
  priority: pri
439
723
  });
440
724
  };
441
- if (preferred) emit(preferred, "lan-ipv4", `${formatKind(preferred.kind)} — ${preferred.name}`, `${scheme}://${preferred.address}:${port}`, priority++);
725
+ if (httpLan && lanHttp.localHostname) out.push({
726
+ label: "Local name",
727
+ baseUrl: `${lanScheme}://${lanHttp.localHostname}:${lanPort}`,
728
+ kind: "lan-ipv4",
729
+ interfaceKind: "lan",
730
+ plausible: true,
731
+ plausibleReason: "",
732
+ priority: priority++
733
+ });
734
+ if (preferred) emit(preferred, "lan-ipv4", `${formatKind(preferred.kind)} — ${preferred.name}`, `${lanScheme}://${preferred.address}:${lanPort}`, priority++);
442
735
  for (const iface of interfaces) {
443
736
  if (iface.internal || iface.family !== "IPv4") continue;
444
737
  if (iface.kind === "loopback") continue;
445
738
  if (preferred && iface.name === preferred.name && iface.address === preferred.address) continue;
446
739
  if (iface.address.startsWith("169.254.")) continue;
447
- emit(iface, "lan-ipv4", `${formatKind(iface.kind)} — ${iface.name}`, `${scheme}://${iface.address}:${port}`, 10 + priority++);
740
+ emit(iface, "lan-ipv4", `${formatKind(iface.kind)} — ${iface.name}`, `${lanScheme}://${iface.address}:${lanPort}`, 10 + priority++);
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++);
448
748
  }
449
749
  if (publicHostname) out.push({
450
750
  label: "Public tunnel",
@@ -464,15 +764,6 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
464
764
  plausibleReason: "",
465
765
  priority: 150
466
766
  });
467
- if (!ipv4Only) {
468
- let v6prio = 200;
469
- for (const iface of interfaces) {
470
- if (iface.internal || iface.family !== "IPv6") continue;
471
- if (iface.kind === "loopback") continue;
472
- if (iface.address.startsWith("fe80:")) continue;
473
- emit(iface, "lan-ipv6", `${formatKind(iface.kind)} — ${iface.name} (IPv6)`, `${scheme}://[${iface.address}]:${port}`, v6prio++);
474
- }
475
- }
476
767
  if (includeLoopback) out.push({
477
768
  label: "Loopback",
478
769
  baseUrl: `${scheme}://127.0.0.1:${port}`,
@@ -490,16 +781,14 @@ function buildEndpoints(interfaces, port, includeLoopback, ipv4Only, publicHostn
490
781
  *
491
782
  * - **IPv4**: skip link-local (`169.254.*`), keep the rest.
492
783
  * - **IPv6**: skip link-local (`fe80::*`), unspecified, and
493
- * multicast. Keep ULAs (`fc00::/7` `fc??:` / `fd??:`) and Global
494
- * Unicast addresses (`2000::/3` `2???`/`3???`). Privacy-extension
495
- * temporary addresses get included by default; the operator can
496
- * prune them from the Network Addresses tab if the rotating IPs
497
- * 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`).
498
787
  *
499
788
  * Skips docker/vpn/loopback/other entirely — those stay opt-in.
500
789
  */
501
790
  function autoSeedAllowlist(interfaces) {
502
- 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))];
503
792
  }
504
793
  /**
505
794
  * Per-interface tooltip text surfaced on the "Unlikely usable" badge.
@@ -588,7 +877,33 @@ function formatKind(kind) {
588
877
  case "other": return "Other";
589
878
  }
590
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
+ }
591
905
  //#endregion
906
+ exports.ALLOWLIST_RECONCILE_INTERVAL_MS = ALLOWLIST_RECONCILE_INTERVAL_MS;
592
907
  exports.LocalNetworkAddon = LocalNetworkAddon;
593
908
  exports.applyAllowlist = applyAllowlist;
594
909
  exports.autoSeedAllowlist = autoSeedAllowlist;
@@ -596,7 +911,11 @@ exports.buildEndpoints = buildEndpoints;
596
911
  exports.classifyKind = classifyKind;
597
912
  exports.enumerateOsInterfaces = enumerateOsInterfaces;
598
913
  exports.explainNonPlausible = explainNonPlausible;
914
+ exports.ipv6Prefix64Key = ipv6Prefix64Key;
599
915
  exports.isPlausibleAutoSeed = isPlausibleAutoSeed;
916
+ exports.parseProcIfInet6 = parseProcIfInet6;
600
917
  exports.pickPreferred = pickPreferred;
601
918
  exports.prefixLen = prefixLen;
919
+ exports.reconcileAllowlist = reconcileAllowlist;
602
920
  exports.resolveAdvertisedPort = resolveAdvertisedPort;
921
+ exports.shouldAdvertiseIpv6 = shouldAdvertiseIpv6;