@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.
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/ipv6-lan.d.ts +26 -0
- package/dist/builtins/local-network/local-hostname.d.ts +15 -0
- package/dist/builtins/local-network/local-network.addon.d.ts +59 -21
- package/dist/builtins/local-network/local-network.addon.js +369 -50
- package/dist/builtins/local-network/local-network.addon.mjs +365 -51
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +2 -2
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +2 -2
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-Drb54SZD.mjs → dist-D4Vmj8iw.mjs} +151 -9
- package/dist/{dist-iAwSA2_f.js → dist-DMEGlqg6.js} +151 -9
- package/dist/http/lan-http-bind.d.ts +30 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +30 -472
- package/dist/index.mjs +11 -468
- package/dist/lan-http-bind-DmgpFP6_.js +890 -0
- package/dist/lan-http-bind-jKrj6OjQ.mjs +752 -0
- package/dist/{retired-settings-keys-Dz5-7gjt.mjs → retired-settings-keys--aM_vGF9.mjs} +1 -1
- package/dist/{retired-settings-keys-OfhqQio4.js → retired-settings-keys-DET4YkAU.js} +1 -1
- package/dist/tls/cert-manager.d.ts +11 -0
- package/dist/tls/index.d.ts +3 -1
- package/dist/tls/tls-status.d.ts +16 -0
- package/dist/tls/uploaded-cert.d.ts +14 -0
- 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-
|
|
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
|
|
346
|
-
const
|
|
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.
|
|
376
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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}`, `${
|
|
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`
|
|
494
|
-
*
|
|
495
|
-
*
|
|
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;
|