@camstack/server 1.2.572 → 1.2.573
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-http-auth.js +59 -6
- package/dist/agent/agent-http.js +94 -16
- package/dist/agent/agent-mesh-join.js +2 -0
- package/dist/agent/agent-node-certificates.js +5 -0
- package/dist/agent/agent-pairing-local.js +108 -0
- package/dist/agent/agent-pairing-routes.js +87 -0
- package/dist/agent/agent-pairing-store.js +105 -0
- package/dist/agent/main.js +65 -0
- package/dist/api/core/agent-pairing.router.js +264 -0
- package/dist/api/core/certificates.router.js +2 -2
- package/dist/api/trpc/generated-cap-routers.js +9 -0
- package/dist/api/trpc/trpc.router.js +7 -0
- package/dist/core/agent/agent-pairing-http.js +150 -0
- package/dist/core/agent/hub-agent-pairing.js +123 -0
- package/dist/core/agent/node-certificates-client.js +1 -0
- package/dist/core/moleculer/hub-mesh-guard.js +1 -0
- package/dist/core/moleculer/moleculer.service.js +1 -0
- package/package.json +7 -7
|
@@ -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 (
|
|
89
|
+
if (isGenuineLoopback(input))
|
|
66
90
|
return true;
|
|
67
|
-
|
|
68
|
-
|
|
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
|
+
}
|
package/dist/agent/agent-http.js
CHANGED
|
@@ -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([
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
272
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|