@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.
- package/dist/agent/agent-config.js +2 -11
- package/dist/agent/agent-http-auth.js +91 -78
- package/dist/agent/agent-http.js +89 -139
- package/dist/agent/agent-hub-registration.js +31 -58
- package/dist/agent/agent-json-file.js +59 -0
- package/dist/agent/agent-mesh-gate.js +57 -0
- package/dist/agent/agent-node-certificates.js +7 -7
- package/dist/agent/agent-pairing-routes.js +1 -4
- package/dist/agent/agent-pairing-store.js +8 -8
- package/dist/agent/agent-service.js +0 -9
- package/dist/agent/main.js +41 -99
- package/dist/api/core/auth.router.js +11 -0
- package/dist/api/core/cap-providers.js +3 -1
- package/dist/api/core/certificates.router.js +2 -2
- package/dist/api/core/server-name.js +64 -0
- package/dist/api/oauth2/consent-page.js +22 -31
- package/dist/api/oauth2/oauth2-routes.js +5 -0
- package/dist/api/trpc/trpc.context.js +3 -3
- package/dist/api/trpc/trpc.router.js +29 -2
- package/dist/auth/app-confirm-page.js +38 -24
- package/dist/auth/app-confirm-routes.js +8 -1
- package/dist/auth/app-page-shell.js +123 -0
- package/dist/auth/app-page-strings.js +157 -0
- package/dist/auth/app-passkey-page.js +47 -26
- package/dist/auth/app-passkey-routes.js +9 -2
- package/dist/auth/app-refused-route.js +43 -0
- package/dist/auth/session-connections.js +136 -0
- package/dist/auth/sso-finish-app.js +7 -1
- package/dist/boot/boot-config.js +7 -5
- package/dist/core/agent/agent-registry.service.js +0 -10
- package/dist/core/moleculer/hub-mesh-guard.js +30 -101
- package/dist/core/moleculer/moleculer.service.js +8 -32
- package/dist/main.js +33 -2
- package/package.json +9 -9
- package/dist/agent/agent-mesh-enroll.js +0 -317
- package/dist/agent/agent-mesh-join.js +0 -140
- 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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
195
|
-
//
|
|
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
|
|
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
|
-
//
|
|
296
|
-
|
|
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.
|
|
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.
|
|
42
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
43
|
-
"@camstack/addon-auth": "1.2.
|
|
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.
|
|
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.
|
|
49
|
+
"@camstack/develop": "0.1.11",
|
|
50
50
|
"@camstack/sdk": "1.2.131",
|
|
51
|
-
"@camstack/system": "1.2.
|
|
52
|
-
"@camstack/types": "1.2.
|
|
53
|
-
"@camstack/ui-library": "1.2.
|
|
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
|
-
}
|