@camstack/server 1.2.572 → 1.2.574

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.
@@ -16,6 +16,8 @@
16
16
  * through the pairing surface only; everything else stays denied.
17
17
  */
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.hasForwardingHeader = hasForwardingHeader;
20
+ exports.isGenuineLoopback = isGenuineLoopback;
19
21
  exports.isLoopbackAddress = isLoopbackAddress;
20
22
  exports.bearerMatchesSecret = bearerMatchesSecret;
21
23
  exports.readBearerToken = readBearerToken;
@@ -23,7 +25,29 @@ exports.isAuthorizedAgentRequest = isAuthorizedAgentRequest;
23
25
  exports.isPairingRequest = isPairingRequest;
24
26
  exports.isUnlockSessionRequest = isUnlockSessionRequest;
25
27
  exports.isUnlockInfoRequest = isUnlockInfoRequest;
28
+ exports.isPairingProtocolRequest = isPairingProtocolRequest;
26
29
  const node_crypto_1 = require("node:crypto");
30
+ const FORWARDING_HEADERS = [
31
+ 'forwarded',
32
+ 'x-forwarded-for',
33
+ 'x-forwarded-host',
34
+ 'x-forwarded-proto',
35
+ 'x-forwarded-port',
36
+ 'x-real-ip',
37
+ 'via',
38
+ 'cf-connecting-ip',
39
+ ];
40
+ /** True when any header says the request was relayed (D857): then loopback proves nothing. */
41
+ function hasForwardingHeader(headers) {
42
+ return FORWARDING_HEADERS.some((name) => headers[name] !== undefined);
43
+ }
44
+ /**
45
+ * A request from this machine itself: a loopback peer AND no forwarding header. A reverse proxy on
46
+ * the same host makes every caller look like 127.0.0.1; the header it adds is what gives it away.
47
+ */
48
+ function isGenuineLoopback(input) {
49
+ return input.forwarded !== true && isLoopbackAddress(input.remoteAddress);
50
+ }
27
51
  /** IPv4/IPv6 loopback (incl. the IPv4-mapped IPv6 form Node reports). */
28
52
  function isLoopbackAddress(addr) {
29
53
  if (!addr)
@@ -62,11 +86,11 @@ function readBearerToken(authorization) {
62
86
  * `sessionTokenOk` admits a hub-minted unlock session (D821).
63
87
  */
64
88
  function isAuthorizedAgentRequest(input, clusterSecret, sessionTokenOk = () => false) {
65
- if (isLoopbackAddress(input.remoteAddress))
89
+ if (isGenuineLoopback(input))
66
90
  return true;
67
- if (clusterSecret === null || clusterSecret.length === 0)
68
- return false;
69
- if (bearerMatchesSecret(input.authorization, clusterSecret))
91
+ // A secretless agent (paired by certificate, D857) is still reachable through a hub-unlocked session.
92
+ const hasSecret = clusterSecret !== null && clusterSecret.length > 0;
93
+ if (hasSecret && bearerMatchesSecret(input.authorization, clusterSecret))
70
94
  return true;
71
95
  const token = readBearerToken(input.authorization);
72
96
  return token !== null && sessionTokenOk(token);
@@ -77,7 +101,9 @@ function isAuthorizedAgentRequest(input, clusterSecret, sessionTokenOk = () => f
77
101
  * trigger the reconnect). Process control and health details are NEVER
78
102
  * part of it. Only consulted while the agent has no secret configured.
79
103
  */
80
- function isPairingRequest(method, urlPath) {
104
+ function isPairingRequest(method, urlPath,
105
+ /** Pairing exists: the first-boot surface is read-only, writes need loopback or a hub session (D857). */
106
+ readOnly = false) {
81
107
  const pathOnly = urlPath.split('?')[0] ?? urlPath;
82
108
  if (method === 'GET') {
83
109
  return (pathOnly === '/api/agent/status' ||
@@ -85,7 +111,7 @@ function isPairingRequest(method, urlPath) {
85
111
  pathOnly === '/api/agent/discovered-nodes' ||
86
112
  pathOnly === '/api/agent/hubs-found');
87
113
  }
88
- if (method === 'POST') {
114
+ if (method === 'POST' && !readOnly) {
89
115
  return (pathOnly === '/api/agent/config' ||
90
116
  pathOnly === '/api/agent/restart' ||
91
117
  pathOnly === '/api/agent/hubs-found/refresh');
@@ -108,3 +134,30 @@ function isUnlockInfoRequest(method, urlPath) {
108
134
  const pathOnly = urlPath.split('?')[0] ?? urlPath;
109
135
  return method === 'GET' && pathOnly === '/api/agent/unlock-info';
110
136
  }
137
+ const PAIRING_GET = new Set([
138
+ '/api/agent/pairing/hello',
139
+ '/api/agent/pairing/status',
140
+ ]);
141
+ const PAIRING_POST = new Set([
142
+ '/api/agent/pairing/offer',
143
+ '/api/agent/pairing/reveal',
144
+ '/api/agent/pairing/poll',
145
+ '/api/agent/pairing/reset',
146
+ '/api/agent/pairing/cancel',
147
+ '/api/agent/pairing/confirm',
148
+ '/api/agent/pairing/reject',
149
+ '/api/agent/pairing/complete',
150
+ ]);
151
+ /**
152
+ * The pairing protocol endpoints (D857): reachable from the network without a session, each
153
+ * authorized by what it carries (a pairing id the agent made up, a proof the mesh CA signed).
154
+ * `forget` is NOT among them - leaving a hub is an authenticated action.
155
+ */
156
+ function isPairingProtocolRequest(method, urlPath) {
157
+ const pathOnly = urlPath.split('?')[0] ?? urlPath;
158
+ if (method === 'GET')
159
+ return PAIRING_GET.has(pathOnly);
160
+ if (method === 'POST')
161
+ return PAIRING_POST.has(pathOnly);
162
+ return false;
163
+ }
@@ -41,6 +41,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
41
41
  };
42
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
43
  exports.pickIpv4 = void 0;
44
+ exports.describePairedHub = describePairedHub;
44
45
  exports.compareVersions = compareVersions;
45
46
  exports.readUiDistCandidate = readUiDistCandidate;
46
47
  exports.pickUiDist = pickUiDist;
@@ -66,6 +67,19 @@ const derive_hub_url_js_1 = require("./derive-hub-url.js");
66
67
  Object.defineProperty(exports, "pickIpv4", { enumerable: true, get: function () { return derive_hub_url_js_1.pickIpv4; } });
67
68
  const agent_addon_runtime_js_1 = require("./agent-addon-runtime.js");
68
69
  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
+ /** The pinned CA's fingerprint, or `null` when none is pinned or the PEM cannot be read. */
73
+ function describePairedHub(pinnedCa, hubHttpsOrigin) {
74
+ if (pinnedCa === null)
75
+ return null;
76
+ try {
77
+ return { url: hubHttpsOrigin, caFingerprint: (0, system_1.caFingerprintCompact)(pinnedCa) };
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
69
83
  /** Dotted-numeric version compare; `null` sorts lowest. */
70
84
  function compareVersions(a, b) {
71
85
  if (a === null && b === null)
@@ -246,7 +260,14 @@ function getEffectiveConfig(configPath, nodeId) {
246
260
  };
247
261
  }
248
262
  /** Persisted fields that are credentials: never echoed by `GET /api/agent/config`. */
249
- const NEVER_ECHOED_CONFIG_KEYS = new Set(['secret', 'meshTls', 'joinToken']);
263
+ const NEVER_ECHOED_CONFIG_KEYS = new Set([
264
+ 'secret',
265
+ 'meshTls',
266
+ 'meshTlsPrevious',
267
+ 'joinToken',
268
+ ]);
269
+ /** Clock skew allowed between the hub that issued a ticket and this agent's boot instant. */
270
+ const TICKET_BOOT_SKEW_MS = 2 * 60_000;
250
271
  const defaultRedeemJoinToken = (token, nodeId) => (0, agent_mesh_enroll_js_1.enrollAgentMesh)({ token, nodeId });
251
272
  // ---------------------------------------------------------------------------
252
273
  // Factory
@@ -264,14 +285,34 @@ async function createAgentHttpServer(getBroker, config) {
264
285
  const raw = readConfigFile(config.configPath);
265
286
  return typeof raw.secret === 'string' && raw.secret.length > 0 ? raw.secret : null;
266
287
  };
267
- /** Tab sessions from hub unlock tickets — token → expiry ms. */
288
+ /**
289
+ * Tab sessions from hub unlock tickets. A session is bound to what vouched for it - the mesh CA
290
+ * (by fingerprint) or the cluster secret - and dies the moment that changes: forgetting the hub
291
+ * or pairing with another one ends every session, not just the old Bearer (D857).
292
+ */
268
293
  const unlockSessions = new Map();
269
- const unlockJtis = new system_1.UnlockTicketJtiStore();
294
+ let unlockJtis = new system_1.UnlockTicketJtiStore();
295
+ let jtiBinding = null;
296
+ const bootMs = Date.now();
297
+ /** What would vouch for a session now: the pinned CA, else the secret, else nothing. */
298
+ const caBinding = () => {
299
+ const ca = (0, agent_pairing_store_js_1.pinnedMeshCaCert)(config.configPath);
300
+ return ca === null ? null : `ca:${(0, system_1.caFingerprintCompact)(ca)}`;
301
+ };
302
+ const secretBinding = () => {
303
+ const secret = currentSecret();
304
+ return secret === null ? null : `secret:${(0, system_1.hashClusterSecret)(secret)}`;
305
+ };
270
306
  const sessionOk = (token) => {
271
- const exp = unlockSessions.get(token);
272
- if (exp === undefined)
307
+ const entry = unlockSessions.get(token);
308
+ if (entry === undefined)
273
309
  return false;
274
- if (exp <= Date.now()) {
310
+ if (entry.exp <= Date.now()) {
311
+ unlockSessions.delete(token);
312
+ return false;
313
+ }
314
+ const current = entry.binding.startsWith('ca:') ? caBinding() : secretBinding();
315
+ if (current !== entry.binding) {
275
316
  unlockSessions.delete(token);
276
317
  return false;
277
318
  }
@@ -282,23 +323,35 @@ async function createAgentHttpServer(getBroker, config) {
282
323
  const isProtected = pathOnly.startsWith('/api/') || pathOnly.startsWith('/health/');
283
324
  if (!isProtected)
284
325
  return;
326
+ if ((0, agent_http_auth_js_1.isPairingProtocolRequest)(req.method, pathOnly))
327
+ return;
285
328
  const secret = currentSecret();
286
- if (secret !== null && (0, agent_http_auth_js_1.isUnlockSessionRequest)(req.method, pathOnly))
329
+ // Paired = holds the cluster secret OR a mesh certificate (D857): either closes the open surface.
330
+ const locked = secret !== null || (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath);
331
+ if (locked && (0, agent_http_auth_js_1.isUnlockSessionRequest)(req.method, pathOnly))
287
332
  return;
288
333
  if ((0, agent_http_auth_js_1.isUnlockInfoRequest)(req.method, pathOnly))
289
334
  return;
290
335
  const authInput = {
291
336
  remoteAddress: req.socket.remoteAddress ?? undefined,
337
+ forwarded: (0, agent_http_auth_js_1.hasForwardingHeader)(req.headers),
292
338
  ...(typeof req.headers.authorization === 'string'
293
339
  ? { authorization: req.headers.authorization }
294
340
  : {}),
295
341
  };
296
342
  if ((0, agent_http_auth_js_1.isAuthorizedAgentRequest)(authInput, secret, sessionOk))
297
343
  return;
298
- if (secret === null && (0, agent_http_auth_js_1.isPairingRequest)(req.method, pathOnly))
344
+ if (!locked && (0, agent_http_auth_js_1.isPairingRequest)(req.method, pathOnly, config.pairing !== undefined))
299
345
  return;
300
346
  return reply.status(401).send({ ok: false, error: 'unauthorized' });
301
347
  });
348
+ if (config.pairing !== undefined) {
349
+ (0, agent_pairing_routes_js_1.registerAgentPairingRoutes)(app, {
350
+ pairing: config.pairing,
351
+ onForgetHub: config.onForgetHub ?? (async () => undefined),
352
+ log: (message) => config.log?.info(message),
353
+ });
354
+ }
302
355
  // -- Health ---------------------------------------------------------
303
356
  // PUBLIC probe: liveness only — `{ok}`, no nodeId/version/topology (a
304
357
  // public endpoint must not enumerate the deployment; same policy as the
@@ -355,6 +408,8 @@ async function createAgentHttpServer(getBroker, config) {
355
408
  // when the agent runs headless / in Docker.
356
409
  const appVersion = process.env['CAMSTACK_AGENT_APP_VERSION'] ?? null;
357
410
  const discoveredNodes = mapDiscoveredNodes(nodes, broker.nodeID);
411
+ // The hub this agent is paired with, for the agent UI's "Paired with" card (D857).
412
+ const pairedHub = describePairedHub((0, agent_pairing_store_js_1.pinnedMeshCaCert)(config.configPath), hubHttpsOrigin);
358
413
  try {
359
414
  const status = (await broker.call('$agent.status'));
360
415
  return {
@@ -367,6 +422,8 @@ async function createAgentHttpServer(getBroker, config) {
367
422
  hubConnected,
368
423
  hubConnectionState,
369
424
  hasSecret: eff.hasSecret,
425
+ paired: (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath),
426
+ pairedHub,
370
427
  meshTransport,
371
428
  discoveredNodes,
372
429
  };
@@ -382,6 +439,8 @@ async function createAgentHttpServer(getBroker, config) {
382
439
  hubConnected,
383
440
  hubConnectionState,
384
441
  hasSecret: eff.hasSecret,
442
+ paired: (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath),
443
+ pairedHub,
385
444
  meshTransport,
386
445
  discoveredNodes,
387
446
  addons: [],
@@ -399,7 +458,7 @@ async function createAgentHttpServer(getBroker, config) {
399
458
  (resolvedHubAddress !== null ? (0, derive_hub_url_js_1.deriveHubUrlFromHubAddress)(resolvedHubAddress) : undefined) ??
400
459
  null;
401
460
  return {
402
- paired: secret !== null,
461
+ paired: secret !== null || (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath),
403
462
  nodeId: config.nodeId,
404
463
  hubHttpsOrigin,
405
464
  };
@@ -407,26 +466,42 @@ async function createAgentHttpServer(getBroker, config) {
407
466
  // -- Unlock session (hub admin ticket → tab Bearer; D821) -----------
408
467
  app.post('/api/agent/session', async (req, reply) => {
409
468
  const secret = currentSecret();
410
- if (secret === null) {
469
+ const pinnedCa = (0, agent_pairing_store_js_1.pinnedMeshCaCert)(config.configPath);
470
+ if (secret === null && pinnedCa === null) {
411
471
  return reply.status(400).send({ ok: false, error: 'agent is not paired' });
412
472
  }
413
473
  const ticket = typeof req.body?.ticket === 'string' ? req.body.ticket.trim() : '';
414
474
  if (ticket.length === 0) {
415
475
  return reply.status(400).send({ ok: false, error: 'ticket required' });
416
476
  }
417
- const payload = (0, system_1.verifyAgentUiUnlockTicket)((0, system_1.hashClusterSecret)(secret), ticket, {
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
+ // Nothing issued before this process started is accepted: the one-use ledger is in memory.
480
+ const options = {
418
481
  expectedNodeId: config.nodeId,
419
- });
420
- if (payload === null) {
482
+ notBeforeMs: bootMs - TICKET_BOOT_SKEW_MS,
483
+ };
484
+ const es256 = pinnedCa !== null ? (0, system_1.verifyAgentUiUnlockTicketEs256)(pinnedCa, ticket, options) : null;
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();
490
+ if (payload === null || binding === null) {
421
491
  return reply.status(401).send({ ok: false, error: 'invalid or expired unlock ticket' });
422
492
  }
493
+ // A new vouching CA or secret starts a new ledger.
494
+ if (jtiBinding !== binding) {
495
+ unlockJtis = new system_1.UnlockTicketJtiStore();
496
+ jtiBinding = binding;
497
+ }
423
498
  if (!unlockJtis.consume(payload.jti, payload.exp)) {
424
499
  return reply.status(401).send({ ok: false, error: 'unlock ticket already used' });
425
500
  }
426
501
  const token = (0, node_crypto_1.randomBytes)(32).toString('base64url');
427
502
  // Session outlives the ticket so the tab keeps working after redeem.
428
503
  const sessionExp = Date.now() + 8 * 60 * 60_000;
429
- unlockSessions.set(token, sessionExp);
504
+ unlockSessions.set(token, { exp: sessionExp, binding });
430
505
  return { ok: true, token, expiresAt: sessionExp };
431
506
  });
432
507
  // -- Processes ------------------------------------------------------
@@ -489,12 +564,15 @@ async function createAgentHttpServer(getBroker, config) {
489
564
  const clearMeshTls = body['clearMeshTls'] === true;
490
565
  const patch = Object.fromEntries(Object.entries(body).filter(([k]) => k !== 'joinToken' && k !== 'meshTls' && k !== 'clearMeshTls'));
491
566
  const existing = readConfigFile(config.configPath);
492
- const alreadyPaired = typeof existing.secret === 'string' && existing.secret.length > 0;
567
+ const alreadyPaired = (typeof existing.secret === 'string' && existing.secret.length > 0) ||
568
+ (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath);
493
569
  const patchSecret = typeof patch['secret'] === 'string' && patch['secret'].trim().length > 0;
494
570
  const hasJoinToken = typeof joinToken === 'string' && joinToken.trim().length > 0;
495
571
  // First pairing (D803 / D821): hub always has a secret — require secret or
496
572
  // join token. Later edits may omit secret to keep the stored value.
497
- if (!alreadyPaired && !patchSecret && !hasJoinToken) {
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) {
498
576
  return reply.status(400).send({
499
577
  success: false,
500
578
  error: 'Cluster secret is required — copy it from the hub System → Cluster page, or paste a join token',
@@ -41,6 +41,8 @@ function createAgentMeshJoin(opts) {
41
41
  const authenticator = new system_1.AgentMeshAuthenticator({
42
42
  ownNodeId: opts.nodeId,
43
43
  hubNodeId: HUB_NODE_ID,
44
+ ...(opts.isVerifiedPeer !== undefined ? { isVerifiedPeer: opts.isVerifiedPeer } : {}),
45
+ ...(opts.isPaired !== undefined ? { isPaired: opts.isPaired } : {}),
44
46
  hasSecret: () => {
45
47
  const s = opts.secret();
46
48
  return s !== null && s.length > 0;
@@ -254,6 +254,11 @@ function createAgentNodeCertificates(deps) {
254
254
  return { confirmed: true };
255
255
  },
256
256
  rollbackMeshLeaf: async () => ({ rolledBack: revert('the hub asked for a rollback') }),
257
+ forgetHub: async () => {
258
+ log.warn('The hub unpaired this agent', { tags });
259
+ deps.forgetHub();
260
+ return { ok: true };
261
+ },
257
262
  prepareHttpsCertificate: async () => {
258
263
  const names = (0, system_1.collectAgentNames)(nodeId, deps.hostFacts);
259
264
  const request = await (0, system_1.createAgentHttpsRequest)(deps.dataDir, nodeId, names);
@@ -0,0 +1,108 @@
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.PAIRING_RESET_FILE = exports.PAIRING_PIN_FILE = void 0;
37
+ exports.createLocalPairingAnnouncer = createLocalPairingAnnouncer;
38
+ /**
39
+ * Where a pairing's one-time PIN, the agent's own code and the reset code are shown: ONLY on the
40
+ * agent's machine (D857).
41
+ *
42
+ * The agent's page is opened from another PC and its API is reachable by the whole LAN, so anything
43
+ * shown there could be read and used by anyone. The operator reads these things at the machine
44
+ * instead - the process console (`docker logs` for a container), a 0600 file in the data dir, and the
45
+ * desktop window (which asks the agent's loopback `status`) - and types the hub's code and the PIN
46
+ * into the page.
47
+ */
48
+ const fs = __importStar(require("node:fs"));
49
+ const path = __importStar(require("node:path"));
50
+ const node_1 = require("@camstack/types/node");
51
+ exports.PAIRING_PIN_FILE = 'pairing-pin.txt';
52
+ exports.PAIRING_RESET_FILE = 'pairing-reset.txt';
53
+ function describe(s) {
54
+ const fp = s.hub.caFingerprint.slice(0, 16);
55
+ return [
56
+ `Pairing request from hub "${s.hub.nodeId}" (${s.hub.url}), CA ${fp}`,
57
+ ` PIN: ${s.pin} (type it on this agent's page with the code the HUB shows)`,
58
+ ` this agent's code: ${s.code ?? '(not known yet - the hub has not revealed its nonce)'} (type it on the hub)`,
59
+ ` expires: ${new Date(s.expiresAt).toISOString()}`,
60
+ ].join('\n');
61
+ }
62
+ /**
63
+ * Keep `pairing-pin.txt` (0600) and the console in step with the pending pairings. The file always
64
+ * holds exactly the live ones and is removed when none is left - and a stale one left by a previous
65
+ * run is removed here, at boot. A PIN is never logged anywhere else.
66
+ */
67
+ function createLocalPairingAnnouncer(dataDir, log) {
68
+ const file = path.join(dataDir, exports.PAIRING_PIN_FILE);
69
+ const resetFile = path.join(dataDir, exports.PAIRING_RESET_FILE);
70
+ const live = new Map();
71
+ const flush = () => {
72
+ try {
73
+ if (live.size === 0) {
74
+ fs.rmSync(file, { force: true });
75
+ return;
76
+ }
77
+ fs.mkdirSync(dataDir, { recursive: true });
78
+ (0, node_1.writeOwnerOnlyFileSync)(file, `${[...live.values()].map(describe).join('\n\n')}\n`);
79
+ }
80
+ catch (err) {
81
+ log.warn(`could not write ${file}: ${err instanceof Error ? err.message : String(err)} - the PIN is in the console above`);
82
+ }
83
+ };
84
+ // A PIN file from a previous run belongs to pairings that no longer exist.
85
+ flush();
86
+ return {
87
+ onSecret(secret) {
88
+ live.set(secret.pairingId, secret);
89
+ // The console is the agent's own stdout: `docker logs`, the service journal, the desktop log.
90
+ log.warn(`\n${describe(secret)}\n (also in ${file})`);
91
+ flush();
92
+ },
93
+ onCleared(pairingId) {
94
+ if (live.delete(pairingId))
95
+ flush();
96
+ },
97
+ onResetCode(code) {
98
+ log.warn(`\nPairing reset code: ${code}\n (clears pairing lockouts and pending offers: POST /api/agent/pairing/reset {"code": ...}; also in ${resetFile})`);
99
+ try {
100
+ fs.mkdirSync(dataDir, { recursive: true });
101
+ (0, node_1.writeOwnerOnlyFileSync)(resetFile, `${code}\n`);
102
+ }
103
+ catch (err) {
104
+ log.warn(`could not write ${resetFile}: ${err instanceof Error ? err.message : String(err)} - the code is in the console above`);
105
+ }
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isLocalRequest = isLocalRequest;
4
+ exports.registerAgentPairingRoutes = registerAgentPairingRoutes;
5
+ const system_1 = require("@camstack/system");
6
+ const agent_http_auth_js_1 = require("./agent-http-auth.js");
7
+ const OFFER_WINDOW_MS = 60_000;
8
+ const DEFAULT_OFFERS_PER_MINUTE = 10;
9
+ const MAX_TRACKED_SOURCES = 512;
10
+ /** True for a request from this machine itself: loopback peer, no forwarding header. */
11
+ function isLocalRequest(req) {
12
+ return (0, agent_http_auth_js_1.isGenuineLoopback)({
13
+ remoteAddress: req.socket.remoteAddress ?? undefined,
14
+ forwarded: (0, agent_http_auth_js_1.hasForwardingHeader)(req.headers),
15
+ });
16
+ }
17
+ function send(reply, result) {
18
+ if (result.ok)
19
+ return result;
20
+ const failure = result;
21
+ if (failure.retryAfterMs !== undefined) {
22
+ void reply.header('retry-after', String(Math.ceil(failure.retryAfterMs / 1000)));
23
+ }
24
+ return reply.status(failure.status).send({
25
+ ok: false,
26
+ error: failure.error,
27
+ message: failure.message,
28
+ ...(failure.retryAfterMs !== undefined ? { retryAfterMs: failure.retryAfterMs } : {}),
29
+ });
30
+ }
31
+ function registerAgentPairingRoutes(app, deps) {
32
+ const { pairing } = deps;
33
+ const now = deps.now ?? Date.now;
34
+ const limit = deps.offersPerMinute ?? DEFAULT_OFFERS_PER_MINUTE;
35
+ const offersBySource = new Map();
36
+ /** At most `limit` offers per source per minute, counted before the offer is even parsed. */
37
+ const rateLimited = (source) => {
38
+ const at = now();
39
+ const recent = (offersBySource.get(source) ?? []).filter((t) => at - t < OFFER_WINDOW_MS);
40
+ if (offersBySource.size >= MAX_TRACKED_SOURCES && !offersBySource.has(source)) {
41
+ offersBySource.clear();
42
+ }
43
+ if (recent.length >= limit) {
44
+ offersBySource.set(source, recent);
45
+ return true;
46
+ }
47
+ offersBySource.set(source, [...recent, at]);
48
+ return false;
49
+ };
50
+ app.get(system_1.PAIRING_PATHS.hello, async () => pairing.hello());
51
+ // The network sees who asks; the agent's own machine sees the secrets.
52
+ app.get(system_1.PAIRING_PATHS.status, async (req) => isLocalRequest(req) ? pairing.localView() : pairing.publicView());
53
+ app.post(system_1.PAIRING_PATHS.offer, async (req, reply) => {
54
+ if (rateLimited(req.socket.remoteAddress ?? 'unknown')) {
55
+ deps.log(`pairing offers from ${req.socket.remoteAddress ?? 'unknown'} are rate limited`);
56
+ return reply.status(429).header('retry-after', '60').send({
57
+ ok: false,
58
+ error: 'rate-limited',
59
+ message: 'too many pairing offers from this address - wait a minute',
60
+ });
61
+ }
62
+ return send(reply, await pairing.offer(req.body));
63
+ });
64
+ app.post(system_1.PAIRING_PATHS.reveal, async (req, reply) => send(reply, pairing.reveal(req.body)));
65
+ app.post(system_1.PAIRING_PATHS.poll, async (req, reply) => send(reply, pairing.poll(req.body)));
66
+ app.post(system_1.PAIRING_PATHS.cancel, async (req, reply) => send(reply, pairing.cancel(req.body)));
67
+ app.post(system_1.PAIRING_PATHS.complete, async (req, reply) => send(reply, await pairing.complete(req.body)));
68
+ const contextOf = (req) => ({
69
+ local: isLocalRequest(req),
70
+ source: req.socket.remoteAddress ?? 'unknown',
71
+ });
72
+ app.post(system_1.PAIRING_PATHS.confirm, async (req, reply) => send(reply, pairing.confirm(req.body, contextOf(req))));
73
+ app.post(system_1.PAIRING_PATHS.reject, async (req, reply) => send(reply, pairing.reject(req.body, contextOf(req))));
74
+ // Clears the offer lockouts and pending offers: this machine, or the reset code printed at boot.
75
+ app.post(system_1.PAIRING_PATHS.reset, async (req, reply) => send(reply, pairing.reset(req.body, contextOf(req))));
76
+ // Authenticated by the onRequest hook (not a protocol path): this machine or a hub session.
77
+ app.post(system_1.PAIRING_PATHS.forget, async (_req, reply) => {
78
+ if (pairing.state() !== 'paired') {
79
+ return reply
80
+ .status(409)
81
+ .send({ ok: false, error: 'not-paired', message: 'this agent is not paired' });
82
+ }
83
+ deps.log('Forgetting the hub on request (agent UI or local call)');
84
+ await deps.onForgetHub();
85
+ return { ok: true };
86
+ });
87
+ }
@@ -0,0 +1,105 @@
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.isAgentPaired = isAgentPaired;
37
+ exports.pinnedMeshCaCert = pinnedMeshCaCert;
38
+ exports.persistPairing = persistPairing;
39
+ exports.forgetPairing = forgetPairing;
40
+ exports.pairedLeafFingerprint = pairedLeafFingerprint;
41
+ /**
42
+ * What pairing writes to, and removes from, `agent.json` (D857). The file is
43
+ * 0600 on every OS; this module only ever touches the keys named below. An older
44
+ * `secret` field is neither read nor removed here — the operator deletes it.
45
+ */
46
+ const node_crypto_1 = require("node:crypto");
47
+ const fs = __importStar(require("node:fs"));
48
+ const system_1 = require("@camstack/system");
49
+ const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
50
+ /** Keys that exist only while this agent is paired. */
51
+ const PAIRED_KEYS = ['meshTls', 'meshTlsPrevious', 'meshSwap', 'hubAddress'];
52
+ /** `agent.json` holds mesh material: the agent is paired. */
53
+ function isAgentPaired(configPath) {
54
+ const meshTls = (0, agent_mesh_enroll_js_1.readJsonFile)(configPath)['meshTls'];
55
+ return (meshTls !== null &&
56
+ typeof meshTls === 'object' &&
57
+ typeof Reflect.get(meshTls, 'certPem') === 'string' &&
58
+ typeof Reflect.get(meshTls, 'keyPem') === 'string' &&
59
+ typeof Reflect.get(meshTls, 'caCertPem') === 'string');
60
+ }
61
+ /** The CA certificate this agent pinned at pairing, or `null` while unpaired. */
62
+ function pinnedMeshCaCert(configPath) {
63
+ const meshTls = (0, agent_mesh_enroll_js_1.readJsonFile)(configPath)['meshTls'];
64
+ if (meshTls === null || typeof meshTls !== 'object')
65
+ return null;
66
+ const ca = Reflect.get(meshTls, 'caCertPem');
67
+ return typeof ca === 'string' && ca.length > 0 ? ca : null;
68
+ }
69
+ /** Persist a completed pairing: the leaf, its key, the pinned CA and the hub's host. */
70
+ function persistPairing(configPath, pairing) {
71
+ const { joinToken: _joinToken, meshTlsPrevious: _previous, meshSwap: _swap, ...existing } = (0, agent_mesh_enroll_js_1.readJsonFile)(configPath);
72
+ (0, agent_mesh_enroll_js_1.writeJsonFile0600)(configPath, {
73
+ ...existing,
74
+ hubAddress: pairing.hubAddress,
75
+ meshTls: pairing.meshTls,
76
+ });
77
+ }
78
+ /**
79
+ * Leave the hub: erase the mesh material, the hub address and the HTTPS bundle
80
+ * the hub issued. The agent is unpaired afterwards and accepts offers again.
81
+ */
82
+ function forgetPairing(configPath, dataDir) {
83
+ const existing = (0, agent_mesh_enroll_js_1.readJsonFile)(configPath);
84
+ (0, agent_mesh_enroll_js_1.writeJsonFile0600)(configPath, Object.fromEntries(Object.entries(existing).filter(([key]) => !PAIRED_KEYS.includes(key))));
85
+ const https = (0, system_1.agentHttpsPaths)(dataDir);
86
+ for (const file of [https.bundlePath, https.certPath, https.pendingKeyPath]) {
87
+ fs.rmSync(file, { force: true });
88
+ }
89
+ }
90
+ /** First 16 hex chars of the SHA-256 of the agent's mesh leaf, `null` while unpaired. */
91
+ function pairedLeafFingerprint(configPath) {
92
+ const meshTls = (0, agent_mesh_enroll_js_1.readJsonFile)(configPath)['meshTls'];
93
+ const certPem = meshTls !== null && typeof meshTls === 'object' ? Reflect.get(meshTls, 'certPem') : null;
94
+ if (typeof certPem !== 'string')
95
+ return null;
96
+ try {
97
+ return new node_crypto_1.X509Certificate(certPem).fingerprint256
98
+ .replaceAll(':', '')
99
+ .toLowerCase()
100
+ .slice(0, 16);
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }