@camstack/server 1.2.578 → 1.2.580

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.
Files changed (37) hide show
  1. package/dist/agent/agent-config.js +2 -11
  2. package/dist/agent/agent-http-auth.js +91 -78
  3. package/dist/agent/agent-http.js +89 -139
  4. package/dist/agent/agent-hub-registration.js +31 -58
  5. package/dist/agent/agent-json-file.js +59 -0
  6. package/dist/agent/agent-mesh-gate.js +57 -0
  7. package/dist/agent/agent-node-certificates.js +7 -7
  8. package/dist/agent/agent-pairing-routes.js +1 -4
  9. package/dist/agent/agent-pairing-store.js +8 -8
  10. package/dist/agent/agent-service.js +0 -9
  11. package/dist/agent/main.js +41 -99
  12. package/dist/api/core/auth.router.js +11 -0
  13. package/dist/api/core/cap-providers.js +3 -1
  14. package/dist/api/core/certificates.router.js +2 -2
  15. package/dist/api/core/server-name.js +64 -0
  16. package/dist/api/oauth2/consent-page.js +22 -31
  17. package/dist/api/oauth2/oauth2-routes.js +5 -0
  18. package/dist/api/trpc/trpc.context.js +3 -3
  19. package/dist/api/trpc/trpc.router.js +29 -2
  20. package/dist/auth/app-confirm-page.js +38 -24
  21. package/dist/auth/app-confirm-routes.js +8 -1
  22. package/dist/auth/app-page-shell.js +123 -0
  23. package/dist/auth/app-page-strings.js +157 -0
  24. package/dist/auth/app-passkey-page.js +47 -26
  25. package/dist/auth/app-passkey-routes.js +9 -2
  26. package/dist/auth/app-refused-route.js +43 -0
  27. package/dist/auth/session-connections.js +136 -0
  28. package/dist/auth/sso-finish-app.js +7 -1
  29. package/dist/boot/boot-config.js +7 -5
  30. package/dist/core/agent/agent-registry.service.js +0 -10
  31. package/dist/core/moleculer/hub-mesh-guard.js +30 -101
  32. package/dist/core/moleculer/moleculer.service.js +8 -32
  33. package/dist/main.js +33 -2
  34. package/package.json +9 -9
  35. package/dist/agent/agent-mesh-enroll.js +0 -317
  36. package/dist/agent/agent-mesh-join.js +0 -140
  37. package/dist/api/mesh/enroll.routes.js +0 -34
@@ -26,9 +26,9 @@ class MoleculerService {
26
26
  }
27
27
  logger;
28
28
  /**
29
- * D802 — who may talk to the hub over the mesh: the challenge–response join,
30
- * the broker middleware that refuses / tolerates unauthenticated nodes and
31
- * strips forged identities, and the automatic end of `transition`.
29
+ * D802, D857 — who may talk to the hub over the mesh: the broker middleware
30
+ * that admits a node by its CERTIFICATE (verified at the TLS handshake),
31
+ * refuses the rest and strips forged identities.
32
32
  */
33
33
  meshGuard;
34
34
  /**
@@ -191,15 +191,13 @@ class MoleculerService {
191
191
  // moleculer→eventemitter2 whose types are unresolvable at this
192
192
  // boundary, so the inference falls to `error` and trips
193
193
  // `no-unsafe-assignment`. Going via `unknown` documents the boundary.
194
- // No cluster secret here (D797): the broker is created before the keychain
195
- // can answer, and the Moleculer namespace was never authentication (TCP
196
- // ignores it). The secret gates
197
- // `$hub.registerNode`, read from the keychain at call time.
194
+ // No shared secret (D857): the Moleculer namespace was never authentication (TCP
195
+ // ignores it); a node is admitted by the certificate it presented at the TLS handshake.
198
196
  this.meshGuard = new hub_mesh_guard_js_1.HubMeshGuard('hub', this.logging.createLogger('mesh-auth'));
199
197
  const broker = (0, system_1.createBroker)({
200
198
  nodeID: 'hub',
201
199
  mode: 'hub',
202
- // D802: every action and event another node sends passes the mesh gate.
200
+ // D802, D857: every action and event another node sends passes the certificate gate.
203
201
  middlewares: [this.meshGuard.middleware()],
204
202
  logLevel: this.config.get('moleculer.logLevel') ?? 'warn',
205
203
  ...(tcpPort && !Number.isNaN(tcpPort) ? { tcpPort } : {}),
@@ -224,23 +222,6 @@ class MoleculerService {
224
222
  setOnAgentRegistered(cb) {
225
223
  this.onAgentRegisteredCb = cb;
226
224
  }
227
- /**
228
- * D802: the cluster-node history, for the automatic end of the mesh
229
- * `transition` — wired by `AgentRegistryService`, which owns the history.
230
- */
231
- setKnownAgentsProvider(provider) {
232
- this.meshGuard.setKnownAgentsProvider(provider);
233
- }
234
- /** Every node Moleculer currently lists as available (this hub included). */
235
- listAvailableNodeIds() {
236
- const registry = this.broker.registry;
237
- try {
238
- return (registry?.getNodeList?.({ onlyAvailable: true }) ?? []).map((n) => n.id);
239
- }
240
- catch {
241
- return [];
242
- }
243
- }
244
225
  async onModuleInit() {
245
226
  const logger = this.logging.createLogger('moleculer');
246
227
  const hubDeps = {
@@ -292,10 +273,8 @@ class MoleculerService {
292
273
  onUnregisterNode: (nodeId) => {
293
274
  this.removeNodeFromRegistry(nodeId);
294
275
  },
295
- // System → Cluster, from the keychain (D797): off / on / unknown.
296
- clusterSecretGate: system_1.getProcessClusterSecretGate,
297
- // D802: challenge–response join + who may register.
298
- meshAuth: this.meshGuard.authenticator,
276
+ // D857: a node registers only itself, and only with a certificate identity.
277
+ meshIdentity: this.meshGuard.gate,
299
278
  };
300
279
  const hubService = (0, system_1.createHubService)(hubDeps);
301
280
  this.brokerSafe.createService(hubService);
@@ -639,7 +618,6 @@ class MoleculerService {
639
618
  // the incarnation) and this call is refused if the id has been reclaimed;
640
619
  // remote nodes register no owner at all, so they clean up as before.
641
620
  this.removeNodeFromRegistry(node.id);
642
- // D802: a node authenticates again on its next connection.
643
621
  this.meshGuard.forget(node.id);
644
622
  // Release A: its next connection decides again whether it is `tls` or `clear`.
645
623
  (0, system_1.getBrokerTlsTransporter)(this.broker)?.forgetPeer(node.id);
@@ -717,7 +695,6 @@ class MoleculerService {
717
695
  });
718
696
  await this.brokerSafe.start();
719
697
  logger.info('Moleculer broker started (TCP transport)');
720
- this.meshGuard.start(() => this.listAvailableNodeIds());
721
698
  // Construct the CapRouteResolver now that both the broker and
722
699
  // localChildRegistry are ready. The resolver reads live registry state
723
700
  // via closure accessors on every call (not a frozen snapshot), so new
@@ -1392,7 +1369,6 @@ class MoleculerService {
1392
1369
  async onModuleDestroy() {
1393
1370
  (0, system_1.setProcessMeshTlsEnabler)(null);
1394
1371
  (0, system_1.setProcessMeshPeerTransportLookup)(null);
1395
- this.meshGuard.stop();
1396
1372
  if (this.agentReadinessPullTimer !== null) {
1397
1373
  clearInterval(this.agentReadinessPullTimer);
1398
1374
  this.agentReadinessPullTimer = null;
package/dist/main.js CHANGED
@@ -59,7 +59,6 @@ const ws_1 = require("@trpc/server/adapters/ws");
59
59
  const ws_2 = require("ws");
60
60
  const addon_upload_1 = require("./api/addon-upload");
61
61
  const auth_whoami_1 = require("./api/auth-whoami");
62
- const enroll_routes_js_1 = require("./api/mesh/enroll.routes.js");
63
62
  const backup_download_js_1 = require("./api/backup-download.js");
64
63
  const cap_providers_1 = require("./api/core/cap-providers");
65
64
  const logging_settings_1 = require("./api/core/logging-settings");
@@ -81,6 +80,7 @@ const core_cap_bridge_1 = require("./api/trpc/core-cap-bridge");
81
80
  const http_request_census_singleton_1 = require("./api/trpc/http-request-census.singleton");
82
81
  const http_request_census_hook_1 = require("./api/trpc/http-request-census-hook");
83
82
  const trpc_context_1 = require("./api/trpc/trpc.context");
83
+ const session_connections_js_1 = require("./auth/session-connections.js");
84
84
  const trpc_router_1 = require("./api/trpc/trpc.router");
85
85
  const trpc_error_device_tags_1 = require("./api/trpc/trpc-error-device-tags");
86
86
  const trpc_error_principal_1 = require("./api/trpc/trpc-error-principal");
@@ -99,6 +99,7 @@ const sso_finish_deps_js_1 = require("./auth/sso-finish-deps.js");
99
99
  const app_confirmations_js_1 = require("./auth/app-confirmations.js");
100
100
  const app_confirm_routes_js_1 = require("./auth/app-confirm-routes.js");
101
101
  const app_passkey_routes_js_1 = require("./auth/app-passkey-routes.js");
102
+ const app_refused_route_js_1 = require("./auth/app-refused-route.js");
102
103
  const passkey_provider_js_1 = require("./auth/passkey-provider.js");
103
104
  const second_factors_js_1 = require("./auth/second-factors.js");
104
105
  const sso_routes_js_1 = require("./auth/sso-routes.js");
@@ -474,7 +475,6 @@ async function bootstrap() {
474
475
  // Mirrors the addon-upload auth chain so the CLI can ping for
475
476
  // token-still-valid without consuming a real cap.
476
477
  await (0, auth_whoami_1.registerAuthWhoamiRoute)(fastify, uploadAuthService, uploadAddonRegistry);
477
- (0, enroll_routes_js_1.registerMeshEnrollRoute)(fastify);
478
478
  console.log('[bootstrap] Auth whoami route registered at GET /api/auth/whoami');
479
479
  }
480
480
  catch (err) {
@@ -981,6 +981,7 @@ async function bootstrap() {
981
981
  limiter: authRouteLimiter,
982
982
  logger: app.get(logging_service_1.LoggingService).createLogger('app-passkey'),
983
983
  });
984
+ (0, app_refused_route_js_1.registerAppRefusedRoute)(fastify);
984
985
  // The last step of both app flows: "Sign in the app as <user>?" - the
985
986
  // handoff code is minted only when the person presses Continue (D858).
986
987
  (0, app_confirm_routes_js_1.registerAppConfirmRoutes)(fastify, {
@@ -1356,6 +1357,19 @@ async function bootstrap() {
1356
1357
  (0, ws_request_census_1.attachWsCensusSession)(httpRequestCensus, client, req, app.get(logging_service_1.LoggingService).createLogger('tRPC:ws'));
1357
1358
  wsSlowConsumerGuard.attach(client, (0, ws_slow_consumer_guard_js_1.wsConnectionFacts)(req));
1358
1359
  });
1360
+ // Revoking a login session ends what it holds: its sockets (close 4401
1361
+ // `session-ended`) and its live media. See `auth/session-connections.ts`.
1362
+ const sessionConnections = (0, session_connections_js_1.getSessionConnections)();
1363
+ sessionConnections.setLogger(app.get(logging_service_1.LoggingService).createLogger('session-revocation'));
1364
+ (0, system_1.onSessionsRevoked)((event) => {
1365
+ sessionConnections.endSessions({
1366
+ sessionIds: event.sessionIds,
1367
+ userId: event.userId,
1368
+ allOfUser: event.allOfUser,
1369
+ exceptSessionId: event.exceptSessionId,
1370
+ reason: event.reason,
1371
+ });
1372
+ });
1359
1373
  (0, ws_1.applyWSSHandler)({
1360
1374
  wss,
1361
1375
  router: appRouter,
@@ -1376,6 +1390,23 @@ async function bootstrap() {
1376
1390
  const principal = (0, trpc_error_principal_1.describeTrpcPrincipal)(wsCtx.user);
1377
1391
  (0, ws_request_census_1.identifyWsCensusSession)(opts.res, principal);
1378
1392
  wsSlowConsumerGuard.identify(opts.res, principal);
1393
+ const connectionLogger = app.get(logging_service_1.LoggingService).createLogger('tRPC:ws');
1394
+ if (wsCtx.credentialRejected === true) {
1395
+ // A bearer that resolves to nobody (revoked, expired): say so with
1396
+ // the close code the client recognises, instead of leaving an
1397
+ // anonymous socket open for the client to poll.
1398
+ connectionLogger.info('Closing a socket whose credential was refused', {
1399
+ meta: { principal, code: session_connections_js_1.SESSION_ENDED_CLOSE_CODE },
1400
+ });
1401
+ opts.res.close(session_connections_js_1.SESSION_ENDED_CLOSE_CODE, session_connections_js_1.SESSION_ENDED_CLOSE_REASON);
1402
+ }
1403
+ else if (wsCtx.user !== null) {
1404
+ const untrack = sessionConnections.trackSocket(opts.res, {
1405
+ userId: wsCtx.user.id,
1406
+ sessionId: wsCtx.user.sessionId,
1407
+ });
1408
+ opts.res.once('close', untrack);
1409
+ }
1379
1410
  return wsCtx;
1380
1411
  },
1381
1412
  onError: ({ path: trpcPath, error, ctx, input, }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.578",
3
+ "version": "1.2.580",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
@@ -38,19 +38,19 @@
38
38
  ]
39
39
  },
40
40
  "dependencies": {
41
- "@camstack/addon-admin-ui": "1.2.426",
42
- "@camstack/addon-agent-ui": "1.2.233",
43
- "@camstack/addon-auth": "1.2.145",
41
+ "@camstack/addon-admin-ui": "1.2.428",
42
+ "@camstack/addon-agent-ui": "1.2.235",
43
+ "@camstack/addon-auth": "1.2.147",
44
44
  "@camstack/addon-decoder-nodeav": "1.2.123",
45
45
  "@camstack/addon-notifiers": "1.2.134",
46
- "@camstack/addon-pipeline": "1.2.369",
46
+ "@camstack/addon-pipeline": "1.2.370",
47
47
  "@camstack/addon-pipeline-orchestrator": "1.2.323",
48
48
  "@camstack/addon-post-analysis": "1.2.369",
49
- "@camstack/develop": "0.1.10",
49
+ "@camstack/develop": "0.1.11",
50
50
  "@camstack/sdk": "1.2.131",
51
- "@camstack/system": "1.2.435",
52
- "@camstack/types": "1.2.371",
53
- "@camstack/ui-library": "1.2.289",
51
+ "@camstack/system": "1.2.437",
52
+ "@camstack/types": "1.2.373",
53
+ "@camstack/ui-library": "1.2.291",
54
54
  "@fastify/compress": "^9.0.0",
55
55
  "@fastify/cookie": "^11.0.2",
56
56
  "@fastify/cors": "^11.2.0",
@@ -1,317 +0,0 @@
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.MeshEnrollError = void 0;
37
- exports.decodeJoinTokenClaims = decodeJoinTokenClaims;
38
- exports.hubHostFromMeshAddress = hubHostFromMeshAddress;
39
- exports.enrollAgentMesh = enrollAgentMesh;
40
- exports.readJsonFile = readJsonFile;
41
- exports.writeJsonFile0600 = writeJsonFile0600;
42
- exports.persistEnrollment = persistEnrollment;
43
- exports.discardPendingJoinToken = discardPendingJoinToken;
44
- exports.enrollAndPersist = enrollAndPersist;
45
- /**
46
- * The agent's side of mesh mTLS enrollment (Release A).
47
- *
48
- * The operator mints a single-use join token on the hub (System → Cluster →
49
- * Add agent) and pastes it into this agent's setup. Redeeming it:
50
- *
51
- * 1. decode the token's claims WITHOUT trusting them (the agent holds no CA
52
- * key yet): `hubUrl`, `hubMeshAddress`, `caFingerprint`, optional `nodeId`;
53
- * 2. a token pinned to another node id is refused before anything is sent;
54
- * 3. generate this node's own key pair; the private key never leaves it;
55
- * 4. `POST <hubUrl>/api/mesh/enroll` with `{ token, csr }`. The hub's HTTPS
56
- * certificate is NOT the mesh CA, so the TLS peer is not what is pinned:
57
- * 5. the answer is trusted only if its CA certificate hashes to the token's
58
- * `caFingerprint` (a MITM with another CA fails here), the token's ES256
59
- * signature verifies against that CA, and the issued leaf is signed by it,
60
- * carries this node id as CN and matches the private key;
61
- * 6. only then are the material and the hub address persisted (`agent.json`,
62
- * 0600) — the dial port is NOT stored: with a certificate the broker dials
63
- * the TLS port, so the address keeps the host only.
64
- */
65
- const node_crypto_1 = require("node:crypto");
66
- const fs = __importStar(require("node:fs"));
67
- const http = __importStar(require("node:http"));
68
- const https = __importStar(require("node:https"));
69
- const system_1 = require("@camstack/system");
70
- const node_1 = require("@camstack/types/node");
71
- class MeshEnrollError extends Error {
72
- constructor(message) {
73
- super(message);
74
- this.name = 'MeshEnrollError';
75
- }
76
- }
77
- exports.MeshEnrollError = MeshEnrollError;
78
- const ENROLL_PATH = '/api/mesh/enroll';
79
- const ENROLL_TIMEOUT_MS = 15_000;
80
- function base64urlJson(segment) {
81
- return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
82
- }
83
- /** Decode the payload of a compact JWS. Untrusted: nothing here is verified. */
84
- function decodeJoinTokenClaims(token) {
85
- const parts = token.trim().split('.');
86
- if (parts.length !== 3 || parts[1] === undefined) {
87
- throw new MeshEnrollError('join token is malformed (expected a compact JWT)');
88
- }
89
- let payload;
90
- try {
91
- payload = base64urlJson(parts[1]);
92
- }
93
- catch {
94
- throw new MeshEnrollError('join token payload is not valid JSON');
95
- }
96
- if (payload === null || typeof payload !== 'object') {
97
- throw new MeshEnrollError('join token payload is not an object');
98
- }
99
- const hubUrl = Reflect.get(payload, 'hubUrl');
100
- const hubMeshAddress = Reflect.get(payload, 'hubMeshAddress');
101
- const caFingerprint = Reflect.get(payload, 'caFingerprint');
102
- const nodeId = Reflect.get(payload, 'nodeId');
103
- const exp = Reflect.get(payload, 'exp');
104
- if (typeof hubUrl !== 'string' || hubUrl.length === 0) {
105
- throw new MeshEnrollError('join token carries no hubUrl');
106
- }
107
- if (typeof hubMeshAddress !== 'string' || hubMeshAddress.length === 0) {
108
- throw new MeshEnrollError('join token carries no hubMeshAddress');
109
- }
110
- if (typeof caFingerprint !== 'string' || caFingerprint.length === 0) {
111
- throw new MeshEnrollError('join token carries no caFingerprint');
112
- }
113
- if (typeof exp !== 'number')
114
- throw new MeshEnrollError('join token carries no expiry');
115
- return {
116
- hubUrl,
117
- hubMeshAddress,
118
- caFingerprint: normalizeFingerprint(caFingerprint),
119
- ...(typeof nodeId === 'string' && nodeId.length > 0 ? { nodeId } : {}),
120
- exp,
121
- };
122
- }
123
- function normalizeFingerprint(fp) {
124
- return fp.replace(/:/g, '').toLowerCase();
125
- }
126
- /** `host:6000` / `[::1]:6000` / `host` → the host, port dropped. */
127
- function hubHostFromMeshAddress(address) {
128
- const trimmed = address.trim();
129
- const bracketed = /^(\[[^\]]+\])(?::\d+)?$/.exec(trimmed);
130
- if (bracketed?.[1] !== undefined)
131
- return bracketed[1];
132
- const hostPort = /^([^:]+):\d+$/.exec(trimmed);
133
- return hostPort?.[1] ?? trimmed;
134
- }
135
- function compactFingerprint(certPem) {
136
- return (0, node_crypto_1.createHash)('sha256').update(new node_crypto_1.X509Certificate(certPem).raw).digest('hex');
137
- }
138
- /** ES256 compact-JWS signature check against the CA certificate's public key. */
139
- function tokenSignatureValid(token, caCertPem) {
140
- const parts = token.trim().split('.');
141
- if (parts.length !== 3)
142
- return false;
143
- const [header, payload, signature] = parts;
144
- if (header === undefined || payload === undefined || signature === undefined)
145
- return false;
146
- try {
147
- return (0, node_crypto_1.verify)('sha256', Buffer.from(`${header}.${payload}`), { key: new node_crypto_1.X509Certificate(caCertPem).publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(signature, 'base64url'));
148
- }
149
- catch {
150
- return false;
151
- }
152
- }
153
- /** Node `http(s)` POST. The hub's HTTPS leaf is not the mesh CA, so the CA pin below is the trust. */
154
- const nodeEnrollFetch = (url, init) => new Promise((resolve, reject) => {
155
- const target = new URL(url);
156
- const request = (target.protocol === 'http:' ? http : https).request(target, {
157
- method: init.method,
158
- headers: { ...init.headers, 'content-length': Buffer.byteLength(init.body) },
159
- timeout: ENROLL_TIMEOUT_MS,
160
- ...(target.protocol === 'https:' ? { rejectUnauthorized: false } : {}),
161
- }, (res) => {
162
- const chunks = [];
163
- res.on('data', (chunk) => chunks.push(chunk));
164
- res.on('end', () => {
165
- const raw = Buffer.concat(chunks).toString('utf8');
166
- resolve({
167
- status: res.statusCode ?? 0,
168
- json: () => Promise.resolve(JSON.parse(raw)),
169
- });
170
- });
171
- res.on('error', reject);
172
- });
173
- request.on('timeout', () => request.destroy(new Error(`enroll request timed out`)));
174
- request.on('error', reject);
175
- request.end(init.body);
176
- });
177
- function readAnswer(status, body) {
178
- const record = body !== null && typeof body === 'object' ? body : {};
179
- if (status !== 200 || Reflect.get(record, 'ok') !== true) {
180
- const error = Reflect.get(record, 'error');
181
- throw new MeshEnrollError(`hub refused the enrollment (HTTP ${status}): ${typeof error === 'string' ? error : 'no reason given'}`);
182
- }
183
- const cert = Reflect.get(record, 'cert');
184
- const caCert = Reflect.get(record, 'caCert');
185
- if (typeof cert !== 'string' || typeof caCert !== 'string') {
186
- throw new MeshEnrollError('hub answered without a certificate and CA certificate');
187
- }
188
- return { cert, caCert };
189
- }
190
- /**
191
- * Redeem a join token. Throws {@link MeshEnrollError} with an operator-readable
192
- * reason; nothing is persisted here.
193
- */
194
- async function enrollAgentMesh(input) {
195
- const token = input.token.trim();
196
- const claims = decodeJoinTokenClaims(token);
197
- const nowSec = input.nowSec ?? Math.floor(Date.now() / 1000);
198
- if (claims.exp <= nowSec) {
199
- throw new MeshEnrollError('join token has expired — mint a new one on the hub');
200
- }
201
- if (claims.nodeId !== undefined && claims.nodeId !== input.nodeId) {
202
- throw new MeshEnrollError(`join token is for node "${claims.nodeId}", but this agent is "${input.nodeId}"`);
203
- }
204
- const keys = await (0, system_1.generateMeshKeyPair)();
205
- const csr = await (0, system_1.createMeshCsrPem)(keys, input.nodeId);
206
- const keyPem = await (0, system_1.exportMeshPrivateKeyPem)(keys.privateKey);
207
- const url = `${claims.hubUrl.replace(/\/+$/, '')}${ENROLL_PATH}`;
208
- const fetchFn = input.fetchFn ?? nodeEnrollFetch;
209
- let response;
210
- try {
211
- response = await fetchFn(url, {
212
- method: 'POST',
213
- headers: { 'content-type': 'application/json' },
214
- body: JSON.stringify({ token, csr }),
215
- });
216
- }
217
- catch (err) {
218
- throw new MeshEnrollError(`could not reach the hub at ${claims.hubUrl}: ${err instanceof Error ? err.message : String(err)}`);
219
- }
220
- let body;
221
- try {
222
- body = await response.json();
223
- }
224
- catch {
225
- body = null;
226
- }
227
- const answer = readAnswer(response.status, body);
228
- // Trust nothing the hub said until it proves it holds the CA the token names.
229
- let fingerprint;
230
- try {
231
- fingerprint = compactFingerprint(answer.caCert);
232
- }
233
- catch {
234
- throw new MeshEnrollError('hub returned an unreadable CA certificate');
235
- }
236
- if (fingerprint !== claims.caFingerprint) {
237
- throw new MeshEnrollError('the CA certificate returned by the hub does not match the token fingerprint — ' +
238
- 'enrollment aborted (is something between this agent and the hub?)');
239
- }
240
- if (!tokenSignatureValid(token, answer.caCert)) {
241
- throw new MeshEnrollError('join token signature does not verify against the hub CA');
242
- }
243
- assertLeafIsOurs(answer, keyPem, input.nodeId);
244
- return {
245
- meshTls: { certPem: answer.cert, keyPem, caCertPem: answer.caCert },
246
- hubAddress: hubHostFromMeshAddress(claims.hubMeshAddress),
247
- claims,
248
- };
249
- }
250
- function assertLeafIsOurs(answer, keyPem, nodeId) {
251
- let leaf;
252
- try {
253
- leaf = new node_crypto_1.X509Certificate(answer.cert);
254
- }
255
- catch {
256
- throw new MeshEnrollError('hub returned an unreadable node certificate');
257
- }
258
- const ca = new node_crypto_1.X509Certificate(answer.caCert);
259
- if (!leaf.verify(ca.publicKey)) {
260
- throw new MeshEnrollError('the node certificate is not signed by the hub CA');
261
- }
262
- if (!new RegExp(`(?:^|\\n)CN=${escapeRegExp(nodeId)}(?:\\n|$)`).test(leaf.subject)) {
263
- throw new MeshEnrollError(`the node certificate is not issued for "${nodeId}"`);
264
- }
265
- if (!leaf.checkPrivateKey((0, node_crypto_1.createPrivateKey)(keyPem))) {
266
- throw new MeshEnrollError('the node certificate does not match this agent’s private key');
267
- }
268
- }
269
- function escapeRegExp(value) {
270
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
271
- }
272
- function readJsonFile(file) {
273
- if (!fs.existsSync(file))
274
- return {};
275
- try {
276
- const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
277
- return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
278
- ? Object.fromEntries(Object.entries(parsed))
279
- : {};
280
- }
281
- catch {
282
- return {};
283
- }
284
- }
285
- function withoutJoinToken(record) {
286
- return Object.fromEntries(Object.entries(record).filter(([key]) => key !== 'joinToken'));
287
- }
288
- /** Owner-only on every OS (an owner+SYSTEM ACL on Windows, where 0600 is a no-op). */
289
- function writeJsonFile0600(file, data) {
290
- (0, node_1.writeOwnerOnlyFileSync)(file, JSON.stringify(data, null, 2));
291
- }
292
- /**
293
- * Write the enrollment into `agent.json` (0600): `meshTls` + the host-only
294
- * `hubAddress`; a pending `joinToken` is removed. The cluster secret, if any,
295
- * stays — it is still a valid join proof for one release.
296
- */
297
- function persistEnrollment(configPath, enrolled) {
298
- const next = {
299
- ...withoutJoinToken(readJsonFile(configPath)),
300
- hubAddress: enrolled.hubAddress,
301
- meshTls: enrolled.meshTls,
302
- };
303
- writeJsonFile0600(configPath, next);
304
- }
305
- /** Drop a pending `joinToken` from `agent.json` (consumed — or dead). */
306
- function discardPendingJoinToken(configPath) {
307
- const existing = readJsonFile(configPath);
308
- if (!('joinToken' in existing))
309
- return;
310
- writeJsonFile0600(configPath, withoutJoinToken(existing));
311
- }
312
- /** Redeem `token` and persist the result. Throws {@link MeshEnrollError}. */
313
- async function enrollAndPersist(input) {
314
- const enrolled = await enrollAgentMesh(input);
315
- persistEnrollment(input.configPath, enrolled);
316
- return enrolled;
317
- }
@@ -1,140 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createAgentMeshJoin = createAgentMeshJoin;
4
- /**
5
- * The agent's mesh join (D802): authenticate to the hub on every connection,
6
- * before anything else reaches it, and check that the hub is the hub — plus
7
- * the agent's inbound mesh gate so only that proven hub (and this agent's own
8
- * registered children) may call it.
9
- *
10
- * `MeshJoinClient` (in `@camstack/system`) holds the outbound protocol and the
11
- * call gate; `AgentMeshAuthenticator` + `createMeshAccessMiddleware` hold the
12
- * inbound side. This file binds both to the agent's broker lifecycle:
13
- * - the hub connects → a join runs; a settled mutual proof stamps the hub;
14
- * - the hub disconnects → back to `pending` (calls wait for the next join)
15
- * and the hub is forgotten on the inbound gate;
16
- * - any other node disconnects → forgotten on the inbound gate;
17
- * - the hub says "not authenticated" (it restarted and lost us) → join again;
18
- * - the join settles `unavailable` (the hub cannot read its secret yet) →
19
- * retried every {@link REJOIN_INTERVAL_MS} while connected.
20
- */
21
- const system_1 = require("@camstack/system");
22
- const REJOIN_INTERVAL_MS = 15_000;
23
- const HUB_NODE_ID = 'hub';
24
- function nodeIdOf(data) {
25
- if (data === null || typeof data !== 'object')
26
- return undefined;
27
- const node = Reflect.get(data, 'node');
28
- if (node === null || typeof node !== 'object')
29
- return undefined;
30
- const id = Reflect.get(node, 'id');
31
- return typeof id === 'string' ? id : undefined;
32
- }
33
- function createAgentMeshJoin(opts) {
34
- let connected = false;
35
- let rejoinTimer = null;
36
- const clearRejoin = () => {
37
- if (rejoinTimer !== null)
38
- clearTimeout(rejoinTimer);
39
- rejoinTimer = null;
40
- };
41
- const authenticator = new system_1.AgentMeshAuthenticator({
42
- ownNodeId: opts.nodeId,
43
- hubNodeId: HUB_NODE_ID,
44
- ...(opts.isVerifiedPeer !== undefined ? { isVerifiedPeer: opts.isVerifiedPeer } : {}),
45
- ...(opts.isPaired !== undefined ? { isPaired: opts.isPaired } : {}),
46
- hasSecret: () => {
47
- const s = opts.secret();
48
- return s !== null && s.length > 0;
49
- },
50
- onTolerated: (nodeId, action) => {
51
- opts.log?.(`mesh inbound: node "${nodeId}" is calling without having authenticated — ` +
52
- `tolerated while this agent has no cluster secret (first call: ${action})`);
53
- },
54
- // D848: what the hub sent before the join settled is held, then decided — said here.
55
- onHoldReport: (r) => {
56
- const what = `${r.events} hub event(s) and ${r.calls} hub call(s) held ${r.heldForMs} ms before the mesh join settled`;
57
- const overflow = r.overflow > 0 ? `; ${r.overflow} more refused because the hold was full` : '';
58
- opts.log?.(r.outcome === 'delivered'
59
- ? `[mesh-join] ${what}: delivered${overflow}`
60
- : `[mesh-join] ${what}: REFUSED (join ${r.reason})${overflow}`);
61
- },
62
- });
63
- const client = new system_1.MeshJoinClient({
64
- nodeId: opts.nodeId,
65
- secret: opts.secret,
66
- call: opts.call,
67
- hubNodeId: HUB_NODE_ID,
68
- ...(opts.log !== undefined ? { log: opts.log } : {}),
69
- onSettled: (state) => {
70
- authenticator.noteHubJoinState(state);
71
- opts.onState?.(state);
72
- clearRejoin();
73
- if (state === 'unavailable' && connected) {
74
- rejoinTimer = setTimeout(() => void runJoin(), REJOIN_INTERVAL_MS);
75
- rejoinTimer.unref?.();
76
- }
77
- },
78
- });
79
- const runJoin = async () => {
80
- if (!connected)
81
- return;
82
- await client.join();
83
- };
84
- const rejoin = () => {
85
- client.reset();
86
- authenticator.noteHubJoinState('pending');
87
- void runJoin();
88
- };
89
- const joinMiddleware = client.middleware(rejoin);
90
- const eventScope = (0, system_1.createAgentEventScope)({
91
- hubNodeId: HUB_NODE_ID,
92
- ...(opts.log !== undefined ? { log: opts.log } : {}),
93
- });
94
- const inboundMiddleware = (0, system_1.createMeshAccessMiddleware)({
95
- ownNodeId: opts.nodeId,
96
- authenticator,
97
- logger: {
98
- warn: (message, meta) => {
99
- const detail = meta === undefined
100
- ? ''
101
- : ` ${Object.entries(meta)
102
- .map(([k, v]) => `${k}=${String(v)}`)
103
- .join(' ')}`;
104
- opts.log?.(`${message}${detail}`);
105
- },
106
- },
107
- });
108
- return {
109
- client,
110
- authenticator,
111
- middleware: joinMiddleware,
112
- eventScope,
113
- middlewares: [inboundMiddleware, joinMiddleware, eventScope.middleware],
114
- rejoin() {
115
- if (connected)
116
- rejoin();
117
- },
118
- attach(broker) {
119
- broker.localBus.on('$node.connected', (data) => {
120
- if (nodeIdOf(data) !== HUB_NODE_ID)
121
- return;
122
- connected = true;
123
- rejoin();
124
- });
125
- broker.localBus.on('$node.disconnected', (data) => {
126
- const id = nodeIdOf(data);
127
- if (id === undefined)
128
- return;
129
- authenticator.forget(id);
130
- if (id !== HUB_NODE_ID)
131
- return;
132
- connected = false;
133
- clearRejoin();
134
- client.reset();
135
- authenticator.noteHubJoinState('pending');
136
- });
137
- },
138
- stop: clearRejoin,
139
- };
140
- }