@camstack/server 1.2.492 → 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
- * — the secret is the agent↔hub trust anchor and the only credential
13
- * an agent can verify without a user database,
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
- return bearerMatchesSecret(input.authorization, clusterSecret);
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
+ }
@@ -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 `Authorization: Bearer <cluster secret>`. While
231
- // the agent is UNPAIRED (no secret configured) only the pairing surface
232
- // is reachable remotely. Registered as an onRequest hook over the /api
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 (typeof joinToken === 'string' && joinToken.trim().length > 0) {
473
+ if (hasJoinToken) {
399
474
  try {
400
475
  enrolled = await (config.redeemJoinToken ?? defaultRedeemJoinToken)(joinToken, config.nodeId);
401
476
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.492",
3
+ "version": "1.2.493",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
@@ -37,8 +37,8 @@
37
37
  ]
38
38
  },
39
39
  "dependencies": {
40
- "@camstack/addon-admin-ui": "1.2.365",
41
- "@camstack/addon-agent-ui": "1.2.199",
40
+ "@camstack/addon-admin-ui": "1.2.366",
41
+ "@camstack/addon-agent-ui": "1.2.200",
42
42
  "@camstack/addon-auth": "1.2.136",
43
43
  "@camstack/addon-decoder-nodeav": "1.2.122",
44
44
  "@camstack/addon-notifiers": "1.2.129",
@@ -46,7 +46,7 @@
46
46
  "@camstack/addon-pipeline-orchestrator": "1.2.302",
47
47
  "@camstack/addon-post-analysis": "1.2.350",
48
48
  "@camstack/sdk": "1.2.128",
49
- "@camstack/system": "1.2.386",
49
+ "@camstack/system": "1.2.387",
50
50
  "@camstack/types": "1.2.336",
51
51
  "@camstack/ui-library": "1.2.257",
52
52
  "@fastify/compress": "^9.0.0",