@camstack/server 1.2.577 → 1.2.579
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/agent/agent-config.js +2 -11
- package/dist/agent/agent-http-auth.js +91 -78
- package/dist/agent/agent-http.js +89 -139
- package/dist/agent/agent-hub-registration.js +31 -58
- package/dist/agent/agent-json-file.js +59 -0
- package/dist/agent/agent-mesh-gate.js +57 -0
- package/dist/agent/agent-node-certificates.js +7 -7
- package/dist/agent/agent-pairing-routes.js +1 -4
- package/dist/agent/agent-pairing-store.js +8 -8
- package/dist/agent/agent-service.js +0 -9
- package/dist/agent/main.js +41 -99
- package/dist/api/core/certificates.router.js +2 -2
- package/dist/api/oauth2/consent-page.js +19 -31
- package/dist/api/trpc/trpc.context.js +2 -3
- package/dist/auth/app-confirm-page.js +29 -21
- package/dist/auth/app-page-shell.js +120 -0
- package/dist/auth/app-passkey-page.js +24 -19
- package/dist/auth/app-refused-route.js +37 -0
- package/dist/boot/boot-config.js +7 -5
- package/dist/core/agent/agent-registry.service.js +0 -10
- package/dist/core/moleculer/hub-mesh-guard.js +30 -101
- package/dist/core/moleculer/moleculer.service.js +8 -32
- package/dist/main.js +2 -2
- package/package.json +9 -9
- package/dist/agent/agent-mesh-enroll.js +0 -317
- package/dist/agent/agent-mesh-join.js +0 -140
- package/dist/api/mesh/enroll.routes.js +0 -34
package/dist/agent/agent-http.js
CHANGED
|
@@ -57,24 +57,27 @@ const node_crypto_1 = require("node:crypto");
|
|
|
57
57
|
const fs = __importStar(require("node:fs"));
|
|
58
58
|
const path = __importStar(require("node:path"));
|
|
59
59
|
const fastify_1 = __importDefault(require("fastify"));
|
|
60
|
+
const zod_1 = require("zod");
|
|
60
61
|
const system_1 = require("@camstack/system");
|
|
61
62
|
const agent_sniffing_listener_js_1 = require("./agent-sniffing-listener.js");
|
|
62
63
|
const agent_http_auth_js_1 = require("./agent-http-auth.js");
|
|
64
|
+
const agent_pairing_routes_js_1 = require("./agent-pairing-routes.js");
|
|
65
|
+
const agent_pairing_store_js_1 = require("./agent-pairing-store.js");
|
|
63
66
|
const node_1 = require("@camstack/types/node");
|
|
64
|
-
const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
|
|
65
67
|
const agent_update_routes_js_1 = require("./agent-update-routes.js");
|
|
66
68
|
const derive_hub_url_js_1 = require("./derive-hub-url.js");
|
|
67
69
|
Object.defineProperty(exports, "pickIpv4", { enumerable: true, get: function () { return derive_hub_url_js_1.pickIpv4; } });
|
|
68
70
|
const agent_addon_runtime_js_1 = require("./agent-addon-runtime.js");
|
|
69
71
|
const hubs_found_js_1 = require("./hubs-found.js");
|
|
70
|
-
const agent_pairing_routes_js_1 = require("./agent-pairing-routes.js");
|
|
71
|
-
const agent_pairing_store_js_1 = require("./agent-pairing-store.js");
|
|
72
72
|
/** The pinned CA's fingerprint, or `null` when none is pinned or the PEM cannot be read. */
|
|
73
73
|
function describePairedHub(pinnedCa, hubHttpsOrigin) {
|
|
74
74
|
if (pinnedCa === null)
|
|
75
75
|
return null;
|
|
76
76
|
try {
|
|
77
|
-
return {
|
|
77
|
+
return {
|
|
78
|
+
url: hubHttpsOrigin,
|
|
79
|
+
caFingerprint: (0, system_1.caFingerprintCompact)(pinnedCa),
|
|
80
|
+
};
|
|
78
81
|
}
|
|
79
82
|
catch {
|
|
80
83
|
return null;
|
|
@@ -174,7 +177,7 @@ function readConfigFile(configPath) {
|
|
|
174
177
|
return {};
|
|
175
178
|
}
|
|
176
179
|
}
|
|
177
|
-
/** agent.json holds the
|
|
180
|
+
/** agent.json holds the agent's private mesh key (D857): owner-only on every OS. */
|
|
178
181
|
function writeConfigFile(configPath, data) {
|
|
179
182
|
(0, node_1.writeOwnerOnlyFileSync)(configPath, JSON.stringify(data, null, 2));
|
|
180
183
|
}
|
|
@@ -235,13 +238,15 @@ function mapDiscoveredNodes(nodes, selfId) {
|
|
|
235
238
|
}
|
|
236
239
|
/**
|
|
237
240
|
* Derive the hub connection state from broker registry + config when no
|
|
238
|
-
* external getter is wired. Does NOT detect `
|
|
241
|
+
* external getter is wired. Does NOT detect `certificate-rejected` — that
|
|
239
242
|
* requires the bootstrap to inject `getHubConnectionState`.
|
|
240
243
|
*/
|
|
241
|
-
function deriveHubConnectionState(nodes, hubAddressConfigured) {
|
|
244
|
+
function deriveHubConnectionState(nodes, hubAddressConfigured, paired) {
|
|
242
245
|
const hubInRegistry = nodes.some((n) => n.id === 'hub');
|
|
243
246
|
if (hubInRegistry)
|
|
244
247
|
return 'connected';
|
|
248
|
+
if (!paired)
|
|
249
|
+
return 'unpaired';
|
|
245
250
|
if (!hubAddressConfigured)
|
|
246
251
|
return 'unconfigured';
|
|
247
252
|
return 'disconnected';
|
|
@@ -255,54 +260,51 @@ function getEffectiveConfig(configPath, nodeId) {
|
|
|
255
260
|
return {
|
|
256
261
|
name: typeof raw.name === 'string' ? raw.name : nodeId,
|
|
257
262
|
hubAddress: typeof raw.hubAddress === 'string' ? raw.hubAddress : null,
|
|
258
|
-
|
|
263
|
+
paired: (0, agent_pairing_store_js_1.isAgentPaired)(configPath),
|
|
259
264
|
hasMeshTls: raw.meshTls !== null && typeof raw.meshTls === 'object',
|
|
260
265
|
};
|
|
261
266
|
}
|
|
262
|
-
/**
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
267
|
+
/**
|
|
268
|
+
* The only keys `POST /api/agent/config` writes and `GET /api/agent/config` echoes. Everything
|
|
269
|
+
* else in `agent.json` (the mesh key and chain, a swap in progress, the node id, an old `secret`)
|
|
270
|
+
* is not reachable through this API, in either direction (D857).
|
|
271
|
+
*/
|
|
272
|
+
const EDITABLE_CONFIG_KEYS = ['name', 'hubAddress', 'logLevel'];
|
|
273
|
+
/** Value types of the editable keys; unknown keys pass through to the allow-list refusal above. */
|
|
274
|
+
const ConfigPatchSchema = zod_1.z.looseObject({
|
|
275
|
+
name: zod_1.z.string().max(100).nullable().optional(),
|
|
276
|
+
hubAddress: zod_1.z.string().max(300).nullable().optional(),
|
|
277
|
+
logLevel: zod_1.z.enum(['debug', 'info', 'warn', 'error']).optional(),
|
|
278
|
+
});
|
|
269
279
|
/** Clock skew allowed between the hub that issued a ticket and this agent's boot instant. */
|
|
270
280
|
const TICKET_BOOT_SKEW_MS = 2 * 60_000;
|
|
271
|
-
const defaultRedeemJoinToken = (token, nodeId) => (0, agent_mesh_enroll_js_1.enrollAgentMesh)({ token, nodeId });
|
|
272
281
|
// ---------------------------------------------------------------------------
|
|
273
282
|
// Factory
|
|
274
283
|
// ---------------------------------------------------------------------------
|
|
275
284
|
async function createAgentHttpServer(getBroker, config) {
|
|
276
285
|
const app = (0, fastify_1.default)({ logger: false });
|
|
277
|
-
|
|
278
|
-
|
|
286
|
+
// No CORS: the agent's page is served by the agent itself, so every legitimate caller is
|
|
287
|
+
// same-origin. `Access-Control-Allow-Origin: *` would let any web page read this API (D857).
|
|
279
288
|
// -- Auth gate (port-4444 hardening, mirrors the hub's /health work) --
|
|
280
|
-
// Loopback (Electron renderer, in-container probes) always passes
|
|
281
|
-
//
|
|
282
|
-
// (D821).
|
|
283
|
-
// `POST /api/agent/session` redeems an unlock ticket without a prior
|
|
284
|
-
|
|
285
|
-
const raw = readConfigFile(config.configPath);
|
|
286
|
-
return typeof raw.secret === 'string' && raw.secret.length > 0 ? raw.secret : null;
|
|
287
|
-
};
|
|
289
|
+
// Loopback (the agent's own page, the Electron renderer, in-container probes) always passes - a
|
|
290
|
+
// loopback peer behind a proxy does not (forwarding header). A remote caller needs a session minted
|
|
291
|
+
// from a hub unlock ticket (D821, D857). The pairing protocol routes are reachable by anyone, each
|
|
292
|
+
// authorized by what it carries. `POST /api/agent/session` redeems an unlock ticket without a prior
|
|
293
|
+
// session.
|
|
288
294
|
/**
|
|
289
|
-
* Tab sessions from hub unlock tickets. A session is bound to
|
|
290
|
-
*
|
|
291
|
-
*
|
|
295
|
+
* Tab sessions from hub unlock tickets. A session is bound to the mesh CA that vouched for it (by
|
|
296
|
+
* fingerprint) and dies the moment that changes: forgetting the hub or pairing with another one
|
|
297
|
+
* ends every session (D857).
|
|
292
298
|
*/
|
|
293
299
|
const unlockSessions = new Map();
|
|
294
300
|
let unlockJtis = new system_1.UnlockTicketJtiStore();
|
|
295
301
|
let jtiBinding = null;
|
|
296
302
|
const bootMs = Date.now();
|
|
297
|
-
/** What
|
|
303
|
+
/** What vouches for a session now: the pinned CA, else nothing. */
|
|
298
304
|
const caBinding = () => {
|
|
299
305
|
const ca = (0, agent_pairing_store_js_1.pinnedMeshCaCert)(config.configPath);
|
|
300
306
|
return ca === null ? null : `ca:${(0, system_1.caFingerprintCompact)(ca)}`;
|
|
301
307
|
};
|
|
302
|
-
const secretBinding = () => {
|
|
303
|
-
const secret = currentSecret();
|
|
304
|
-
return secret === null ? null : `secret:${(0, system_1.hashClusterSecret)(secret)}`;
|
|
305
|
-
};
|
|
306
308
|
const sessionOk = (token) => {
|
|
307
309
|
const entry = unlockSessions.get(token);
|
|
308
310
|
if (entry === undefined)
|
|
@@ -311,37 +313,33 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
311
313
|
unlockSessions.delete(token);
|
|
312
314
|
return false;
|
|
313
315
|
}
|
|
314
|
-
|
|
315
|
-
if (current !== entry.binding) {
|
|
316
|
+
if (caBinding() !== entry.binding) {
|
|
316
317
|
unlockSessions.delete(token);
|
|
317
318
|
return false;
|
|
318
319
|
}
|
|
319
320
|
return true;
|
|
320
321
|
};
|
|
322
|
+
// The gate decides on the route the ROUTER matched (`routeOptions.url`, the pattern), never on the
|
|
323
|
+
// raw URL: Fastify percent-decodes the path before matching, so `/%61pi/agent/restart` reaches the
|
|
324
|
+
// `/api/agent/restart` handler. Deny by default: only an explicit public route, a static UI asset
|
|
325
|
+
// (marked below) or a 404 (served by the not-found handler, which exposes nothing) skips the check.
|
|
321
326
|
app.addHook('onRequest', async (req, reply) => {
|
|
322
|
-
|
|
323
|
-
const isProtected = pathOnly.startsWith('/api/') || pathOnly.startsWith('/health/');
|
|
324
|
-
if (!isProtected)
|
|
327
|
+
if (req.is404)
|
|
325
328
|
return;
|
|
326
|
-
if (
|
|
329
|
+
if (req.routeOptions.config?.publicAsset === true)
|
|
327
330
|
return;
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (locked && (0, agent_http_auth_js_1.isUnlockSessionRequest)(req.method, pathOnly))
|
|
331
|
+
const method = req.method === 'HEAD' ? 'GET' : req.method;
|
|
332
|
+
const matched = req.routeOptions.url ?? '';
|
|
333
|
+
if (method === 'GET' && matched === '/health')
|
|
332
334
|
return;
|
|
333
|
-
if ((0, agent_http_auth_js_1.
|
|
335
|
+
if ((0, agent_http_auth_js_1.isPairingProtocolRequest)(method, matched))
|
|
334
336
|
return;
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
...(typeof req.headers.authorization === 'string'
|
|
339
|
-
? { authorization: req.headers.authorization }
|
|
340
|
-
: {}),
|
|
341
|
-
};
|
|
342
|
-
if ((0, agent_http_auth_js_1.isAuthorizedAgentRequest)(authInput, secret, sessionOk))
|
|
337
|
+
if ((0, agent_http_auth_js_1.isUnlockSessionRequest)(method, matched))
|
|
338
|
+
return;
|
|
339
|
+
if ((0, agent_http_auth_js_1.isUnlockInfoRequest)(method, matched))
|
|
343
340
|
return;
|
|
344
|
-
|
|
341
|
+
const authInput = (0, agent_http_auth_js_1.authInputOf)(req);
|
|
342
|
+
if ((0, agent_http_auth_js_1.isAuthorizedAgentRequest)(authInput, sessionOk))
|
|
345
343
|
return;
|
|
346
344
|
return reply.status(401).send({ ok: false, error: 'unauthorized' });
|
|
347
345
|
});
|
|
@@ -390,7 +388,7 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
390
388
|
const hubConnected = nodes.some((n) => n.id === 'hub');
|
|
391
389
|
const hubConnectionState = config.getHubConnectionState
|
|
392
390
|
? config.getHubConnectionState()
|
|
393
|
-
: deriveHubConnectionState(nodes, Boolean(eff.hubAddress));
|
|
391
|
+
: deriveHubConnectionState(nodes, Boolean(eff.hubAddress), eff.paired);
|
|
394
392
|
const meshTransport = config.getMeshTransport
|
|
395
393
|
? config.getMeshTransport()
|
|
396
394
|
: eff.hasMeshTls
|
|
@@ -421,8 +419,7 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
421
419
|
appVersion,
|
|
422
420
|
hubConnected,
|
|
423
421
|
hubConnectionState,
|
|
424
|
-
|
|
425
|
-
paired: (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath),
|
|
422
|
+
paired: eff.paired,
|
|
426
423
|
pairedHub,
|
|
427
424
|
meshTransport,
|
|
428
425
|
discoveredNodes,
|
|
@@ -438,8 +435,7 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
438
435
|
appVersion,
|
|
439
436
|
hubConnected,
|
|
440
437
|
hubConnectionState,
|
|
441
|
-
|
|
442
|
-
paired: (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath),
|
|
438
|
+
paired: eff.paired,
|
|
443
439
|
pairedHub,
|
|
444
440
|
meshTransport,
|
|
445
441
|
discoveredNodes,
|
|
@@ -450,7 +446,6 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
450
446
|
});
|
|
451
447
|
// -- Unlock info (public; Continue-with-hub needs hub origin while locked) --
|
|
452
448
|
app.get('/api/agent/unlock-info', async () => {
|
|
453
|
-
const secret = currentSecret();
|
|
454
449
|
const eff = getEffectiveConfig(config.configPath, config.nodeId);
|
|
455
450
|
const nodes = getRegistryNodes(getBroker());
|
|
456
451
|
const resolvedHubAddress = resolveHubEndpoint(nodes);
|
|
@@ -458,35 +453,28 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
458
453
|
(resolvedHubAddress !== null ? (0, derive_hub_url_js_1.deriveHubUrlFromHubAddress)(resolvedHubAddress) : undefined) ??
|
|
459
454
|
null;
|
|
460
455
|
return {
|
|
461
|
-
paired:
|
|
456
|
+
paired: eff.paired,
|
|
462
457
|
nodeId: config.nodeId,
|
|
463
458
|
hubHttpsOrigin,
|
|
464
459
|
};
|
|
465
460
|
});
|
|
466
461
|
// -- Unlock session (hub admin ticket → tab Bearer; D821) -----------
|
|
467
462
|
app.post('/api/agent/session', async (req, reply) => {
|
|
468
|
-
|
|
463
|
+
// The ticket is signed by the mesh CA this agent pinned at pairing (D857).
|
|
469
464
|
const pinnedCa = (0, agent_pairing_store_js_1.pinnedMeshCaCert)(config.configPath);
|
|
470
|
-
if (
|
|
465
|
+
if (pinnedCa === null) {
|
|
471
466
|
return reply.status(400).send({ ok: false, error: 'agent is not paired' });
|
|
472
467
|
}
|
|
473
468
|
const ticket = typeof req.body?.ticket === 'string' ? req.body.ticket.trim() : '';
|
|
474
469
|
if (ticket.length === 0) {
|
|
475
470
|
return reply.status(400).send({ ok: false, error: 'ticket required' });
|
|
476
471
|
}
|
|
477
|
-
// Two forms are accepted in the transition release (D857): signed by the mesh CA this agent
|
|
478
|
-
// pinned (ES256), or the older HMAC under the cluster secret.
|
|
479
472
|
// Nothing issued before this process started is accepted: the one-use ledger is in memory.
|
|
480
|
-
const
|
|
473
|
+
const payload = (0, system_1.verifyAgentUiUnlockTicket)(pinnedCa, ticket, {
|
|
481
474
|
expectedNodeId: config.nodeId,
|
|
482
475
|
notBeforeMs: bootMs - TICKET_BOOT_SKEW_MS,
|
|
483
|
-
};
|
|
484
|
-
const
|
|
485
|
-
const hmac = es256 === null && secret !== null
|
|
486
|
-
? (0, system_1.verifyAgentUiUnlockTicket)((0, system_1.hashClusterSecret)(secret), ticket, options)
|
|
487
|
-
: null;
|
|
488
|
-
const payload = es256 ?? hmac;
|
|
489
|
-
const binding = es256 !== null ? caBinding() : secretBinding();
|
|
476
|
+
});
|
|
477
|
+
const binding = caBinding();
|
|
490
478
|
if (payload === null || binding === null) {
|
|
491
479
|
return reply.status(401).send({ ok: false, error: 'invalid or expired unlock ticket' });
|
|
492
480
|
}
|
|
@@ -544,12 +532,9 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
544
532
|
nodeId: config.nodeId,
|
|
545
533
|
name: eff.name,
|
|
546
534
|
hubAddress: eff.hubAddress,
|
|
547
|
-
|
|
535
|
+
paired: eff.paired,
|
|
548
536
|
hasMeshTls: eff.hasMeshTls,
|
|
549
|
-
|
|
550
|
-
dataDir: config.dataDir,
|
|
551
|
-
// Include all persisted fields except credentials (secret, mesh key, pending token)
|
|
552
|
-
...Object.fromEntries(Object.entries(persisted).filter(([k]) => !NEVER_ECHOED_CONFIG_KEYS.has(k))),
|
|
537
|
+
...Object.fromEntries(Object.entries(persisted).filter(([k]) => EDITABLE_CONFIG_KEYS.includes(k))),
|
|
553
538
|
};
|
|
554
539
|
});
|
|
555
540
|
// -- Config write (merge-patch + persist) ---------------------------
|
|
@@ -557,56 +542,23 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
557
542
|
// the returned value / catches rejections, so an async handler is correct here.
|
|
558
543
|
// eslint-disable-next-line oxc/no-async-endpoint-handlers
|
|
559
544
|
app.post('/api/agent/config', async (req, reply) => {
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
const patchSecret = typeof patch['secret'] === 'string' && patch['secret'].trim().length > 0;
|
|
570
|
-
const hasJoinToken = typeof joinToken === 'string' && joinToken.trim().length > 0;
|
|
571
|
-
// First pairing (D803 / D821): hub always has a secret — require secret or
|
|
572
|
-
// join token. Later edits may omit secret to keep the stored value.
|
|
573
|
-
// A rename alone is not a pairing: it is allowed before the agent is paired (D857).
|
|
574
|
-
const renameOnly = Object.keys(patch).every((k) => k === 'name');
|
|
575
|
-
if (!alreadyPaired && !patchSecret && !hasJoinToken && !renameOnly) {
|
|
545
|
+
// Allow-list (D857): the mesh material only ever comes from a pairing, and the node id never
|
|
546
|
+
// changes. Any other key is refused whole - nothing is written.
|
|
547
|
+
const parsed = ConfigPatchSchema.safeParse(req.body ?? {});
|
|
548
|
+
if (!parsed.success) {
|
|
549
|
+
return reply.status(400).send({ success: false, error: 'Invalid config values' });
|
|
550
|
+
}
|
|
551
|
+
const body = parsed.data;
|
|
552
|
+
const refused = Object.keys(body).filter((k) => !EDITABLE_CONFIG_KEYS.includes(k));
|
|
553
|
+
if (refused.length > 0) {
|
|
576
554
|
return reply.status(400).send({
|
|
577
555
|
success: false,
|
|
578
|
-
error:
|
|
556
|
+
error: `Not editable here: ${refused.join(', ')}. Editable: ${EDITABLE_CONFIG_KEYS.join(', ')}`,
|
|
579
557
|
});
|
|
580
558
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
let enrolled = null;
|
|
585
|
-
if (hasJoinToken) {
|
|
586
|
-
try {
|
|
587
|
-
enrolled = await (config.redeemJoinToken ?? defaultRedeemJoinToken)(joinToken, config.nodeId);
|
|
588
|
-
}
|
|
589
|
-
catch (err) {
|
|
590
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
591
|
-
console.error(`[Agent] join-token enrollment failed: ${message}`);
|
|
592
|
-
return reply
|
|
593
|
-
.status(err instanceof agent_mesh_enroll_js_1.MeshEnrollError ? 400 : 502)
|
|
594
|
-
.send({ success: false, error: message });
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
const merged = { ...existing, ...patch };
|
|
598
|
-
if (enrolled !== null) {
|
|
599
|
-
merged['hubAddress'] = enrolled.hubAddress;
|
|
600
|
-
merged['meshTls'] = enrolled.meshTls;
|
|
601
|
-
}
|
|
602
|
-
else if (clearMeshTls) {
|
|
603
|
-
delete merged['meshTls'];
|
|
604
|
-
}
|
|
605
|
-
writeConfigFile(config.configPath, merged);
|
|
606
|
-
if (enrolled !== null) {
|
|
607
|
-
console.log(`[Agent] Enrolled in the mesh CA via join token — hub ${enrolled.hubAddress}, ` +
|
|
608
|
-
'mutual TLS on next reconnect');
|
|
609
|
-
}
|
|
559
|
+
const patch = Object.fromEntries(Object.entries(body));
|
|
560
|
+
const existing = readConfigFile(config.configPath);
|
|
561
|
+
writeConfigFile(config.configPath, { ...existing, ...patch });
|
|
610
562
|
// Apply name change immediately (no reconnect needed)
|
|
611
563
|
if (typeof patch.name === 'string' && patch.name.trim()) {
|
|
612
564
|
try {
|
|
@@ -617,22 +569,13 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
617
569
|
/* best-effort */
|
|
618
570
|
}
|
|
619
571
|
}
|
|
620
|
-
// Only flag reconnect when a connection-affecting field actually
|
|
621
|
-
//
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
const needsReconnect = enrolled !== null ||
|
|
626
|
-
clearMeshTls ||
|
|
627
|
-
reconnectRelevant.some((k) => Object.prototype.hasOwnProperty.call(patch, k) && patch[k] !== existing[k]);
|
|
628
|
-
return {
|
|
629
|
-
success: true,
|
|
630
|
-
restartRequired: needsReconnect,
|
|
631
|
-
...(enrolled !== null ? { enrolled: true } : {}),
|
|
632
|
-
...(clearMeshTls ? { clearedMeshTls: true } : {}),
|
|
633
|
-
};
|
|
572
|
+
// Only flag reconnect when a connection-affecting field actually CHANGED. The form posts
|
|
573
|
+
// every visible field on every Save, so a present `hubAddress` alone is not a change.
|
|
574
|
+
const needsReconnect = Object.prototype.hasOwnProperty.call(patch, 'hubAddress') &&
|
|
575
|
+
patch['hubAddress'] !== existing['hubAddress'];
|
|
576
|
+
return { success: true, restartRequired: needsReconnect };
|
|
634
577
|
});
|
|
635
|
-
// -- Agent reconnect (applies new hub/
|
|
578
|
+
// -- Agent reconnect (applies a new hub address / mesh material) -----
|
|
636
579
|
app.post('/api/agent/restart', async () => {
|
|
637
580
|
if (!config.onReconnect) {
|
|
638
581
|
return { success: false, message: 'Reconnect not available' };
|
|
@@ -668,6 +611,12 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
668
611
|
const uiDir = resolveUiDistDir(config.dataDir, process.env['CAMSTACK_BUNDLED_ADDONS_DIR']);
|
|
669
612
|
if (uiDir) {
|
|
670
613
|
const fastifyStatic = await Promise.resolve().then(() => __importStar(require('@fastify/static')));
|
|
614
|
+
// Every route the static plugin mounts is the public UI bundle: tag them for the auth gate.
|
|
615
|
+
let mountingAssets = true;
|
|
616
|
+
app.addHook('onRoute', (route) => {
|
|
617
|
+
if (mountingAssets)
|
|
618
|
+
route.config = { ...route.config, publicAsset: true };
|
|
619
|
+
});
|
|
671
620
|
await app.register(fastifyStatic.default, {
|
|
672
621
|
root: uiDir,
|
|
673
622
|
prefix: '/',
|
|
@@ -677,6 +626,7 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
677
626
|
// fallback request 500'd with "reply.sendFile is not a function".
|
|
678
627
|
decorateReply: true,
|
|
679
628
|
});
|
|
629
|
+
mountingAssets = false;
|
|
680
630
|
app.setNotFoundHandler(async (req, reply) => {
|
|
681
631
|
if (req.url.startsWith('/api/') || req.url.startsWith('/health')) {
|
|
682
632
|
return reply.status(404).send({ error: 'Not found' });
|
|
@@ -2,59 +2,37 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createAgentHubRegistration = createAgentHubRegistration;
|
|
4
4
|
/**
|
|
5
|
-
* The agent's upward capability registration (D3)
|
|
5
|
+
* The agent's upward capability registration (D3).
|
|
6
6
|
*
|
|
7
|
-
* The hub forgets an agent
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* agents still gossiped about them; each flap wiped the agent's capabilities
|
|
14
|
-
* on the hub, the agent re-joined (its next call came back "not
|
|
15
|
-
* authenticated") but never re-registered, and both stayed online with ZERO
|
|
16
|
-
* capabilities until a manual restart.
|
|
7
|
+
* The hub forgets an agent's registration on every `$node.disconnected` the HUB
|
|
8
|
+
* sees. That event is not symmetric: Moleculer marks a node offline on gossip
|
|
9
|
+
* from a third node, on a failed write, on a heartbeat timeout, and the agent,
|
|
10
|
+
* whose own view of the hub never broke, sees none of it. On 2026-10-14 the hub
|
|
11
|
+
* flapped two agents offline/online every 2–6 s for ~35 min; each flap wiped the
|
|
12
|
+
* agent's capabilities on the hub and nothing told the agent to send them again.
|
|
17
13
|
*
|
|
18
|
-
* So the registration follows the
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* was reset (a new connection, or the hub said it lost us);
|
|
22
|
-
* - a registration the hub refuses as "cluster secret mismatch" while THIS
|
|
23
|
-
* connection's join proved the secret is not a mismatch: the hub forgot the
|
|
24
|
-
* join. The agent joins again, and that join re-registers;
|
|
14
|
+
* So the registration follows the HUB'S CONNECTION and the hub's own ask, not
|
|
15
|
+
* the agent's view of itself:
|
|
16
|
+
* - every time the hub node connects, the complete manifest is sent again;
|
|
25
17
|
* - the hub may ask for it (`$agent.requestRegistration`, sent on the hub's
|
|
26
|
-
* own `$node.connected`)
|
|
27
|
-
*
|
|
18
|
+
* own `$node.connected`);
|
|
19
|
+
* - a child runner registering, an addon reload and boot send it too.
|
|
20
|
+
*
|
|
21
|
+
* Who the agent is comes from its certificate (D857): there is no join to wait
|
|
22
|
+
* for and no secret to prove. If the hub refuses the registration because it
|
|
23
|
+
* does not accept this agent's certificate (revoked, or issued by another CA),
|
|
24
|
+
* `onCertificateRefused` says so and the retry loop keeps trying — the operator
|
|
25
|
+
* re-pairs, and the next attempt succeeds.
|
|
28
26
|
*
|
|
29
27
|
* Each send is ONE atomic `$hub.registerNode` carrying the full set; sends are
|
|
30
28
|
* coalesced (one in flight, at most one queued, the manifest built when it
|
|
31
29
|
* goes). Nothing waits on a timer: the triggers are events, the retry is the ack.
|
|
32
30
|
*/
|
|
33
31
|
const system_1 = require("@camstack/system");
|
|
34
|
-
/** States in which a call to the hub is refused locally — nothing to send. */
|
|
35
|
-
const BLOCKING = new Set(['secret-mismatch', 'hub-unverified']);
|
|
36
32
|
function createAgentHubRegistration(opts) {
|
|
37
|
-
let waiting = false;
|
|
38
33
|
let sending = false;
|
|
39
34
|
let queuedReason = null;
|
|
40
|
-
const onRefused = (err) => {
|
|
41
|
-
if (!(0, system_1.isClusterSecretMismatchError)(err))
|
|
42
|
-
return; // aborted: shutting down
|
|
43
|
-
if (opts.join.client.state === 'authenticated') {
|
|
44
|
-
// This connection's join PROVED the secret, so the hub did not refuse
|
|
45
|
-
// the secret — it forgot the join (it saw this node disconnect). Join
|
|
46
|
-
// again; the settled join re-registers.
|
|
47
|
-
opts.log('[register-node] the hub refused the registration although this connection proved the cluster secret — it has forgotten the join; joining again');
|
|
48
|
-
opts.join.rejoin();
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
opts.onSecretRefused?.(err);
|
|
52
|
-
};
|
|
53
35
|
const send = (reason) => {
|
|
54
|
-
if (sending) {
|
|
55
|
-
queuedReason = reason;
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
36
|
sending = true;
|
|
59
37
|
const manifest = opts.buildManifest();
|
|
60
38
|
const bindings = manifest.addons.reduce((n, a) => n + a.capabilities.length, 0);
|
|
@@ -67,12 +45,18 @@ function createAgentHubRegistration(opts) {
|
|
|
67
45
|
target: 'hub',
|
|
68
46
|
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
69
47
|
log: opts.log,
|
|
48
|
+
onAttemptFailed: (err) => {
|
|
49
|
+
if ((0, system_1.meshErrorType)(err) === system_1.MESH_NODE_UNAUTHENTICATED_TYPE)
|
|
50
|
+
opts.onCertificateRefused?.(err);
|
|
51
|
+
},
|
|
70
52
|
})
|
|
71
53
|
.then(() => {
|
|
72
54
|
opts.log(`[register-node] the hub acked this node's capabilities: ${manifest.addons.length} addon(s), ${bindings} binding(s) (${reason})`);
|
|
73
55
|
opts.onAcked?.();
|
|
74
56
|
})
|
|
75
|
-
.catch(
|
|
57
|
+
.catch(() => {
|
|
58
|
+
// Aborted: shutting down.
|
|
59
|
+
})
|
|
76
60
|
.finally(() => {
|
|
77
61
|
sending = false;
|
|
78
62
|
const next = queuedReason;
|
|
@@ -82,27 +66,16 @@ function createAgentHubRegistration(opts) {
|
|
|
82
66
|
});
|
|
83
67
|
};
|
|
84
68
|
const trigger = (reason) => {
|
|
85
|
-
|
|
86
|
-
|
|
69
|
+
if (sending) {
|
|
70
|
+
queuedReason = reason;
|
|
87
71
|
return;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
waiting = false;
|
|
91
|
-
send(reason);
|
|
92
|
-
}, () => {
|
|
93
|
-
// Blocked (mismatch / unverified hub) or the join is still pending:
|
|
94
|
-
// the next settled join sends it (`onJoinSettled`).
|
|
95
|
-
waiting = false;
|
|
96
|
-
});
|
|
72
|
+
}
|
|
73
|
+
send(reason);
|
|
97
74
|
};
|
|
98
75
|
return {
|
|
99
76
|
trigger,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
return;
|
|
103
|
-
// Every settled join means the hub's view of this node was reset (a new
|
|
104
|
-
// connection, or the hub said it lost us): the full set goes again.
|
|
105
|
-
trigger(`join settled: ${state}`);
|
|
77
|
+
onHubConnected() {
|
|
78
|
+
trigger('hub connected');
|
|
106
79
|
},
|
|
107
80
|
};
|
|
108
81
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.readJsonFile = readJsonFile;
|
|
37
|
+
exports.writeJsonFile0600 = writeJsonFile0600;
|
|
38
|
+
/**
|
|
39
|
+
* `agent.json` read/write helpers. The file is owner-only on every OS (an owner+SYSTEM ACL on
|
|
40
|
+
* Windows, where 0600 is a no-op) because it holds the mesh private key.
|
|
41
|
+
*/
|
|
42
|
+
const fs = __importStar(require("node:fs"));
|
|
43
|
+
const node_1 = require("@camstack/types/node");
|
|
44
|
+
function readJsonFile(file) {
|
|
45
|
+
if (!fs.existsSync(file))
|
|
46
|
+
return {};
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
49
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
50
|
+
? Object.fromEntries(Object.entries(parsed))
|
|
51
|
+
: {};
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function writeJsonFile0600(file, data) {
|
|
58
|
+
(0, node_1.writeOwnerOnlyFileSync)(file, JSON.stringify(data, null, 2));
|
|
59
|
+
}
|