@camstack/system 1.2.381 → 1.2.382
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/addon-runner.js +4 -2
- package/dist/addon-runner.mjs +4 -2
- package/dist/addon-utils.js +1 -1
- package/dist/addon-utils.mjs +1 -1
- package/dist/auth/admin-token-manager.d.ts +75 -0
- package/dist/auth/api-key-manager.d.ts +1 -14
- package/dist/auth/auth-audit-log.d.ts +59 -0
- package/dist/auth/auth-manager.d.ts +28 -16
- package/dist/auth/cidr.d.ts +22 -0
- package/dist/auth/login-session-manager.d.ts +158 -0
- package/dist/auth/user-management-internal.d.ts +69 -0
- 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/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +14 -0
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +291 -3631
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +284 -3617
- package/dist/builtins/backup-orchestrator/encryption-key-store.d.ts +36 -0
- package/dist/builtins/backup-orchestrator/manifest-store.d.ts +19 -1
- package/dist/builtins/backup-orchestrator/restore-archive.d.ts +24 -0
- package/dist/builtins/calendars/calendars.addon.js +1 -1
- package/dist/builtins/calendars/calendars.addon.mjs +1 -1
- package/dist/builtins/camera-grid/index.js +1 -1
- package/dist/builtins/camera-grid/index.mjs +1 -1
- package/dist/builtins/composer/composer.addon.js +2 -2
- package/dist/builtins/composer/composer.addon.mjs +2 -2
- 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 +2 -2
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
- 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/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/keychain/index.js +1108 -733
- package/dist/builtins/keychain/index.mjs +1108 -733
- package/dist/builtins/keychain/keychain-reset-trigger.d.ts +25 -0
- package/dist/builtins/keychain/keychain-service.d.ts +36 -3
- package/dist/builtins/keychain/keychain-store.d.ts +24 -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.d.ts +16 -0
- package/dist/builtins/local-auth/local-auth.addon.js +1021 -118
- package/dist/builtins/local-auth/local-auth.addon.mjs +1034 -141
- package/dist/builtins/local-network/local-network.addon.js +3 -3
- package/dist/builtins/local-network/local-network.addon.mjs +3 -3
- package/dist/builtins/loki-logging/index.js +97 -29
- package/dist/builtins/loki-logging/index.mjs +97 -29
- package/dist/builtins/loki-logging/loki-logging.addon.d.ts +7 -23
- package/dist/builtins/loki-logging/retire-enabled-flag.d.ts +23 -0
- 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 +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +2 -2
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +2 -2
- package/dist/builtins/system-backup/backup-encryption.d.ts +57 -0
- package/dist/builtins/system-backup/system-backup.service.d.ts +16 -1
- package/dist/builtins/system-config/cluster-secret-keeper.d.ts +7 -0
- package/dist/builtins/system-config/system-config.addon.d.ts +13 -3
- package/dist/builtins/system-config/system-config.addon.js +324 -38
- package/dist/builtins/system-config/system-config.addon.mjs +312 -39
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{child-cap-dispatch-hf-Gce_4.js → child-cap-dispatch-BrMdKHfp.js} +6 -3
- package/dist/{child-cap-dispatch-BTe4eKep.mjs → child-cap-dispatch-WsYPNvnK.mjs} +10 -7
- package/dist/{cluster-secret-BUiG8DCo.js → cluster-secret-BEPLh3LL.js} +1 -1
- package/dist/{cluster-secret-DgJTZ4qY.mjs → cluster-secret-DhPdZ0VA.mjs} +1 -1
- package/dist/{composition-sources-4SmsP0O4.js → composition-sources-C6ZynCZ2.js} +1 -1
- package/dist/{composition-sources-CgOQVIwa.mjs → composition-sources-XqRX51db.mjs} +1 -1
- package/dist/{dist-BC0GnGeg.mjs → dist-CP5TTjwd.mjs} +1710 -158
- package/dist/{dist-BugrtARh.js → dist-tnAO12ak.js} +1721 -157
- package/dist/{event-loop-stall-monitor-CnQ-K6Gn.js → event-loop-stall-monitor-9kR0sAPS.js} +15 -15
- package/dist/{event-loop-stall-monitor-GgpnfAC0.mjs → event-loop-stall-monitor-WkssIXjq.mjs} +15 -15
- package/dist/{hub-cap-forward-CH6_VWVO.mjs → hub-cap-forward-CDiN1p98.mjs} +18 -2
- package/dist/{hub-cap-forward-COCmYaup.js → hub-cap-forward-W8oEm4ME.js} +23 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +3341 -79
- package/dist/index.mjs +3567 -354
- package/dist/{jwt-signing-key-CsuVKA1W.mjs → jwt-signing-key-CAbozwvF.mjs} +1 -1
- package/dist/{jwt-signing-key-CjoZcol8.js → jwt-signing-key-Dgf8sIXY.js} +1 -1
- package/dist/kernel/index.d.ts +12 -0
- package/dist/kernel/moleculer/broker-factory.d.ts +8 -1
- package/dist/kernel/moleculer/child-addon-call-dispatch.d.ts +1 -0
- package/dist/kernel/moleculer/hub-service.d.ts +9 -0
- package/dist/kernel/moleculer/mesh-access-middleware.d.ts +28 -0
- package/dist/kernel/moleculer/mesh-auth-mode.d.ts +64 -0
- package/dist/kernel/moleculer/mesh-authenticator.d.ts +71 -0
- package/dist/kernel/moleculer/mesh-auto-enforce.d.ts +26 -0
- package/dist/kernel/moleculer/mesh-join-client.d.ts +51 -0
- package/dist/kernel/moleculer/mesh-join-proof.d.ts +39 -0
- package/dist/kernel/system-settings-policy.d.ts +4 -3
- package/dist/kernel/transport/hub-cap-forward.d.ts +14 -3
- package/dist/kernel/transport/index.d.ts +1 -1
- package/dist/restore-archive-BV39nY46.mjs +4045 -0
- package/dist/restore-archive-CbvrfPuB.js +4139 -0
- package/dist/{retired-settings-keys-DCuetwOt.js → retired-settings-keys-4bHpL-0m.js} +1 -1
- package/dist/{retired-settings-keys-MCVhxPkJ.mjs → retired-settings-keys-CzpIkwS-.mjs} +1 -1
- package/dist/{tls-CJHogwTl.mjs → tls-4cRH24Id.mjs} +3 -3
- package/package.json +1 -1
- package/dist/{tls-CCMX7MjX.js → tls-CljF057D.js} +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ArchiveSqliteSnapshot, DatabaseSnapshotResult, IScopedLogger } from '@camstack/types';
|
|
2
|
+
import { BACKUP_ENCRYPTION_SCHEME } from './backup-encryption.js';
|
|
2
3
|
/**
|
|
3
4
|
* Thrown when an archive build (or a backup run driving one) is
|
|
4
5
|
* stopped via its `AbortSignal`. Callers use `instanceof` to tell a
|
|
@@ -94,11 +95,19 @@ export interface ArchiveManifest {
|
|
|
94
95
|
*/
|
|
95
96
|
sqliteSnapshots?: ArchiveSqliteSnapshot[];
|
|
96
97
|
}
|
|
98
|
+
/** How an archive was encrypted — absent on a plain `.tar.gz`. */
|
|
99
|
+
export interface ArchiveEncryption {
|
|
100
|
+
readonly scheme: typeof BACKUP_ENCRYPTION_SCHEME;
|
|
101
|
+
/** The backup key the archive needs to be restored. */
|
|
102
|
+
readonly keyId: string;
|
|
103
|
+
}
|
|
97
104
|
export interface CreateArchiveResult {
|
|
98
105
|
archivePath: string;
|
|
99
106
|
includedLocations: readonly string[];
|
|
100
|
-
/** Archive size in bytes (compressed, on disk). */
|
|
107
|
+
/** Archive size in bytes (compressed — and encrypted, when it is — on disk). */
|
|
101
108
|
sizeBytes: number;
|
|
109
|
+
/** Present when the archive is encrypted to a backup key. */
|
|
110
|
+
encryption?: ArchiveEncryption;
|
|
102
111
|
/** Embedded manifest — same shape as `getArchiveManifest()`. */
|
|
103
112
|
manifest: ArchiveManifest;
|
|
104
113
|
}
|
|
@@ -197,6 +206,12 @@ export declare class SystemBackupService {
|
|
|
197
206
|
* because its upload prunes an older archive that HAS it.
|
|
198
207
|
*/
|
|
199
208
|
owner?: DatabaseOwner;
|
|
209
|
+
/**
|
|
210
|
+
* A backup PUBLIC key: the gzip stream is encrypted to it as it is
|
|
211
|
+
* written, so no plaintext archive ever touches the staging disk. Parsed
|
|
212
|
+
* before any work — a bad key builds nothing.
|
|
213
|
+
*/
|
|
214
|
+
encryptTo?: string;
|
|
200
215
|
}): Promise<CreateArchiveResult>;
|
|
201
216
|
/**
|
|
202
217
|
* Tar `present` (filtered by `excluded`) plus the staged manifest into
|
|
@@ -17,6 +17,12 @@ export interface ClusterSecretKeeperDeps {
|
|
|
17
17
|
readonly readSection: () => Promise<Record<string, unknown>>;
|
|
18
18
|
readonly logger: KeeperLogger;
|
|
19
19
|
readonly retryMs?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Generate and store a new secret (D802). Called when no marker is stored.
|
|
22
|
+
* Throws when it cannot (no keychain, locked): the keeper retries on its
|
|
23
|
+
* timer. Absent → nothing is generated (tests of the D797 states).
|
|
24
|
+
*/
|
|
25
|
+
readonly generateSecret?: () => Promise<void>;
|
|
20
26
|
}
|
|
21
27
|
export declare class ClusterSecretKeeper {
|
|
22
28
|
private readonly deps;
|
|
@@ -29,6 +35,7 @@ export declare class ClusterSecretKeeper {
|
|
|
29
35
|
stop(): void;
|
|
30
36
|
/** Re-read the row and the keychain; publish the gate. */
|
|
31
37
|
reload(): Promise<void>;
|
|
38
|
+
private generate;
|
|
32
39
|
private compute;
|
|
33
40
|
private report;
|
|
34
41
|
private scheduleRetry;
|
|
@@ -14,10 +14,20 @@ export declare class SystemConfigAddon extends BaseAddon {
|
|
|
14
14
|
updateGlobalSettings(patch: Record<string, unknown>): Promise<void>;
|
|
15
15
|
/**
|
|
16
16
|
* The cluster secret's save path: the value goes to the keychain (owner
|
|
17
|
-
* `system`), the row gets the marker; `''` / a view keeps
|
|
18
|
-
*
|
|
19
|
-
* inline (D787, P5).
|
|
17
|
+
* `system`), the row gets the marker; `''` / a view keeps. `$secretClear` is
|
|
18
|
+
* REFUSED: the secret is mandatory (D802). A value the keychain cannot store
|
|
19
|
+
* is REFUSED — never written inline (D787, P5). `meshAuth` is a plain value.
|
|
20
20
|
*/
|
|
21
21
|
private writeClusterSection;
|
|
22
|
+
/** `cluster.meshAuth` (D802), then republish it. */
|
|
23
|
+
private writeMeshAuthMode;
|
|
24
|
+
/**
|
|
25
|
+
* First boot, or a keychain reset that cleared the marker (D802): a fresh
|
|
26
|
+
* random secret, stored like an operator save. A NEW secret means every
|
|
27
|
+
* agent must be given it, so the mesh goes (back) to `transition`; on a hub
|
|
28
|
+
* with no agent yet, the hub's auto-enforce ends it at once.
|
|
29
|
+
*/
|
|
30
|
+
private generateClusterSecret;
|
|
31
|
+
private storeClusterSecret;
|
|
22
32
|
}
|
|
23
33
|
export default SystemConfigAddon;
|
|
@@ -3,21 +3,154 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
7
|
-
const require_cluster_secret = require("../../cluster-secret-
|
|
6
|
+
const require_dist = require("../../dist-tnAO12ak.js");
|
|
7
|
+
const require_cluster_secret = require("../../cluster-secret-BEPLh3LL.js");
|
|
8
|
+
let node_crypto = require("node:crypto");
|
|
9
|
+
let node_net = require("node:net");
|
|
10
|
+
//#region src/auth/cidr.ts
|
|
11
|
+
/**
|
|
12
|
+
* CIDR lists for the auth settings that name networks: the trusted reverse
|
|
13
|
+
* proxies whose forwarding headers the hub believes, and the trusted local
|
|
14
|
+
* networks a login may skip its second factor from.
|
|
15
|
+
*
|
|
16
|
+
* Built on `net.BlockList`, so IPv4 and IPv6 are matched by the platform, not
|
|
17
|
+
* by string prefixes. A bare address is read as a single host. Anything that
|
|
18
|
+
* does not parse is REPORTED back (`invalid`) so the settings form can refuse
|
|
19
|
+
* it — a list that silently dropped a typo would quietly trust less than the
|
|
20
|
+
* operator wrote, and one that guessed would trust more.
|
|
21
|
+
*/
|
|
22
|
+
/** Strip an IPv4-mapped IPv6 prefix and a zone id, so ranges see a clean address. */
|
|
23
|
+
function normalizeIp(ip) {
|
|
24
|
+
let out = ip.trim();
|
|
25
|
+
if (out.toLowerCase().startsWith("::ffff:") && (0, node_net.isIP)(out.slice(7)) === 4) out = out.slice(7);
|
|
26
|
+
const pct = out.indexOf("%");
|
|
27
|
+
if (pct !== -1) out = out.slice(0, pct);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
function parseCidr(raw) {
|
|
31
|
+
const text = raw.trim();
|
|
32
|
+
const slash = text.indexOf("/");
|
|
33
|
+
const address = normalizeIp(slash === -1 ? text : text.slice(0, slash));
|
|
34
|
+
const version = (0, node_net.isIP)(address);
|
|
35
|
+
if (version === 0) return null;
|
|
36
|
+
const max = version === 4 ? 32 : 128;
|
|
37
|
+
let prefix = max;
|
|
38
|
+
if (slash !== -1) {
|
|
39
|
+
const bits = text.slice(slash + 1);
|
|
40
|
+
if (!/^\d{1,3}$/.test(bits)) return null;
|
|
41
|
+
prefix = Number.parseInt(bits, 10);
|
|
42
|
+
if (prefix > max) return null;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
network: address,
|
|
46
|
+
prefix,
|
|
47
|
+
family: version === 4 ? "ipv4" : "ipv6"
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Parse a list of CIDR strings; blanks are skipped, unparseable entries returned. */
|
|
51
|
+
function parseCidrList(entries) {
|
|
52
|
+
const valid = [];
|
|
53
|
+
const invalid = [];
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
if (entry.trim().length === 0) continue;
|
|
56
|
+
const parsed = parseCidr(entry);
|
|
57
|
+
if (parsed === null) invalid.push(entry);
|
|
58
|
+
else valid.push(parsed);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
valid,
|
|
62
|
+
invalid
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** A stored setting as a string list — accepts a list, or one comma/space separated string. */
|
|
66
|
+
function readCidrSetting(value) {
|
|
67
|
+
if (Array.isArray(value)) return value.filter((v) => typeof v === "string");
|
|
68
|
+
if (typeof value === "string") return value.split(/[\s,]+/).filter((v) => v.length > 0);
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
/** A matcher over the VALID entries of `entries`; invalid ones match nothing. */
|
|
72
|
+
function createCidrMatcher(entries) {
|
|
73
|
+
const { valid } = parseCidrList(entries);
|
|
74
|
+
const list = new node_net.BlockList();
|
|
75
|
+
for (const cidr of valid) list.addSubnet(cidr.network, cidr.prefix, cidr.family);
|
|
76
|
+
return {
|
|
77
|
+
size: valid.length,
|
|
78
|
+
contains(ip) {
|
|
79
|
+
if (valid.length === 0) return false;
|
|
80
|
+
const address = normalizeIp(ip);
|
|
81
|
+
const version = (0, node_net.isIP)(address);
|
|
82
|
+
if (version === 0) return false;
|
|
83
|
+
return list.check(address, version === 4 ? "ipv4" : "ipv6");
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/kernel/moleculer/mesh-auth-mode.ts
|
|
89
|
+
/** The field key inside the `cluster` section. */
|
|
90
|
+
var MESH_AUTH_MODE_FIELD = "meshAuth";
|
|
91
|
+
function isMeshAuthMode(value) {
|
|
92
|
+
return value === "transition" || value === "enforced";
|
|
93
|
+
}
|
|
94
|
+
/** A stored value that is not a mode reads as `transition`: never refuse on a typo. */
|
|
95
|
+
function parseMeshAuthMode(value) {
|
|
96
|
+
return isMeshAuthMode(value) ? value : "transition";
|
|
97
|
+
}
|
|
98
|
+
var MODE_SLOT = Symbol.for("camstack.cluster.mesh-auth-mode");
|
|
99
|
+
var ENFORCER_SLOT = Symbol.for("camstack.cluster.mesh-auth-enforcer");
|
|
100
|
+
var STATUS_SLOT = Symbol.for("camstack.cluster.mesh-auth-status");
|
|
101
|
+
/** Published by `system-config` whenever the stored mode is (re)read. */
|
|
102
|
+
function setProcessMeshAuthMode(mode) {
|
|
103
|
+
Reflect.set(globalThis, MODE_SLOT, mode);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The hub's mode. Until `system-config` has read it: `transition` — the safe
|
|
107
|
+
* side for an upgrading cluster (nothing that worked before is refused), and
|
|
108
|
+
* exactly what an unread setting means.
|
|
109
|
+
*/
|
|
110
|
+
function getProcessMeshAuthMode() {
|
|
111
|
+
return parseMeshAuthMode(Reflect.get(globalThis, MODE_SLOT));
|
|
112
|
+
}
|
|
113
|
+
function setProcessMeshEnforcer(enforcer) {
|
|
114
|
+
if (enforcer === null) Reflect.deleteProperty(globalThis, ENFORCER_SLOT);
|
|
115
|
+
else Reflect.set(globalThis, ENFORCER_SLOT, enforcer);
|
|
116
|
+
}
|
|
117
|
+
/** Ask the owner of the setting to switch to `enforced`. `false` when nobody can. */
|
|
118
|
+
async function requestProcessMeshEnforcement(reason) {
|
|
119
|
+
const enforcer = Reflect.get(globalThis, ENFORCER_SLOT);
|
|
120
|
+
if (typeof enforcer !== "function") return false;
|
|
121
|
+
await enforcer(reason);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
/** Published by the hub's broker; read by System → Cluster. */
|
|
125
|
+
function setProcessMeshAuthStatusSource(source) {
|
|
126
|
+
if (source === null) Reflect.deleteProperty(globalThis, STATUS_SLOT);
|
|
127
|
+
else Reflect.set(globalThis, STATUS_SLOT, source);
|
|
128
|
+
}
|
|
129
|
+
function getProcessMeshAuthStatus() {
|
|
130
|
+
const source = Reflect.get(globalThis, STATUS_SLOT);
|
|
131
|
+
return typeof source === "function" ? source() : null;
|
|
132
|
+
}
|
|
133
|
+
//#endregion
|
|
8
134
|
//#region src/builtins/system-config/cluster-secret-keeper.ts
|
|
9
135
|
/**
|
|
10
|
-
* Keeps the hub's cluster-secret gate in step with System → Cluster (D797
|
|
136
|
+
* Keeps the hub's cluster-secret gate in step with System → Cluster (D797,
|
|
137
|
+
* D802).
|
|
11
138
|
*
|
|
12
139
|
* The field is a `password` of `system-config`: its `system-settings` row
|
|
13
140
|
* (`cluster.clusterSecret`) holds a keychain marker, the value lives in the
|
|
14
|
-
* keychain (owner `system`). The gate the
|
|
141
|
+
* keychain (owner `system`). The gate the mesh join reads:
|
|
15
142
|
*
|
|
16
|
-
* - no marker →
|
|
17
|
-
*
|
|
18
|
-
*
|
|
143
|
+
* - no marker → the secret is MANDATORY (D802): the keeper asks
|
|
144
|
+
* its owner to generate one (`generateSecret`), and
|
|
145
|
+
* until that succeeds the gate is `off` — no node can
|
|
146
|
+
* authenticate, and only `transition` lets them in;
|
|
147
|
+
* - marker + value → `on` — nodes prove the secret;
|
|
19
148
|
* - marker, no answer → `unknown` — refused, retryably, and retried here.
|
|
20
|
-
* A locked keychain or a value lost to a reset NEVER opens the gate
|
|
149
|
+
* A locked keychain or a value lost to a reset NEVER opens the gate, and
|
|
150
|
+
* never triggers a generation (the operator decides what replaces a value
|
|
151
|
+
* the keychain lost).
|
|
152
|
+
*
|
|
153
|
+
* It also publishes the mesh mode (`cluster.meshAuth`, `mesh-auth-mode.ts`).
|
|
21
154
|
*/
|
|
22
155
|
/** The field key inside the `cluster` section. */
|
|
23
156
|
var CLUSTER_SECRET_FIELD = "clusterSecret";
|
|
@@ -46,22 +179,35 @@ var ClusterSecretKeeper = class {
|
|
|
46
179
|
/** Re-read the row and the keychain; publish the gate. */
|
|
47
180
|
async reload() {
|
|
48
181
|
if (this.stopped) return;
|
|
49
|
-
|
|
182
|
+
let gate = await this.compute();
|
|
183
|
+
if (gate.kind === "off" && this.deps.generateSecret !== void 0 && !this.stopped) gate = await this.generate();
|
|
50
184
|
if (this.stopped) return;
|
|
51
185
|
require_cluster_secret.setProcessClusterSecretGate(gate);
|
|
52
186
|
this.report(gate);
|
|
53
|
-
if (gate.kind === "unknown") this.scheduleRetry();
|
|
187
|
+
if (gate.kind === "unknown" || gate.kind === "off" && this.deps.generateSecret !== void 0) this.scheduleRetry();
|
|
188
|
+
}
|
|
189
|
+
async generate() {
|
|
190
|
+
try {
|
|
191
|
+
await this.deps.generateSecret?.();
|
|
192
|
+
} catch (err) {
|
|
193
|
+
this.deps.logger.error("The cluster secret is mandatory but could not be generated yet — retrying. Agents cannot authenticate until it exists.", { meta: { error: errText(err) } });
|
|
194
|
+
return { kind: "off" };
|
|
195
|
+
}
|
|
196
|
+
this.deps.logger.info("Generated the cluster secret (none was stored). Reveal it on System → Cluster and enter it in each agent.");
|
|
197
|
+
return this.compute();
|
|
54
198
|
}
|
|
55
199
|
async compute() {
|
|
56
|
-
let
|
|
200
|
+
let section;
|
|
57
201
|
try {
|
|
58
|
-
|
|
202
|
+
section = await this.deps.readSection();
|
|
59
203
|
} catch (err) {
|
|
60
204
|
return {
|
|
61
205
|
kind: "unknown",
|
|
62
206
|
reason: `settings unreadable: ${errText(err)}`
|
|
63
207
|
};
|
|
64
208
|
}
|
|
209
|
+
setProcessMeshAuthMode(parseMeshAuthMode(section[MESH_AUTH_MODE_FIELD]));
|
|
210
|
+
const stored = section[CLUSTER_SECRET_FIELD];
|
|
65
211
|
if (stored === void 0 || stored === null || stored === "") return { kind: "off" };
|
|
66
212
|
if (!require_dist.isSecretMarker(stored)) return {
|
|
67
213
|
kind: "unknown",
|
|
@@ -111,7 +257,7 @@ var ClusterSecretKeeper = class {
|
|
|
111
257
|
this.deps.logger.error("Agents cannot register: a cluster secret is set but cannot be read from the keychain. Registrations are refused until it can (no fallback).", { meta: { reason: gate.reason } });
|
|
112
258
|
return;
|
|
113
259
|
}
|
|
114
|
-
if (changed) this.deps.logger.info(gate.kind === "on" ? "Cluster secret set — agents
|
|
260
|
+
if (changed) this.deps.logger.info(gate.kind === "on" ? "Cluster secret set — agents prove it to join the mesh" : "No cluster secret yet — no node can authenticate to the mesh");
|
|
115
261
|
}
|
|
116
262
|
scheduleRetry() {
|
|
117
263
|
if (this.stopped || this.retryTimer !== null) return;
|
|
@@ -138,10 +284,12 @@ function errText(err) {
|
|
|
138
284
|
*
|
|
139
285
|
* Sections exposed — this list is the whole of it:
|
|
140
286
|
* - server (port, host, dataPath — READ-ONLY, loaded from config.yaml at bootstrap)
|
|
141
|
-
* - auth (tokenExpiry
|
|
287
|
+
* - auth (tokenExpiry = session lifetime, accessTokenExpiry, trustedProxies,
|
|
288
|
+
* trustedNetworks)
|
|
142
289
|
* - cluster (clusterSecret — a `password` field: the value goes to the
|
|
143
290
|
* keychain, owner `system`; the row keeps a marker. Also drawn
|
|
144
|
-
* on System → Cluster. D797
|
|
291
|
+
* on System → Cluster. D797. MANDATORY and generated at first
|
|
292
|
+
* boot; `meshAuth` — transition / enforced. D802)
|
|
145
293
|
*
|
|
146
294
|
* Sections NOT exposed (each owned by the addon that implements them):
|
|
147
295
|
* - logging → `winston-logging` addon (level + retention)
|
|
@@ -182,8 +330,32 @@ var KEY_TO_SECTION = {
|
|
|
182
330
|
host: "server",
|
|
183
331
|
dataPath: "server",
|
|
184
332
|
tokenExpiry: "auth",
|
|
185
|
-
|
|
333
|
+
accessTokenExpiry: "auth",
|
|
334
|
+
trustedProxies: "auth",
|
|
335
|
+
trustedNetworks: "auth",
|
|
336
|
+
[CLUSTER_SECRET_FIELD]: "cluster",
|
|
337
|
+
[MESH_AUTH_MODE_FIELD]: "cluster"
|
|
338
|
+
};
|
|
339
|
+
/** Bytes of a generated cluster secret (D802) — 256 bits, base64url (43 chars). */
|
|
340
|
+
var GENERATED_SECRET_BYTES = 32;
|
|
341
|
+
var STATE_LABELS = {
|
|
342
|
+
authenticated: "authenticated",
|
|
343
|
+
legacy: "legacy hash only — update the agent",
|
|
344
|
+
unauthenticated: "not authenticated — enter the cluster secret in this agent"
|
|
186
345
|
};
|
|
346
|
+
/** The Cluster page's live line per node (D802). */
|
|
347
|
+
function describeMeshStatus(status) {
|
|
348
|
+
if (status === null) return "The mesh status is available on the hub only.";
|
|
349
|
+
const head = status.mode === "enforced" ? "Enforced: a node that has not authenticated is refused." : "Transition: nodes without the secret are still let in (with a warning). The hub switches to Enforced by itself once every known agent has authenticated.";
|
|
350
|
+
if (status.nodes.length === 0) return `${head}\nNo other node is connected.`;
|
|
351
|
+
return `${head}\n${status.nodes.map((n) => `• ${n.nodeId}: ${STATE_LABELS[n.state] ?? n.state}`).join("\n")}`;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Auth keys holding a list of networks (CIDR or single address). A save with
|
|
355
|
+
* an entry that does not parse is REFUSED whole: dropping it would trust less
|
|
356
|
+
* than the operator wrote, guessing would trust more.
|
|
357
|
+
*/
|
|
358
|
+
var CIDR_LIST_KEYS = new Set(["trustedProxies", "trustedNetworks"]);
|
|
187
359
|
/** The section id of the cluster form — System → Cluster renders only this one. */
|
|
188
360
|
var SYSTEM_CONFIG_CLUSTER_SECTION_ID = "system-config-cluster";
|
|
189
361
|
var SystemConfigAddon = class extends require_dist.BaseAddon {
|
|
@@ -196,12 +368,18 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
|
|
|
196
368
|
this.clusterSecretKeeper = new ClusterSecretKeeper({
|
|
197
369
|
secrets: this.systemSecrets(),
|
|
198
370
|
readSection: async () => await this.ctx.settings?.getSection("cluster") ?? {},
|
|
199
|
-
logger: this.ctx.logger
|
|
371
|
+
logger: this.ctx.logger,
|
|
372
|
+
generateSecret: () => this.generateClusterSecret()
|
|
373
|
+
});
|
|
374
|
+
setProcessMeshEnforcer(async (reason) => {
|
|
375
|
+
await this.writeMeshAuthMode("enforced");
|
|
376
|
+
this.ctx.logger.info("Mesh authentication is now ENFORCED", { meta: { reason } });
|
|
200
377
|
});
|
|
201
378
|
await this.clusterSecretKeeper.start();
|
|
202
379
|
this.ctx.logger.info("Initialized — exposes yml-backed sections via getGlobalSettings");
|
|
203
380
|
}
|
|
204
381
|
async onShutdown() {
|
|
382
|
+
setProcessMeshEnforcer(null);
|
|
205
383
|
this.clusterSecretKeeper?.stop();
|
|
206
384
|
this.clusterSecretKeeper = null;
|
|
207
385
|
}
|
|
@@ -254,26 +432,80 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
|
|
|
254
432
|
title: SECTION_TITLES["auth"],
|
|
255
433
|
description: "Token and session settings.",
|
|
256
434
|
columns: 1,
|
|
257
|
-
fields: [
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
435
|
+
fields: [
|
|
436
|
+
{
|
|
437
|
+
type: "text",
|
|
438
|
+
key: "tokenExpiry",
|
|
439
|
+
label: "Session lifetime",
|
|
440
|
+
description: "How long a sign-in lasts without being used, as a duration string (e.g. 7d, 30d). Every refresh starts it again, so a device in use stays signed in; one left unopened this long must sign in again. A bare number is rejected — it would be read as milliseconds. Applies from the next sign-in or refresh.",
|
|
441
|
+
placeholder: "30d",
|
|
442
|
+
default: "30d"
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
type: "text",
|
|
446
|
+
key: "accessTokenExpiry",
|
|
447
|
+
label: "Access token lifetime",
|
|
448
|
+
description: "How long one access token lives before the app silently swaps it for a new one (e.g. 2h; between 5m and 24h, never longer than the session). Shorter limits how long a copied token is useful. Apps that predate refresh tokens get one token for the whole session instead.",
|
|
449
|
+
placeholder: "2h",
|
|
450
|
+
default: "2h"
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
type: "tags",
|
|
454
|
+
key: "trustedProxies",
|
|
455
|
+
label: "Trusted reverse proxies",
|
|
456
|
+
description: "Addresses or CIDR ranges (e.g. 192.168.1.2 or 10.0.0.0/24) of reverse proxies in front of the hub. Only a request whose direct sender is one of these — or this host itself, as with the Cloudflare tunnel — has its X-Forwarded-For / CF-Connecting-IP read as the real client address for rate limits. Leave empty unless a proxy on another host forwards to the hub.",
|
|
457
|
+
creatable: true
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
type: "info",
|
|
461
|
+
key: "trustedNetworksWarning",
|
|
462
|
+
label: "Skipping two-factor authentication",
|
|
463
|
+
variant: "danger",
|
|
464
|
+
content: "A sign-in made DIRECTLY from a network listed below skips the second factor (TOTP or passkey): anyone on that network who knows or guesses a password gets in with the password alone — including a compromised camera or a guest device. Requests that come through the Cloudflare tunnel, the public address or any reverse proxy never skip it, whatever address they claim. Every skip is recorded in the authentication audit log. Leave empty (the default) to always ask for the second factor."
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
type: "tags",
|
|
468
|
+
key: "trustedNetworks",
|
|
469
|
+
label: "Trusted networks (skip the second factor)",
|
|
470
|
+
description: "Addresses or CIDR ranges, e.g. 192.168.1.0/24. Keep it as narrow as you can — a single trusted VLAN, not the whole LAN.",
|
|
471
|
+
creatable: true
|
|
472
|
+
}
|
|
473
|
+
]
|
|
265
474
|
},
|
|
266
475
|
{
|
|
267
476
|
id: SYSTEM_CONFIG_CLUSTER_SECTION_ID,
|
|
268
477
|
title: SECTION_TITLES["cluster"],
|
|
269
|
-
description: "Agents joining this hub.
|
|
478
|
+
description: "Agents joining this hub. Every agent proves the cluster secret when it connects (challenge–response; the secret never crosses the network).",
|
|
270
479
|
columns: 1,
|
|
271
|
-
fields: [
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
480
|
+
fields: [
|
|
481
|
+
{
|
|
482
|
+
type: "password",
|
|
483
|
+
key: CLUSTER_SECRET_FIELD,
|
|
484
|
+
label: "Cluster secret",
|
|
485
|
+
description: "Mandatory — generated by the hub at first boot and stored in the keychain. To pair an agent: Reveal and copy it here, then enter it in the agent (its status page, or the desktop setup window). Saving a new value rotates it: every agent must then be given the new one."
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
type: "select",
|
|
489
|
+
key: MESH_AUTH_MODE_FIELD,
|
|
490
|
+
label: "Agents without the secret",
|
|
491
|
+
description: "Transition lets an agent that has not authenticated keep working (with a warning) while you give each agent the secret; it ends by itself once every known agent has authenticated. Enforced refuses it.",
|
|
492
|
+
default: "transition",
|
|
493
|
+
options: [{
|
|
494
|
+
value: "transition",
|
|
495
|
+
label: "Transition — allow, warn"
|
|
496
|
+
}, {
|
|
497
|
+
value: "enforced",
|
|
498
|
+
label: "Enforced — refuse"
|
|
499
|
+
}]
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
type: "info",
|
|
503
|
+
key: "meshAuthStatus",
|
|
504
|
+
label: "Mesh authentication",
|
|
505
|
+
content: describeMeshStatus(getProcessMeshAuthStatus()),
|
|
506
|
+
variant: "info"
|
|
507
|
+
}
|
|
508
|
+
]
|
|
277
509
|
}
|
|
278
510
|
] };
|
|
279
511
|
}
|
|
@@ -307,6 +539,10 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
|
|
|
307
539
|
this.ctx.logger.warn("update ignored read-only server-section key", { meta: { key } });
|
|
308
540
|
continue;
|
|
309
541
|
}
|
|
542
|
+
if (CIDR_LIST_KEYS.has(key)) {
|
|
543
|
+
const { invalid } = parseCidrList(readCidrSetting(value));
|
|
544
|
+
if (invalid.length > 0) throw new Error(`Not a network address or CIDR range: ${invalid.join(", ")}`);
|
|
545
|
+
}
|
|
310
546
|
let bucket = perSection.get(section);
|
|
311
547
|
if (!bucket) {
|
|
312
548
|
bucket = {};
|
|
@@ -328,13 +564,52 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
|
|
|
328
564
|
}
|
|
329
565
|
/**
|
|
330
566
|
* The cluster secret's save path: the value goes to the keychain (owner
|
|
331
|
-
* `system`), the row gets the marker; `''` / a view keeps
|
|
332
|
-
*
|
|
333
|
-
* inline (D787, P5).
|
|
567
|
+
* `system`), the row gets the marker; `''` / a view keeps. `$secretClear` is
|
|
568
|
+
* REFUSED: the secret is mandatory (D802). A value the keychain cannot store
|
|
569
|
+
* is REFUSED — never written inline (D787, P5). `meshAuth` is a plain value.
|
|
334
570
|
*/
|
|
335
571
|
async writeClusterSection(patch) {
|
|
572
|
+
const { [MESH_AUTH_MODE_FIELD]: mode, ...secretPatch } = patch;
|
|
573
|
+
if (mode !== void 0) {
|
|
574
|
+
if (!isMeshAuthMode(mode)) throw new Error(`system-config: meshAuth must be 'transition' or 'enforced'`);
|
|
575
|
+
await this.writeMeshAuthMode(mode);
|
|
576
|
+
}
|
|
577
|
+
if (Object.keys(secretPatch).length === 0) return;
|
|
578
|
+
if (require_dist.isSecretClear(secretPatch["clusterSecret"])) throw new Error("The cluster secret is mandatory and cannot be cleared — save a new value to rotate it");
|
|
579
|
+
await this.storeClusterSecret(secretPatch);
|
|
580
|
+
this.ctx.logger.info("Cluster secret updated");
|
|
581
|
+
await this.clusterSecretKeeper?.reload();
|
|
582
|
+
}
|
|
583
|
+
/** `cluster.meshAuth` (D802), then republish it. */
|
|
584
|
+
async writeMeshAuthMode(mode) {
|
|
585
|
+
await this.ctx.settings.setSection("cluster", { [MESH_AUTH_MODE_FIELD]: mode });
|
|
586
|
+
await this.clusterSecretKeeper?.reload();
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* First boot, or a keychain reset that cleared the marker (D802): a fresh
|
|
590
|
+
* random secret, stored like an operator save. A NEW secret means every
|
|
591
|
+
* agent must be given it, so the mesh goes (back) to `transition`; on a hub
|
|
592
|
+
* with no agent yet, the hub's auto-enforce ends it at once.
|
|
593
|
+
*/
|
|
594
|
+
async generateClusterSecret() {
|
|
336
595
|
const settings = this.ctx.settings;
|
|
337
596
|
const secrets = this.systemSecrets();
|
|
597
|
+
if (!settings || secrets === null) throw new Error("system-config: no keychain door — the cluster secret cannot be generated");
|
|
598
|
+
const existing = (await secrets.resolve([require_cluster_secret.CLUSTER_SECRET_REF]))[require_cluster_secret.CLUSTER_SECRET_REF];
|
|
599
|
+
if (existing?.kind === "value") {
|
|
600
|
+
await settings.setSection("cluster", { [CLUSTER_SECRET_FIELD]: require_dist.makeSecretMarker(require_cluster_secret.CLUSTER_SECRET_REF) });
|
|
601
|
+
this.ctx.logger.warn("The cluster secret row was empty but the keychain holds a value — re-adopted it");
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (existing?.kind !== "missing") throw new Error(`the keychain cannot answer for the cluster secret (${existing?.kind ?? "no answer"})`);
|
|
605
|
+
const value = (0, node_crypto.randomBytes)(GENERATED_SECRET_BYTES).toString("base64url");
|
|
606
|
+
await this.storeClusterSecret({ [CLUSTER_SECRET_FIELD]: value });
|
|
607
|
+
await settings.setSection("cluster", { [MESH_AUTH_MODE_FIELD]: "transition" });
|
|
608
|
+
}
|
|
609
|
+
async storeClusterSecret(patch) {
|
|
610
|
+
const settings = this.ctx.settings;
|
|
611
|
+
if (!settings) throw new Error("system-config: ctx.settings not available");
|
|
612
|
+
const secrets = this.systemSecrets();
|
|
338
613
|
if (secrets === null) throw new Error("system-config: no keychain door — the cluster secret cannot be stored");
|
|
339
614
|
const stored = await settings.getSection("cluster");
|
|
340
615
|
const result = await require_dist.transformSecretWrite({
|
|
@@ -346,12 +621,23 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
|
|
|
346
621
|
});
|
|
347
622
|
if (Object.keys(result.patch).length > 0) await settings.setSection("cluster", result.patch);
|
|
348
623
|
const { orphaned } = await result.afterWrite();
|
|
349
|
-
if (orphaned.length > 0) this.ctx.logger.warn("cluster secret
|
|
350
|
-
this.ctx.logger.info("Cluster secret updated");
|
|
351
|
-
await this.clusterSecretKeeper?.reload();
|
|
624
|
+
if (orphaned.length > 0) this.ctx.logger.warn("an old cluster secret value could not be deleted from the keychain", { meta: { refs: orphaned } });
|
|
352
625
|
}
|
|
353
626
|
};
|
|
354
627
|
//#endregion
|
|
628
|
+
exports.MESH_AUTH_MODE_FIELD = MESH_AUTH_MODE_FIELD;
|
|
355
629
|
exports.SYSTEM_CONFIG_CLUSTER_SECTION_ID = SYSTEM_CONFIG_CLUSTER_SECTION_ID;
|
|
356
630
|
exports.SystemConfigAddon = SystemConfigAddon;
|
|
357
631
|
exports.default = SystemConfigAddon;
|
|
632
|
+
exports.createCidrMatcher = createCidrMatcher;
|
|
633
|
+
exports.getProcessMeshAuthMode = getProcessMeshAuthMode;
|
|
634
|
+
exports.getProcessMeshAuthStatus = getProcessMeshAuthStatus;
|
|
635
|
+
exports.isMeshAuthMode = isMeshAuthMode;
|
|
636
|
+
exports.normalizeIp = normalizeIp;
|
|
637
|
+
exports.parseCidrList = parseCidrList;
|
|
638
|
+
exports.parseMeshAuthMode = parseMeshAuthMode;
|
|
639
|
+
exports.readCidrSetting = readCidrSetting;
|
|
640
|
+
exports.requestProcessMeshEnforcement = requestProcessMeshEnforcement;
|
|
641
|
+
exports.setProcessMeshAuthMode = setProcessMeshAuthMode;
|
|
642
|
+
exports.setProcessMeshAuthStatusSource = setProcessMeshAuthStatusSource;
|
|
643
|
+
exports.setProcessMeshEnforcer = setProcessMeshEnforcer;
|