@camstack/server 1.2.491 → 1.2.493
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.
|
@@ -9,16 +9,20 @@
|
|
|
9
9
|
* - loopback callers (the Electron renderer, in-container probes) are
|
|
10
10
|
* always allowed — the desktop app keeps working with zero changes,
|
|
11
11
|
* - remote callers must present `Authorization: Bearer <cluster secret>`
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* OR a short-lived unlock session token minted after a hub admin
|
|
13
|
+
* unlock ticket (D821) — the cluster secret stays a mesh / pairing
|
|
14
|
+
* credential, not a daily paste into the browser,
|
|
14
15
|
* - an UNPAIRED agent (no secret configured yet) can be set up remotely
|
|
15
16
|
* through the pairing surface only; everything else stays denied.
|
|
16
17
|
*/
|
|
17
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
19
|
exports.isLoopbackAddress = isLoopbackAddress;
|
|
19
20
|
exports.bearerMatchesSecret = bearerMatchesSecret;
|
|
21
|
+
exports.readBearerToken = readBearerToken;
|
|
20
22
|
exports.isAuthorizedAgentRequest = isAuthorizedAgentRequest;
|
|
21
23
|
exports.isPairingRequest = isPairingRequest;
|
|
24
|
+
exports.isUnlockSessionRequest = isUnlockSessionRequest;
|
|
25
|
+
exports.isUnlockInfoRequest = isUnlockInfoRequest;
|
|
22
26
|
const node_crypto_1 = require("node:crypto");
|
|
23
27
|
/** IPv4/IPv6 loopback (incl. the IPv4-mapped IPv6 form Node reports). */
|
|
24
28
|
function isLoopbackAddress(addr) {
|
|
@@ -43,18 +47,29 @@ function bearerMatchesSecret(authorization, secret) {
|
|
|
43
47
|
const expected = Buffer.from(secret);
|
|
44
48
|
return token.length === expected.length && (0, node_crypto_1.timingSafeEqual)(token, expected);
|
|
45
49
|
}
|
|
50
|
+
/** Extract the bearer token value, or `null`. */
|
|
51
|
+
function readBearerToken(authorization) {
|
|
52
|
+
if (!authorization?.startsWith('Bearer '))
|
|
53
|
+
return null;
|
|
54
|
+
const token = authorization.slice('Bearer '.length);
|
|
55
|
+
return token.length > 0 ? token : null;
|
|
56
|
+
}
|
|
46
57
|
/**
|
|
47
58
|
* True when the request may access the protected agent API. `clusterSecret`
|
|
48
59
|
* is the CURRENTLY configured secret (`null`/empty = unpaired agent — no
|
|
49
60
|
* remote credential can be verified, so remote access is denied except for
|
|
50
61
|
* the pairing surface, which the route layer allows via {@link isPairingRequest}).
|
|
62
|
+
* `sessionTokenOk` admits a hub-minted unlock session (D821).
|
|
51
63
|
*/
|
|
52
|
-
function isAuthorizedAgentRequest(input, clusterSecret) {
|
|
64
|
+
function isAuthorizedAgentRequest(input, clusterSecret, sessionTokenOk = () => false) {
|
|
53
65
|
if (isLoopbackAddress(input.remoteAddress))
|
|
54
66
|
return true;
|
|
55
67
|
if (clusterSecret === null || clusterSecret.length === 0)
|
|
56
68
|
return false;
|
|
57
|
-
|
|
69
|
+
if (bearerMatchesSecret(input.authorization, clusterSecret))
|
|
70
|
+
return true;
|
|
71
|
+
const token = readBearerToken(input.authorization);
|
|
72
|
+
return token !== null && sessionTokenOk(token);
|
|
58
73
|
}
|
|
59
74
|
/**
|
|
60
75
|
* The first-boot pairing surface: what a remote browser needs to configure
|
|
@@ -74,3 +89,19 @@ function isPairingRequest(method, urlPath) {
|
|
|
74
89
|
}
|
|
75
90
|
return false;
|
|
76
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Redeem a hub unlock ticket for a session token — reachable remotely without
|
|
94
|
+
* a prior Bearer (the ticket IS the proof). Paired agents only.
|
|
95
|
+
*/
|
|
96
|
+
function isUnlockSessionRequest(method, urlPath) {
|
|
97
|
+
const pathOnly = urlPath.split('?')[0] ?? urlPath;
|
|
98
|
+
return method === 'POST' && pathOnly === '/api/agent/session';
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Public unlock probe: `nodeId` + hub HTTPS origin for Continue-with-hub (D821).
|
|
102
|
+
* No secrets, no metrics — only what the locked unlock screen needs.
|
|
103
|
+
*/
|
|
104
|
+
function isUnlockInfoRequest(method, urlPath) {
|
|
105
|
+
const pathOnly = urlPath.split('?')[0] ?? urlPath;
|
|
106
|
+
return method === 'GET' && pathOnly === '/api/agent/unlock-info';
|
|
107
|
+
}
|
package/dist/agent/agent-http.js
CHANGED
|
@@ -50,9 +50,11 @@ exports.resolveHubEndpoint = resolveHubEndpoint;
|
|
|
50
50
|
exports.mapDiscoveredNodes = mapDiscoveredNodes;
|
|
51
51
|
exports.createAgentHttpServer = createAgentHttpServer;
|
|
52
52
|
exports.startAgentHttpServer = startAgentHttpServer;
|
|
53
|
+
const node_crypto_1 = require("node:crypto");
|
|
53
54
|
const fs = __importStar(require("node:fs"));
|
|
54
55
|
const path = __importStar(require("node:path"));
|
|
55
56
|
const fastify_1 = __importDefault(require("fastify"));
|
|
57
|
+
const system_1 = require("@camstack/system");
|
|
56
58
|
const agent_http_auth_js_1 = require("./agent-http-auth.js");
|
|
57
59
|
const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
|
|
58
60
|
const agent_update_routes_js_1 = require("./agent-update-routes.js");
|
|
@@ -227,27 +229,43 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
227
229
|
await app.register(cors.default);
|
|
228
230
|
// -- Auth gate (port-4444 hardening, mirrors the hub's /health work) --
|
|
229
231
|
// Loopback (Electron renderer, in-container probes) always passes;
|
|
230
|
-
// remote callers need
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// + /health/details prefixes so any FUTURE route is gated fail-closed.
|
|
232
|
+
// remote callers need Bearer cluster secret OR a hub unlock session
|
|
233
|
+
// (D821). While UNPAIRED only the pairing surface is reachable remotely.
|
|
234
|
+
// `POST /api/agent/session` redeems an unlock ticket without a prior Bearer.
|
|
234
235
|
const currentSecret = () => {
|
|
235
236
|
const raw = readConfigFile(config.configPath);
|
|
236
237
|
return typeof raw.secret === 'string' && raw.secret.length > 0 ? raw.secret : null;
|
|
237
238
|
};
|
|
239
|
+
/** Tab sessions from hub unlock tickets — token → expiry ms. */
|
|
240
|
+
const unlockSessions = new Map();
|
|
241
|
+
const unlockJtis = new system_1.UnlockTicketJtiStore();
|
|
242
|
+
const sessionOk = (token) => {
|
|
243
|
+
const exp = unlockSessions.get(token);
|
|
244
|
+
if (exp === undefined)
|
|
245
|
+
return false;
|
|
246
|
+
if (exp <= Date.now()) {
|
|
247
|
+
unlockSessions.delete(token);
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
return true;
|
|
251
|
+
};
|
|
238
252
|
app.addHook('onRequest', async (req, reply) => {
|
|
239
253
|
const pathOnly = req.url.split('?')[0] ?? req.url;
|
|
240
254
|
const isProtected = pathOnly.startsWith('/api/') || pathOnly.startsWith('/health/');
|
|
241
255
|
if (!isProtected)
|
|
242
256
|
return;
|
|
243
257
|
const secret = currentSecret();
|
|
258
|
+
if (secret !== null && (0, agent_http_auth_js_1.isUnlockSessionRequest)(req.method, pathOnly))
|
|
259
|
+
return;
|
|
260
|
+
if ((0, agent_http_auth_js_1.isUnlockInfoRequest)(req.method, pathOnly))
|
|
261
|
+
return;
|
|
244
262
|
const authInput = {
|
|
245
263
|
remoteAddress: req.socket.remoteAddress ?? undefined,
|
|
246
264
|
...(typeof req.headers.authorization === 'string'
|
|
247
265
|
? { authorization: req.headers.authorization }
|
|
248
266
|
: {}),
|
|
249
267
|
};
|
|
250
|
-
if ((0, agent_http_auth_js_1.isAuthorizedAgentRequest)(authInput, secret))
|
|
268
|
+
if ((0, agent_http_auth_js_1.isAuthorizedAgentRequest)(authInput, secret, sessionOk))
|
|
251
269
|
return;
|
|
252
270
|
if (secret === null && (0, agent_http_auth_js_1.isPairingRequest)(req.method, pathOnly))
|
|
253
271
|
return;
|
|
@@ -302,6 +320,10 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
302
320
|
// registry) — lets the UI show the real endpoint even under UDP discovery
|
|
303
321
|
// where `eff.hubAddress` is null.
|
|
304
322
|
const resolvedHubAddress = resolveHubEndpoint(nodes);
|
|
323
|
+
// HTTPS origin the agent UI uses for "Continue with hub" unlock (D821).
|
|
324
|
+
const hubHttpsOrigin = (eff.hubAddress !== null ? (0, derive_hub_url_js_1.deriveHubUrlFromHubAddress)(eff.hubAddress) : undefined) ??
|
|
325
|
+
(resolvedHubAddress !== null ? (0, derive_hub_url_js_1.deriveHubUrlFromHubAddress)(resolvedHubAddress) : undefined) ??
|
|
326
|
+
null;
|
|
305
327
|
// Version of the Electron wrapper (injected by the desktop app); absent
|
|
306
328
|
// when the agent runs headless / in Docker.
|
|
307
329
|
const appVersion = process.env['CAMSTACK_AGENT_APP_VERSION'] ?? null;
|
|
@@ -313,6 +335,7 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
313
335
|
name: eff.name,
|
|
314
336
|
hubAddress: eff.hubAddress,
|
|
315
337
|
resolvedHubAddress,
|
|
338
|
+
hubHttpsOrigin,
|
|
316
339
|
appVersion,
|
|
317
340
|
hubConnected,
|
|
318
341
|
hubConnectionState,
|
|
@@ -328,6 +351,7 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
328
351
|
name: eff.name,
|
|
329
352
|
hubAddress: eff.hubAddress,
|
|
330
353
|
resolvedHubAddress,
|
|
354
|
+
hubHttpsOrigin,
|
|
331
355
|
appVersion,
|
|
332
356
|
hubConnected,
|
|
333
357
|
hubConnectionState,
|
|
@@ -340,6 +364,46 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
340
364
|
};
|
|
341
365
|
}
|
|
342
366
|
});
|
|
367
|
+
// -- Unlock info (public; Continue-with-hub needs hub origin while locked) --
|
|
368
|
+
app.get('/api/agent/unlock-info', async () => {
|
|
369
|
+
const secret = currentSecret();
|
|
370
|
+
const eff = getEffectiveConfig(config.configPath, config.nodeId);
|
|
371
|
+
const nodes = getRegistryNodes(getBroker());
|
|
372
|
+
const resolvedHubAddress = resolveHubEndpoint(nodes);
|
|
373
|
+
const hubHttpsOrigin = (eff.hubAddress !== null ? (0, derive_hub_url_js_1.deriveHubUrlFromHubAddress)(eff.hubAddress) : undefined) ??
|
|
374
|
+
(resolvedHubAddress !== null ? (0, derive_hub_url_js_1.deriveHubUrlFromHubAddress)(resolvedHubAddress) : undefined) ??
|
|
375
|
+
null;
|
|
376
|
+
return {
|
|
377
|
+
paired: secret !== null,
|
|
378
|
+
nodeId: config.nodeId,
|
|
379
|
+
hubHttpsOrigin,
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
// -- Unlock session (hub admin ticket → tab Bearer; D821) -----------
|
|
383
|
+
app.post('/api/agent/session', async (req, reply) => {
|
|
384
|
+
const secret = currentSecret();
|
|
385
|
+
if (secret === null) {
|
|
386
|
+
return reply.status(400).send({ ok: false, error: 'agent is not paired' });
|
|
387
|
+
}
|
|
388
|
+
const ticket = typeof req.body?.ticket === 'string' ? req.body.ticket.trim() : '';
|
|
389
|
+
if (ticket.length === 0) {
|
|
390
|
+
return reply.status(400).send({ ok: false, error: 'ticket required' });
|
|
391
|
+
}
|
|
392
|
+
const payload = (0, system_1.verifyAgentUiUnlockTicket)((0, system_1.hashClusterSecret)(secret), ticket, {
|
|
393
|
+
expectedNodeId: config.nodeId,
|
|
394
|
+
});
|
|
395
|
+
if (payload === null) {
|
|
396
|
+
return reply.status(401).send({ ok: false, error: 'invalid or expired unlock ticket' });
|
|
397
|
+
}
|
|
398
|
+
if (!unlockJtis.consume(payload.jti, payload.exp)) {
|
|
399
|
+
return reply.status(401).send({ ok: false, error: 'unlock ticket already used' });
|
|
400
|
+
}
|
|
401
|
+
const token = (0, node_crypto_1.randomBytes)(32).toString('base64url');
|
|
402
|
+
// Session outlives the ticket so the tab keeps working after redeem.
|
|
403
|
+
const sessionExp = Date.now() + 8 * 60 * 60_000;
|
|
404
|
+
unlockSessions.set(token, sessionExp);
|
|
405
|
+
return { ok: true, token, expiresAt: sessionExp };
|
|
406
|
+
});
|
|
343
407
|
// -- Processes ------------------------------------------------------
|
|
344
408
|
app.get('/api/agent/processes', async () => {
|
|
345
409
|
try {
|
|
@@ -391,11 +455,22 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
391
455
|
const clearMeshTls = body['clearMeshTls'] === true;
|
|
392
456
|
const patch = Object.fromEntries(Object.entries(body).filter(([k]) => k !== 'joinToken' && k !== 'meshTls' && k !== 'clearMeshTls'));
|
|
393
457
|
const existing = readConfigFile(config.configPath);
|
|
458
|
+
const alreadyPaired = typeof existing.secret === 'string' && existing.secret.length > 0;
|
|
459
|
+
const patchSecret = typeof patch['secret'] === 'string' && patch['secret'].trim().length > 0;
|
|
460
|
+
const hasJoinToken = typeof joinToken === 'string' && joinToken.trim().length > 0;
|
|
461
|
+
// First pairing (D803 / D821): hub always has a secret — require secret or
|
|
462
|
+
// join token. Later edits may omit secret to keep the stored value.
|
|
463
|
+
if (!alreadyPaired && !patchSecret && !hasJoinToken) {
|
|
464
|
+
return reply.status(400).send({
|
|
465
|
+
success: false,
|
|
466
|
+
error: 'Cluster secret is required — copy it from the hub System → Cluster page, or paste a join token',
|
|
467
|
+
});
|
|
468
|
+
}
|
|
394
469
|
// Join-token enrollment (mesh mTLS Release A): the hub address comes from
|
|
395
470
|
// the token and wins over a `hubAddress` posted in the same form. Nothing is
|
|
396
471
|
// written unless the whole enrollment verified.
|
|
397
472
|
let enrolled = null;
|
|
398
|
-
if (
|
|
473
|
+
if (hasJoinToken) {
|
|
399
474
|
try {
|
|
400
475
|
enrolled = await (config.redeemJoinToken ?? defaultRedeemJoinToken)(joinToken, config.nodeId);
|
|
401
476
|
}
|
|
@@ -14,7 +14,7 @@ const types_1 = require("@camstack/types");
|
|
|
14
14
|
*
|
|
15
15
|
* The union was the shape until 2026-08-19, and on a three-node cluster one
|
|
16
16
|
* 6-hourly sweep sent the operator THREE pushes that each listed hub +
|
|
17
|
-
*
|
|
17
|
+
* MacMiniAgent + little-unraid and named no node: every scope's change
|
|
18
18
|
* re-emitted the whole held map, so the notifications differed by one line and
|
|
19
19
|
* read as identical. A list is only actionable if it belongs to somebody.
|
|
20
20
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.493",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"files": [
|
|
@@ -37,18 +37,18 @@
|
|
|
37
37
|
]
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
41
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
42
|
-
"@camstack/addon-auth": "1.2.
|
|
40
|
+
"@camstack/addon-admin-ui": "1.2.366",
|
|
41
|
+
"@camstack/addon-agent-ui": "1.2.200",
|
|
42
|
+
"@camstack/addon-auth": "1.2.136",
|
|
43
43
|
"@camstack/addon-decoder-nodeav": "1.2.122",
|
|
44
44
|
"@camstack/addon-notifiers": "1.2.129",
|
|
45
|
-
"@camstack/addon-pipeline": "1.2.
|
|
46
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
47
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
45
|
+
"@camstack/addon-pipeline": "1.2.348",
|
|
46
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.302",
|
|
47
|
+
"@camstack/addon-post-analysis": "1.2.350",
|
|
48
48
|
"@camstack/sdk": "1.2.128",
|
|
49
|
-
"@camstack/system": "1.2.
|
|
50
|
-
"@camstack/types": "1.2.
|
|
51
|
-
"@camstack/ui-library": "1.2.
|
|
49
|
+
"@camstack/system": "1.2.387",
|
|
50
|
+
"@camstack/types": "1.2.336",
|
|
51
|
+
"@camstack/ui-library": "1.2.257",
|
|
52
52
|
"@fastify/compress": "^9.0.0",
|
|
53
53
|
"@fastify/cookie": "^11.0.2",
|
|
54
54
|
"@fastify/cors": "^11.2.0",
|