@camstack/system 1.2.386 → 1.2.388
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/system-config/mesh-actions.d.ts +12 -0
- package/dist/builtins/system-config/system-config.addon.d.ts +25 -5
- package/dist/builtins/system-config/system-config.addon.js +303 -59
- package/dist/builtins/system-config/system-config.addon.mjs +296 -61
- package/dist/index.js +9 -0
- package/dist/index.mjs +2 -2
- package/dist/kernel/index.d.ts +2 -0
- package/dist/kernel/moleculer/agent-ui-unlock.d.ts +52 -0
- package/dist/kernel/moleculer/mesh-mtls/tls-tcp-transporter.d.ts +16 -3
- package/package.json +1 -1
|
@@ -15,5 +15,17 @@ export declare const systemConfigMeshActions: {
|
|
|
15
15
|
}, z.core.$strip>, "mutation", "admin", {
|
|
16
16
|
readonly kind: "system";
|
|
17
17
|
}>;
|
|
18
|
+
/** Hub admin → short-lived ticket the agent redeems for a tab session (D821). */
|
|
19
|
+
mintAgentUiUnlockTicket: import('@camstack/types').CustomActionSpec<z.ZodObject<{
|
|
20
|
+
nodeId: z.ZodString;
|
|
21
|
+
dashboardBaseUrl: z.ZodOptional<z.ZodString>;
|
|
22
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
23
|
+
ticket: z.ZodString;
|
|
24
|
+
exp: z.ZodNumber;
|
|
25
|
+
nodeId: z.ZodString;
|
|
26
|
+
dashboardUrl: z.ZodOptional<z.ZodString>;
|
|
27
|
+
}, z.core.$strip>, "mutation", "admin", {
|
|
28
|
+
readonly kind: "system";
|
|
29
|
+
}>;
|
|
18
30
|
};
|
|
19
31
|
export type SystemConfigMeshActions = typeof systemConfigMeshActions;
|
|
@@ -23,6 +23,17 @@ export declare class SystemConfigAddon extends BaseAddon {
|
|
|
23
23
|
}, import('zod/v4/core').$strip>, "mutation", "admin", {
|
|
24
24
|
readonly kind: "system";
|
|
25
25
|
}>;
|
|
26
|
+
mintAgentUiUnlockTicket: import('@camstack/types').CustomActionSpec<import('zod').ZodObject<{
|
|
27
|
+
nodeId: import('zod').ZodString;
|
|
28
|
+
dashboardBaseUrl: import('zod').ZodOptional<import('zod').ZodString>;
|
|
29
|
+
}, import('zod/v4/core').$strip>, import('zod').ZodObject<{
|
|
30
|
+
ticket: import('zod').ZodString;
|
|
31
|
+
exp: import('zod').ZodNumber;
|
|
32
|
+
nodeId: import('zod').ZodString;
|
|
33
|
+
dashboardUrl: import('zod').ZodOptional<import('zod').ZodString>;
|
|
34
|
+
}, import('zod/v4/core').$strip>, "mutation", "admin", {
|
|
35
|
+
readonly kind: "system";
|
|
36
|
+
}>;
|
|
26
37
|
};
|
|
27
38
|
actionHandlers: {
|
|
28
39
|
mintJoinToken: (input: unknown) => Promise<{
|
|
@@ -33,6 +44,12 @@ export declare class SystemConfigAddon extends BaseAddon {
|
|
|
33
44
|
caFingerprint: string;
|
|
34
45
|
nodeId?: string;
|
|
35
46
|
}>;
|
|
47
|
+
mintAgentUiUnlockTicket: (input: unknown) => Promise<{
|
|
48
|
+
ticket: string;
|
|
49
|
+
exp: number;
|
|
50
|
+
nodeId: string;
|
|
51
|
+
dashboardUrl?: string;
|
|
52
|
+
}>;
|
|
36
53
|
};
|
|
37
54
|
}>;
|
|
38
55
|
protected onShutdown(): Promise<void>;
|
|
@@ -44,6 +61,9 @@ export declare class SystemConfigAddon extends BaseAddon {
|
|
|
44
61
|
private applyStoredClearPort;
|
|
45
62
|
private onMeshCaReady;
|
|
46
63
|
private mintJoinToken;
|
|
64
|
+
/** D821 — short-lived ticket for the agent's `:4444` unlock session. */
|
|
65
|
+
private mintAgentUiUnlock;
|
|
66
|
+
private readAgentDashboardOrigins;
|
|
47
67
|
private resolveHubUrl;
|
|
48
68
|
private resolveHubMeshAddress;
|
|
49
69
|
/** The keychain handle for owner `system`; `null` where there is no door. */
|
|
@@ -55,7 +75,8 @@ export declare class SystemConfigAddon extends BaseAddon {
|
|
|
55
75
|
* The cluster secret's save path: the value goes to the keychain (owner
|
|
56
76
|
* `system`), the row gets the marker; `''` / a view keeps. `$secretClear` is
|
|
57
77
|
* REFUSED: the secret is mandatory (D802). A value the keychain cannot store
|
|
58
|
-
* is REFUSED — never written inline (D787, P5).
|
|
78
|
+
* is REFUSED — never written inline (D787, P5). Mesh auth mode is always
|
|
79
|
+
* enforced (D821) — a legacy `meshAuth` in the patch is ignored.
|
|
59
80
|
*/
|
|
60
81
|
private writeClusterSection;
|
|
61
82
|
/**
|
|
@@ -64,13 +85,12 @@ export declare class SystemConfigAddon extends BaseAddon {
|
|
|
64
85
|
* Persist, then apply — an `on` save always passes.
|
|
65
86
|
*/
|
|
66
87
|
private writeMeshClearPort;
|
|
67
|
-
/**
|
|
88
|
+
/** Persist + publish mesh auth mode (always `enforced` after D821). */
|
|
68
89
|
private writeMeshAuthMode;
|
|
69
90
|
/**
|
|
70
91
|
* First boot, or a keychain reset that cleared the marker (D802): a fresh
|
|
71
|
-
* random secret, stored like an operator save.
|
|
72
|
-
*
|
|
73
|
-
* with no agent yet, the hub's auto-enforce ends it at once.
|
|
92
|
+
* random secret, stored like an operator save. Mesh auth stays enforced
|
|
93
|
+
* (D821) — every agent must present the secret; there is no transition window.
|
|
74
94
|
*/
|
|
75
95
|
private generateClusterSecret;
|
|
76
96
|
private storeClusterSecret;
|
|
@@ -150,6 +150,145 @@ function getProcessMeshAuthStatus() {
|
|
|
150
150
|
return typeof source === "function" ? source() : null;
|
|
151
151
|
}
|
|
152
152
|
//#endregion
|
|
153
|
+
//#region src/kernel/moleculer/agent-ui-unlock.ts
|
|
154
|
+
/**
|
|
155
|
+
* Agent UI remote unlock tickets (D821).
|
|
156
|
+
*
|
|
157
|
+
* A hub admin mints a short-lived HMAC ticket bound to one agent `nodeId`.
|
|
158
|
+
* The agent redeems it once for a tab session Bearer — the cluster secret
|
|
159
|
+
* stays a mesh / pairing credential and is not pasted into the browser for
|
|
160
|
+
* daily unlock.
|
|
161
|
+
*
|
|
162
|
+
* K = SHA-256(cluster secret) — same as mesh join (D802)
|
|
163
|
+
* ticket = base64url(JSON payload) + '.' + base64url(HMAC-SHA256(K, DOMAIN|payloadB64))
|
|
164
|
+
* payload = { v: 1, nodeId, exp (ms), jti }
|
|
165
|
+
*/
|
|
166
|
+
var AGENT_UI_UNLOCK_DOMAIN = "camstack-agent-ui-unlock/v1";
|
|
167
|
+
var AGENT_UI_UNLOCK_VERSION = 1;
|
|
168
|
+
/** Ticket lifetime — long enough for a redirect hop, short enough to steal poorly. */
|
|
169
|
+
var AGENT_UI_UNLOCK_TTL_MS = 5 * 6e4;
|
|
170
|
+
/**
|
|
171
|
+
* Cluster settings key: extra origins allowed as `returnTo` for Continue-with-hub
|
|
172
|
+
* (NPM hostnames that are not `:4444` on a LAN IP). Comma-separated origins.
|
|
173
|
+
*/
|
|
174
|
+
var AGENT_DASHBOARD_ORIGINS_FIELD = "agentDashboardOrigins";
|
|
175
|
+
/** In-memory spent `jti` until `exp` (ms). */
|
|
176
|
+
var UnlockTicketJtiStore = class {
|
|
177
|
+
spent = /* @__PURE__ */ new Map();
|
|
178
|
+
isSpent(jti, nowMs = Date.now()) {
|
|
179
|
+
this.gc(nowMs);
|
|
180
|
+
return this.spent.has(jti);
|
|
181
|
+
}
|
|
182
|
+
/** Record a jti as used until `exp`. Returns false if already spent. */
|
|
183
|
+
consume(jti, exp, nowMs = Date.now()) {
|
|
184
|
+
this.gc(nowMs);
|
|
185
|
+
if (this.spent.has(jti)) return false;
|
|
186
|
+
this.spent.set(jti, exp);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
/** Test helper. */
|
|
190
|
+
size() {
|
|
191
|
+
return this.spent.size;
|
|
192
|
+
}
|
|
193
|
+
gc(nowMs) {
|
|
194
|
+
for (const [jti, exp] of this.spent) if (exp <= nowMs) this.spent.delete(jti);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
function mintAgentUiUnlockTicket(input) {
|
|
198
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
199
|
+
const ttl = Math.min(Math.max(1e3, input.ttlMs ?? 3e5), AGENT_UI_UNLOCK_TTL_MS);
|
|
200
|
+
const payload = {
|
|
201
|
+
v: 1,
|
|
202
|
+
nodeId: input.nodeId,
|
|
203
|
+
exp: nowMs + ttl,
|
|
204
|
+
jti: input.jti ?? (0, node_crypto.randomBytes)(16).toString("base64url")
|
|
205
|
+
};
|
|
206
|
+
const payloadB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
207
|
+
return `${payloadB64}.${sign(input.secretHash, payloadB64)}`;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Verify a ticket under `secretHash`. Returns the payload, or `null` when the
|
|
211
|
+
* signature, shape, expiry or nodeId fails (no reason string — refuse quietly).
|
|
212
|
+
*/
|
|
213
|
+
function verifyAgentUiUnlockTicket(secretHash, ticket, options) {
|
|
214
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
215
|
+
const dot = ticket.indexOf(".");
|
|
216
|
+
if (dot <= 0 || dot === ticket.length - 1) return null;
|
|
217
|
+
const payloadB64 = ticket.slice(0, dot);
|
|
218
|
+
const sig = ticket.slice(dot + 1);
|
|
219
|
+
if (!sigsEqual(sign(secretHash, payloadB64), sig)) return null;
|
|
220
|
+
let parsed;
|
|
221
|
+
try {
|
|
222
|
+
parsed = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8"));
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
const payload = parsePayload(parsed);
|
|
227
|
+
if (payload === null) return null;
|
|
228
|
+
if (payload.exp <= nowMs) return null;
|
|
229
|
+
if (payload.nodeId !== options.expectedNodeId) return null;
|
|
230
|
+
return payload;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* True when `returnTo` may receive a ticket redirect. Default: any http(s)
|
|
234
|
+
* origin whose port is 4444. Extra origins come from
|
|
235
|
+
* {@link AGENT_DASHBOARD_ORIGINS_FIELD} (exact origin match).
|
|
236
|
+
*/
|
|
237
|
+
function isAllowedAgentDashboardReturnTo(returnTo, extraOrigins = []) {
|
|
238
|
+
let url;
|
|
239
|
+
try {
|
|
240
|
+
url = new URL(returnTo);
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
245
|
+
if (url.username !== "" || url.password !== "") return false;
|
|
246
|
+
if (url.pathname !== "/" && url.pathname !== "") return false;
|
|
247
|
+
if (url.port === "4444") return true;
|
|
248
|
+
const origin = url.origin;
|
|
249
|
+
return extraOrigins.some((o) => o === origin);
|
|
250
|
+
}
|
|
251
|
+
/** Parse a stored comma/whitespace-separated origins list. */
|
|
252
|
+
function parseAgentDashboardOrigins(stored) {
|
|
253
|
+
if (typeof stored !== "string" || stored.trim().length === 0) return [];
|
|
254
|
+
const out = [];
|
|
255
|
+
for (const part of stored.split(/[,\s]+/)) {
|
|
256
|
+
const t = part.trim();
|
|
257
|
+
if (t.length === 0) continue;
|
|
258
|
+
try {
|
|
259
|
+
const u = new URL(t);
|
|
260
|
+
if (u.protocol === "http:" || u.protocol === "https:") out.push(u.origin);
|
|
261
|
+
} catch {}
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
function sign(secretHash, payloadB64) {
|
|
266
|
+
return (0, node_crypto.createHmac)("sha256", Buffer.from(secretHash, "utf8")).update(`${AGENT_UI_UNLOCK_DOMAIN}|${payloadB64}`, "utf8").digest("base64url");
|
|
267
|
+
}
|
|
268
|
+
function sigsEqual(expected, presented) {
|
|
269
|
+
const a = Buffer.from(expected, "utf8");
|
|
270
|
+
const b = Buffer.from(presented, "utf8");
|
|
271
|
+
if (a.length !== b.length) return false;
|
|
272
|
+
return (0, node_crypto.timingSafeEqual)(a, b);
|
|
273
|
+
}
|
|
274
|
+
function parsePayload(value) {
|
|
275
|
+
if (typeof value !== "object" || value === null) return null;
|
|
276
|
+
const rec = value;
|
|
277
|
+
if (rec["v"] !== 1) return null;
|
|
278
|
+
const nodeId = rec["nodeId"];
|
|
279
|
+
const exp = rec["exp"];
|
|
280
|
+
const jti = rec["jti"];
|
|
281
|
+
if (typeof nodeId !== "string" || nodeId.length === 0) return null;
|
|
282
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
|
|
283
|
+
if (typeof jti !== "string" || jti.length === 0) return null;
|
|
284
|
+
return {
|
|
285
|
+
v: 1,
|
|
286
|
+
nodeId,
|
|
287
|
+
exp,
|
|
288
|
+
jti
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
//#endregion
|
|
153
292
|
//#region src/kernel/moleculer/mesh-mtls/mesh-ca.ts
|
|
154
293
|
require_x509_es.cryptoProvider.set(node_crypto.webcrypto);
|
|
155
294
|
var MS_PER_DAY = 864e5;
|
|
@@ -87378,6 +87517,9 @@ var TlsTcpTransporter = class extends TcpTransporter {
|
|
|
87378
87517
|
/** Node id (certificate CN) → serial of every peer that completed a mutual-TLS handshake. */
|
|
87379
87518
|
tlsPeers = /* @__PURE__ */ new Map();
|
|
87380
87519
|
clearDisabled = false;
|
|
87520
|
+
/** Clear-text `TcpWriter.connect` captured once before the TLS wrap (A→B). */
|
|
87521
|
+
clearWriterConnect = null;
|
|
87522
|
+
writerTlsPatched = false;
|
|
87381
87523
|
constructor(opts = {}) {
|
|
87382
87524
|
super({ ...opts });
|
|
87383
87525
|
this.meshTls = opts.meshTls ?? null;
|
|
@@ -87400,20 +87542,43 @@ var TlsTcpTransporter = class extends TcpTransporter {
|
|
|
87400
87542
|
}).then(() => this.onConnected());
|
|
87401
87543
|
}
|
|
87402
87544
|
/**
|
|
87403
|
-
*
|
|
87404
|
-
*
|
|
87545
|
+
* Outbound dial uses mTLS when the peer is already known on TLS (inbound
|
|
87546
|
+
* handshake), or when dialling the hub node id (agent → hub). Everyone else
|
|
87547
|
+
* (runners, unenrolled agents) stays on the clear writer — the hub must not
|
|
87548
|
+
* TLS-wrap every `connect` after deferred `enableMeshTls`.
|
|
87549
|
+
*/
|
|
87550
|
+
shouldDialPeerWithTls(nodeID) {
|
|
87551
|
+
if (nodeID === "hub") return true;
|
|
87552
|
+
return this.tlsPeers.has(nodeID);
|
|
87553
|
+
}
|
|
87554
|
+
peerTlsDialPort() {
|
|
87555
|
+
return this.opts.tlsPort ?? 6443;
|
|
87556
|
+
}
|
|
87557
|
+
/**
|
|
87558
|
+
* Wrap `TcpWriter.connect` so TLS peers are dialed with mutual TLS on the
|
|
87559
|
+
* well-known mesh TLS port (default 6443). Idempotent: the clear connect is
|
|
87560
|
+
* captured once; material refresh rebinds the wrapper.
|
|
87405
87561
|
*/
|
|
87406
87562
|
patchWriterForTlsClient() {
|
|
87407
|
-
|
|
87408
|
-
if (material === null) return;
|
|
87563
|
+
if (this.meshTls === null) return;
|
|
87409
87564
|
const writer = this.writer;
|
|
87410
87565
|
if (writer === null || typeof writer.connect !== "function") return;
|
|
87566
|
+
if (!this.writerTlsPatched) {
|
|
87567
|
+
this.clearWriterConnect = writer.connect.bind(writer);
|
|
87568
|
+
this.writerTlsPatched = true;
|
|
87569
|
+
}
|
|
87570
|
+
const clearConnect = this.clearWriterConnect;
|
|
87571
|
+
if (clearConnect === null) return;
|
|
87411
87572
|
const crl = this.crl;
|
|
87573
|
+
const self = this;
|
|
87412
87574
|
writer.connect = (nodeID) => {
|
|
87575
|
+
if (!self.shouldDialPeerWithTls(nodeID)) return clearConnect(nodeID);
|
|
87576
|
+
const material = self.meshTls;
|
|
87577
|
+
if (material === null) return clearConnect(nodeID);
|
|
87413
87578
|
const node = writer.transporter.getNode(nodeID);
|
|
87414
87579
|
if (!node) return writer.Promise.reject(/* @__PURE__ */ new Error(`Missing node info for '${nodeID}'!`));
|
|
87415
87580
|
const host = writer.transporter.getNodeAddress(node);
|
|
87416
|
-
const port =
|
|
87581
|
+
const port = self.peerTlsDialPort();
|
|
87417
87582
|
writer.logger.debug(`Connecting (TLS) to '${nodeID}' via ${host}:${port}`);
|
|
87418
87583
|
return new writer.Promise((resolve, reject) => {
|
|
87419
87584
|
const socket = node_tls.connect({
|
|
@@ -87442,6 +87607,11 @@ var TlsTcpTransporter = class extends TcpTransporter {
|
|
|
87442
87607
|
if (identity) {
|
|
87443
87608
|
tlsSock.meshPeerCn = identity.cn;
|
|
87444
87609
|
tlsSock.meshPeerSerial = identity.serial;
|
|
87610
|
+
self.tlsPeers.set(identity.cn, identity.serial);
|
|
87611
|
+
self.tlsSockets.add(tlsSock);
|
|
87612
|
+
tlsSock.on("close", () => {
|
|
87613
|
+
self.tlsSockets.delete(tlsSock);
|
|
87614
|
+
});
|
|
87445
87615
|
}
|
|
87446
87616
|
writer.addSocket(nodeID, tlsSock, true);
|
|
87447
87617
|
writer.logger.debug(`Connected successfully (TLS) to '${nodeID}'.`);
|
|
@@ -87464,11 +87634,13 @@ var TlsTcpTransporter = class extends TcpTransporter {
|
|
|
87464
87634
|
}
|
|
87465
87635
|
/**
|
|
87466
87636
|
* Enable (or refresh) the TLS listener once the mesh CA / hub leaf are ready.
|
|
87467
|
-
* Safe to call after connect — starts listening on `tlsPort` alongside clear
|
|
87637
|
+
* Safe to call after connect — starts listening on `tlsPort` alongside clear
|
|
87638
|
+
* and patches the writer so hub→agent dials use mTLS (Release A→B).
|
|
87468
87639
|
*/
|
|
87469
87640
|
async enableMeshTls(material) {
|
|
87470
87641
|
this.meshTls = material;
|
|
87471
87642
|
await this.startTlsServerIfReady();
|
|
87643
|
+
this.patchWriterForTlsClient();
|
|
87472
87644
|
}
|
|
87473
87645
|
/**
|
|
87474
87646
|
* How `nodeId` reaches this transporter: `tls` once it completed a mutual-TLS
|
|
@@ -87660,7 +87832,7 @@ function isTlsSocket(socket) {
|
|
|
87660
87832
|
* - no marker → the secret is MANDATORY (D802): the keeper asks
|
|
87661
87833
|
* its owner to generate one (`generateSecret`), and
|
|
87662
87834
|
* until that succeeds the gate is `off` — no node can
|
|
87663
|
-
* authenticate
|
|
87835
|
+
* authenticate (mesh auth is always enforced, D821);
|
|
87664
87836
|
* - marker + value → `on` — nodes prove the secret;
|
|
87665
87837
|
* - marker, no answer → `unknown` — refused, retryably, and retried here.
|
|
87666
87838
|
* A locked keychain or a value lost to a reset NEVER opens the gate, and
|
|
@@ -87723,7 +87895,7 @@ var ClusterSecretKeeper = class {
|
|
|
87723
87895
|
reason: `settings unreadable: ${errText(err)}`
|
|
87724
87896
|
};
|
|
87725
87897
|
}
|
|
87726
|
-
setProcessMeshAuthMode(
|
|
87898
|
+
setProcessMeshAuthMode("enforced");
|
|
87727
87899
|
const stored = section[CLUSTER_SECRET_FIELD];
|
|
87728
87900
|
if (stored === void 0 || stored === null || stored === "") return { kind: "off" };
|
|
87729
87901
|
if (!require_dist.isSecretMarker(stored)) return {
|
|
@@ -87791,28 +87963,48 @@ function errText(err) {
|
|
|
87791
87963
|
//#endregion
|
|
87792
87964
|
//#region src/builtins/system-config/mesh-actions.ts
|
|
87793
87965
|
/**
|
|
87794
|
-
* System → Cluster — mint a single-use mesh join token (Release A)
|
|
87966
|
+
* System → Cluster — mint a single-use mesh join token (Release A) and
|
|
87967
|
+
* agent-ui unlock tickets (D821).
|
|
87795
87968
|
*/
|
|
87796
|
-
var systemConfigMeshActions = require_dist.defineCustomActions({
|
|
87797
|
-
|
|
87798
|
-
|
|
87799
|
-
|
|
87800
|
-
|
|
87801
|
-
|
|
87802
|
-
|
|
87803
|
-
|
|
87804
|
-
|
|
87805
|
-
|
|
87806
|
-
|
|
87807
|
-
|
|
87808
|
-
|
|
87809
|
-
|
|
87810
|
-
|
|
87811
|
-
|
|
87812
|
-
|
|
87813
|
-
|
|
87814
|
-
|
|
87815
|
-
|
|
87969
|
+
var systemConfigMeshActions = require_dist.defineCustomActions({
|
|
87970
|
+
mintJoinToken: require_dist.customAction(zod.z.object({
|
|
87971
|
+
/** Optional pinned node id for the CSR CN. */
|
|
87972
|
+
nodeId: zod.z.string().min(1).optional(),
|
|
87973
|
+
/** Override hub HTTPS base (default: local-network publicEndpoint). */
|
|
87974
|
+
hubUrl: zod.z.string().url().optional(),
|
|
87975
|
+
/** Override `host:6000` clear mesh address carried in the token. */
|
|
87976
|
+
hubMeshAddress: zod.z.string().min(3).optional(),
|
|
87977
|
+
/** Seconds until expiry; capped at 1 h. */
|
|
87978
|
+
ttlSec: zod.z.number().int().positive().max(3600).optional()
|
|
87979
|
+
}).optional(), zod.z.object({
|
|
87980
|
+
token: zod.z.string().min(1),
|
|
87981
|
+
exp: zod.z.number().int(),
|
|
87982
|
+
hubUrl: zod.z.string(),
|
|
87983
|
+
hubMeshAddress: zod.z.string(),
|
|
87984
|
+
caFingerprint: zod.z.string(),
|
|
87985
|
+
nodeId: zod.z.string().optional()
|
|
87986
|
+
}), {
|
|
87987
|
+
kind: "mutation",
|
|
87988
|
+
auth: "admin"
|
|
87989
|
+
}),
|
|
87990
|
+
/** Hub admin → short-lived ticket the agent redeems for a tab session (D821). */
|
|
87991
|
+
mintAgentUiUnlockTicket: require_dist.customAction(zod.z.object({
|
|
87992
|
+
nodeId: zod.z.string().min(1),
|
|
87993
|
+
/**
|
|
87994
|
+
* Preferred agent dashboard base (`http://host:4444`). When set, the
|
|
87995
|
+
* answer includes `dashboardUrl` with `?ticket=` already attached.
|
|
87996
|
+
*/
|
|
87997
|
+
dashboardBaseUrl: zod.z.string().url().optional()
|
|
87998
|
+
}), zod.z.object({
|
|
87999
|
+
ticket: zod.z.string().min(1),
|
|
88000
|
+
exp: zod.z.number().int(),
|
|
88001
|
+
nodeId: zod.z.string().min(1),
|
|
88002
|
+
dashboardUrl: zod.z.string().url().optional()
|
|
88003
|
+
}), {
|
|
88004
|
+
kind: "mutation",
|
|
88005
|
+
auth: "admin"
|
|
88006
|
+
})
|
|
88007
|
+
});
|
|
87816
88008
|
//#endregion
|
|
87817
88009
|
//#region src/builtins/system-config/system-config.addon.ts
|
|
87818
88010
|
/**
|
|
@@ -87877,7 +88069,8 @@ var KEY_TO_SECTION = {
|
|
|
87877
88069
|
trustedNetworks: "auth",
|
|
87878
88070
|
[CLUSTER_SECRET_FIELD]: "cluster",
|
|
87879
88071
|
[MESH_AUTH_MODE_FIELD]: "cluster",
|
|
87880
|
-
[MESH_CLEAR_PORT_FIELD]: "cluster"
|
|
88072
|
+
[MESH_CLEAR_PORT_FIELD]: "cluster",
|
|
88073
|
+
[AGENT_DASHBOARD_ORIGINS_FIELD]: "cluster"
|
|
87881
88074
|
};
|
|
87882
88075
|
/** Bytes of a generated cluster secret (D802) — 256 bits, base64url (43 chars). */
|
|
87883
88076
|
var GENERATED_SECRET_BYTES = 32;
|
|
@@ -87886,10 +88079,10 @@ var STATE_LABELS = {
|
|
|
87886
88079
|
legacy: "legacy hash only — update the agent",
|
|
87887
88080
|
unauthenticated: "not authenticated — enter the cluster secret in this agent"
|
|
87888
88081
|
};
|
|
87889
|
-
/** The Cluster page's live line per node (D802). */
|
|
88082
|
+
/** The Cluster page's live line per node (D802). Mode is always enforced (D821). */
|
|
87890
88083
|
function describeMeshStatus(status) {
|
|
87891
88084
|
if (status === null) return "The mesh status is available on the hub only.";
|
|
87892
|
-
const head =
|
|
88085
|
+
const head = "Enforced: a node that has not authenticated is refused.";
|
|
87893
88086
|
if (status.nodes.length === 0) return `${head}\nNo other node is connected.`;
|
|
87894
88087
|
return `${head}\n${status.nodes.map((n) => `• ${n.nodeId}: ${STATE_LABELS[n.state] ?? n.state}`).join("\n")}`;
|
|
87895
88088
|
}
|
|
@@ -87920,6 +88113,9 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
|
|
|
87920
88113
|
this.ctx.logger.info("Mesh authentication is now ENFORCED", { meta: { reason } });
|
|
87921
88114
|
});
|
|
87922
88115
|
await this.clusterSecretKeeper.start();
|
|
88116
|
+
try {
|
|
88117
|
+
await this.writeMeshAuthMode("enforced");
|
|
88118
|
+
} catch {}
|
|
87923
88119
|
this.meshCaKeeper = new MeshCaKeeper({
|
|
87924
88120
|
secrets: this.systemSecrets(),
|
|
87925
88121
|
readClusterSection: async () => await this.ctx.settings?.getSection("cluster") ?? {},
|
|
@@ -87934,7 +88130,10 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
|
|
|
87934
88130
|
this.ctx.logger.info("Initialized — exposes yml-backed sections via getGlobalSettings");
|
|
87935
88131
|
return {
|
|
87936
88132
|
customActions: systemConfigMeshActions,
|
|
87937
|
-
actionHandlers: {
|
|
88133
|
+
actionHandlers: {
|
|
88134
|
+
mintJoinToken: async (input) => this.mintJoinToken(parseMintJoinTokenInput(input)),
|
|
88135
|
+
mintAgentUiUnlockTicket: async (input) => this.mintAgentUiUnlock(parseMintUnlockInput(input))
|
|
88136
|
+
}
|
|
87938
88137
|
};
|
|
87939
88138
|
}
|
|
87940
88139
|
async onShutdown() {
|
|
@@ -87987,6 +88186,41 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
|
|
|
87987
88186
|
...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {}
|
|
87988
88187
|
};
|
|
87989
88188
|
}
|
|
88189
|
+
/** D821 — short-lived ticket for the agent's `:4444` unlock session. */
|
|
88190
|
+
async mintAgentUiUnlock(input) {
|
|
88191
|
+
const secrets = this.systemSecrets();
|
|
88192
|
+
if (secrets === null) throw new Error("system-config: no keychain door — cannot mint an unlock ticket");
|
|
88193
|
+
const entry = (await secrets.resolve([require_constants$8.CLUSTER_SECRET_REF]))[require_constants$8.CLUSTER_SECRET_REF];
|
|
88194
|
+
if (entry?.kind !== "value") throw new Error("system-config: cluster secret is not available — cannot mint an unlock ticket");
|
|
88195
|
+
if (input.dashboardBaseUrl !== void 0) {
|
|
88196
|
+
const origins = await this.readAgentDashboardOrigins();
|
|
88197
|
+
if (!isAllowedAgentDashboardReturnTo(input.dashboardBaseUrl, origins)) throw new Error("dashboardBaseUrl is not an allowed agent dashboard origin (:4444 or agentDashboardOrigins)");
|
|
88198
|
+
}
|
|
88199
|
+
const ticket = mintAgentUiUnlockTicket({
|
|
88200
|
+
secretHash: require_constants$8.hashClusterSecret(entry.value),
|
|
88201
|
+
nodeId: input.nodeId
|
|
88202
|
+
});
|
|
88203
|
+
const payloadJson = Buffer.from(ticket.split(".")[0] ?? "", "base64url").toString("utf8");
|
|
88204
|
+
const exp = JSON.parse(payloadJson).exp ?? 0;
|
|
88205
|
+
const dashboardUrl = input.dashboardBaseUrl === void 0 ? void 0 : (() => {
|
|
88206
|
+
const u = new URL(input.dashboardBaseUrl);
|
|
88207
|
+
u.searchParams.set("ticket", ticket);
|
|
88208
|
+
return u.toString();
|
|
88209
|
+
})();
|
|
88210
|
+
return {
|
|
88211
|
+
ticket,
|
|
88212
|
+
exp,
|
|
88213
|
+
nodeId: input.nodeId,
|
|
88214
|
+
...dashboardUrl !== void 0 ? { dashboardUrl } : {}
|
|
88215
|
+
};
|
|
88216
|
+
}
|
|
88217
|
+
async readAgentDashboardOrigins() {
|
|
88218
|
+
try {
|
|
88219
|
+
return parseAgentDashboardOrigins((await this.ctx.settings?.getSection("cluster"))?.[AGENT_DASHBOARD_ORIGINS_FIELD]);
|
|
88220
|
+
} catch {
|
|
88221
|
+
return [];
|
|
88222
|
+
}
|
|
88223
|
+
}
|
|
87990
88224
|
async resolveHubUrl() {
|
|
87991
88225
|
try {
|
|
87992
88226
|
const book = await this.ctx.api?.localNetwork?.getAddresses?.query({ scheme: "https" });
|
|
@@ -88106,20 +88340,6 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
|
|
|
88106
88340
|
label: "Cluster secret",
|
|
88107
88341
|
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."
|
|
88108
88342
|
},
|
|
88109
|
-
{
|
|
88110
|
-
type: "select",
|
|
88111
|
-
key: MESH_AUTH_MODE_FIELD,
|
|
88112
|
-
label: "Agents without the secret",
|
|
88113
|
-
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.",
|
|
88114
|
-
default: "transition",
|
|
88115
|
-
options: [{
|
|
88116
|
-
value: "transition",
|
|
88117
|
-
label: "Transition — allow, warn"
|
|
88118
|
-
}, {
|
|
88119
|
-
value: "enforced",
|
|
88120
|
-
label: "Enforced — refuse"
|
|
88121
|
-
}]
|
|
88122
|
-
},
|
|
88123
88343
|
{
|
|
88124
88344
|
type: "select",
|
|
88125
88345
|
key: MESH_CLEAR_PORT_FIELD,
|
|
@@ -88141,6 +88361,12 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
|
|
|
88141
88361
|
content: describeMeshStatus(getProcessMeshAuthStatus()),
|
|
88142
88362
|
variant: "info"
|
|
88143
88363
|
},
|
|
88364
|
+
{
|
|
88365
|
+
type: "string",
|
|
88366
|
+
key: AGENT_DASHBOARD_ORIGINS_FIELD,
|
|
88367
|
+
label: "Agent dashboard origins (NPM)",
|
|
88368
|
+
description: "Extra origins allowed for Continue-with-hub unlock redirects (comma-separated), e.g. https://agent.example.com. LAN agents on :4444 are always allowed."
|
|
88369
|
+
},
|
|
88144
88370
|
{
|
|
88145
88371
|
type: "addon-action-button",
|
|
88146
88372
|
key: "mintJoinTokenAction",
|
|
@@ -88213,15 +88439,14 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
|
|
|
88213
88439
|
* The cluster secret's save path: the value goes to the keychain (owner
|
|
88214
88440
|
* `system`), the row gets the marker; `''` / a view keeps. `$secretClear` is
|
|
88215
88441
|
* REFUSED: the secret is mandatory (D802). A value the keychain cannot store
|
|
88216
|
-
* is REFUSED — never written inline (D787, P5).
|
|
88442
|
+
* is REFUSED — never written inline (D787, P5). Mesh auth mode is always
|
|
88443
|
+
* enforced (D821) — a legacy `meshAuth` in the patch is ignored.
|
|
88217
88444
|
*/
|
|
88218
88445
|
async writeClusterSection(patch) {
|
|
88219
|
-
const { [MESH_AUTH_MODE_FIELD]:
|
|
88446
|
+
const { [MESH_AUTH_MODE_FIELD]: _legacyMode, [MESH_CLEAR_PORT_FIELD]: clearPort, [AGENT_DASHBOARD_ORIGINS_FIELD]: dashboardOrigins, ...secretPatch } = patch;
|
|
88220
88447
|
if (clearPort !== void 0) await this.writeMeshClearPort(clearPort);
|
|
88221
|
-
if (
|
|
88222
|
-
|
|
88223
|
-
await this.writeMeshAuthMode(mode);
|
|
88224
|
-
}
|
|
88448
|
+
if (dashboardOrigins !== void 0) await this.ctx.settings.setSection("cluster", { [AGENT_DASHBOARD_ORIGINS_FIELD]: typeof dashboardOrigins === "string" ? dashboardOrigins : "" });
|
|
88449
|
+
await this.writeMeshAuthMode("enforced");
|
|
88225
88450
|
if (Object.keys(secretPatch).length === 0) return;
|
|
88226
88451
|
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");
|
|
88227
88452
|
await this.storeClusterSecret(secretPatch);
|
|
@@ -88249,16 +88474,15 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
|
|
|
88249
88474
|
await applyProcessMeshClearPort(value);
|
|
88250
88475
|
this.ctx.logger.info("Mesh clear port switched", { meta: { mode: value } });
|
|
88251
88476
|
}
|
|
88252
|
-
/**
|
|
88477
|
+
/** Persist + publish mesh auth mode (always `enforced` after D821). */
|
|
88253
88478
|
async writeMeshAuthMode(mode) {
|
|
88254
88479
|
await this.ctx.settings.setSection("cluster", { [MESH_AUTH_MODE_FIELD]: mode });
|
|
88255
|
-
|
|
88480
|
+
setProcessMeshAuthMode(mode);
|
|
88256
88481
|
}
|
|
88257
88482
|
/**
|
|
88258
88483
|
* First boot, or a keychain reset that cleared the marker (D802): a fresh
|
|
88259
|
-
* random secret, stored like an operator save.
|
|
88260
|
-
*
|
|
88261
|
-
* with no agent yet, the hub's auto-enforce ends it at once.
|
|
88484
|
+
* random secret, stored like an operator save. Mesh auth stays enforced
|
|
88485
|
+
* (D821) — every agent must present the secret; there is no transition window.
|
|
88262
88486
|
*/
|
|
88263
88487
|
async generateClusterSecret() {
|
|
88264
88488
|
const settings = this.ctx.settings;
|
|
@@ -88273,7 +88497,8 @@ var SystemConfigAddon = class extends require_dist.BaseAddon {
|
|
|
88273
88497
|
if (existing?.kind !== "missing") throw new Error(`the keychain cannot answer for the cluster secret (${existing?.kind ?? "no answer"})`);
|
|
88274
88498
|
const value = (0, node_crypto.randomBytes)(GENERATED_SECRET_BYTES).toString("base64url");
|
|
88275
88499
|
await this.storeClusterSecret({ [CLUSTER_SECRET_FIELD]: value });
|
|
88276
|
-
await settings.setSection("cluster", { [MESH_AUTH_MODE_FIELD]: "
|
|
88500
|
+
await settings.setSection("cluster", { [MESH_AUTH_MODE_FIELD]: "enforced" });
|
|
88501
|
+
setProcessMeshAuthMode("enforced");
|
|
88277
88502
|
}
|
|
88278
88503
|
async storeClusterSecret(patch) {
|
|
88279
88504
|
const settings = this.ctx.settings;
|
|
@@ -88306,6 +88531,16 @@ function parseMintJoinTokenInput(input) {
|
|
|
88306
88531
|
if (typeof ttlSec === "number") out.ttlSec = ttlSec;
|
|
88307
88532
|
return out;
|
|
88308
88533
|
}
|
|
88534
|
+
function parseMintUnlockInput(input) {
|
|
88535
|
+
if (input === null || typeof input !== "object") throw new Error("system-config: mintAgentUiUnlockTicket requires { nodeId }");
|
|
88536
|
+
const nodeId = Reflect.get(input, "nodeId");
|
|
88537
|
+
if (typeof nodeId !== "string" || nodeId.length === 0) throw new Error("system-config: mintAgentUiUnlockTicket requires { nodeId }");
|
|
88538
|
+
const dashboardBaseUrl = Reflect.get(input, "dashboardBaseUrl");
|
|
88539
|
+
return {
|
|
88540
|
+
nodeId,
|
|
88541
|
+
...typeof dashboardBaseUrl === "string" ? { dashboardBaseUrl } : {}
|
|
88542
|
+
};
|
|
88543
|
+
}
|
|
88309
88544
|
/** Read `exp` from an unencrypted JWT payload (no verify — already minted here). */
|
|
88310
88545
|
function readJwtExp(token) {
|
|
88311
88546
|
const parts = token.split(".");
|
|
@@ -88317,6 +88552,10 @@ function readJwtExp(token) {
|
|
|
88317
88552
|
return typeof exp === "number" ? exp : 0;
|
|
88318
88553
|
}
|
|
88319
88554
|
//#endregion
|
|
88555
|
+
exports.AGENT_DASHBOARD_ORIGINS_FIELD = AGENT_DASHBOARD_ORIGINS_FIELD;
|
|
88556
|
+
exports.AGENT_UI_UNLOCK_DOMAIN = AGENT_UI_UNLOCK_DOMAIN;
|
|
88557
|
+
exports.AGENT_UI_UNLOCK_TTL_MS = AGENT_UI_UNLOCK_TTL_MS;
|
|
88558
|
+
exports.AGENT_UI_UNLOCK_VERSION = AGENT_UI_UNLOCK_VERSION;
|
|
88320
88559
|
exports.Errors = Errors;
|
|
88321
88560
|
exports.JoinTokenJtiStore = JoinTokenJtiStore;
|
|
88322
88561
|
exports.MESH_AUTH_MODE_FIELD = MESH_AUTH_MODE_FIELD;
|
|
@@ -88328,6 +88567,7 @@ exports.ServiceBroker = ServiceBroker;
|
|
|
88328
88567
|
exports.SystemConfigAddon = SystemConfigAddon;
|
|
88329
88568
|
exports.default = SystemConfigAddon;
|
|
88330
88569
|
exports.TlsTcpTransporter = TlsTcpTransporter;
|
|
88570
|
+
exports.UnlockTicketJtiStore = UnlockTicketJtiStore;
|
|
88331
88571
|
exports.applyProcessMeshClearPort = applyProcessMeshClearPort;
|
|
88332
88572
|
exports.caFingerprint = caFingerprint;
|
|
88333
88573
|
exports.caFingerprintCompact = caFingerprintCompact;
|
|
@@ -88343,12 +88583,15 @@ exports.getProcessMeshAuthMode = getProcessMeshAuthMode;
|
|
|
88343
88583
|
exports.getProcessMeshAuthStatus = getProcessMeshAuthStatus;
|
|
88344
88584
|
exports.getProcessMeshCaState = getProcessMeshCaState;
|
|
88345
88585
|
exports.getProcessNodeMeshTransport = getProcessNodeMeshTransport;
|
|
88586
|
+
exports.isAllowedAgentDashboardReturnTo = isAllowedAgentDashboardReturnTo;
|
|
88346
88587
|
exports.isMeshAuthMode = isMeshAuthMode;
|
|
88347
88588
|
exports.issueMeshLeafForNode = issueMeshLeafForNode;
|
|
88348
88589
|
exports.issueMeshLeafFromCsr = issueMeshLeafFromCsr;
|
|
88349
88590
|
exports.loadMeshCa = loadMeshCa;
|
|
88591
|
+
exports.mintAgentUiUnlockTicket = mintAgentUiUnlockTicket;
|
|
88350
88592
|
exports.mintMeshJoinToken = mintMeshJoinToken;
|
|
88351
88593
|
exports.normalizeIp = normalizeIp;
|
|
88594
|
+
exports.parseAgentDashboardOrigins = parseAgentDashboardOrigins;
|
|
88352
88595
|
exports.parseCidrList = parseCidrList;
|
|
88353
88596
|
exports.parseMeshAuthMode = parseMeshAuthMode;
|
|
88354
88597
|
exports.processJoinTokenJtiStore = processJoinTokenJtiStore;
|
|
@@ -88362,4 +88605,5 @@ exports.setProcessMeshClearPortControl = setProcessMeshClearPortControl;
|
|
|
88362
88605
|
exports.setProcessMeshEnforcer = setProcessMeshEnforcer;
|
|
88363
88606
|
exports.setProcessMeshTlsEnabler = setProcessMeshTlsEnabler;
|
|
88364
88607
|
exports.systemConfigMeshActions = systemConfigMeshActions;
|
|
88608
|
+
exports.verifyAgentUiUnlockTicket = verifyAgentUiUnlockTicket;
|
|
88365
88609
|
exports.verifyMeshJoinToken = verifyMeshJoinToken;
|