@camstack/server 1.2.488 → 1.2.490
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 +21 -0
- package/dist/agent/agent-http.js +54 -5
- package/dist/agent/agent-mesh-enroll.js +318 -0
- package/dist/agent/main.js +36 -2
- package/dist/api/core/auth-revoke-credentials.procedures.js +124 -0
- package/dist/api/core/auth.router.js +2 -0
- package/dist/api/core/cap-providers.js +8 -0
- package/dist/api/trpc/core-cap-bridge.js +45 -15
- package/dist/core/moleculer/moleculer.service.js +24 -0
- package/package.json +7 -7
|
@@ -81,6 +81,20 @@ function ensurePersistedNodeId(configPath, dataDir) {
|
|
|
81
81
|
}
|
|
82
82
|
return id;
|
|
83
83
|
}
|
|
84
|
+
function parseMeshTls(value) {
|
|
85
|
+
if (value === null || typeof value !== 'object')
|
|
86
|
+
return undefined;
|
|
87
|
+
const certPem = Reflect.get(value, 'certPem');
|
|
88
|
+
const keyPem = Reflect.get(value, 'keyPem');
|
|
89
|
+
const caCertPem = Reflect.get(value, 'caCertPem');
|
|
90
|
+
if (typeof certPem !== 'string' || certPem.length === 0)
|
|
91
|
+
return undefined;
|
|
92
|
+
if (typeof keyPem !== 'string' || keyPem.length === 0)
|
|
93
|
+
return undefined;
|
|
94
|
+
if (typeof caCertPem !== 'string' || caCertPem.length === 0)
|
|
95
|
+
return undefined;
|
|
96
|
+
return { certPem, keyPem, caCertPem };
|
|
97
|
+
}
|
|
84
98
|
function loadAgentConfig(configPath, dataDirOverride) {
|
|
85
99
|
const filePath = configPath ?? 'agent.json';
|
|
86
100
|
const dataDir = dataDirOverride ?? process.env.CAMSTACK_DATA ?? './camstack-data';
|
|
@@ -100,12 +114,17 @@ function loadAgentConfig(configPath, dataDirOverride) {
|
|
|
100
114
|
let fileHubAddress;
|
|
101
115
|
let fileName;
|
|
102
116
|
let fileSecret;
|
|
117
|
+
let fileMeshTls;
|
|
118
|
+
let filePendingJoinToken;
|
|
103
119
|
if (fs.existsSync(configFullPath)) {
|
|
104
120
|
try {
|
|
105
121
|
const raw = JSON.parse(fs.readFileSync(configFullPath, 'utf-8'));
|
|
106
122
|
fileHubAddress = typeof raw.hubAddress === 'string' ? raw.hubAddress : undefined;
|
|
107
123
|
fileName = typeof raw.name === 'string' ? raw.name : undefined;
|
|
108
124
|
fileSecret = typeof raw.secret === 'string' ? raw.secret : undefined;
|
|
125
|
+
fileMeshTls = parseMeshTls(raw.meshTls);
|
|
126
|
+
filePendingJoinToken =
|
|
127
|
+
typeof raw.joinToken === 'string' && raw.joinToken.length > 0 ? raw.joinToken : undefined;
|
|
109
128
|
}
|
|
110
129
|
catch {
|
|
111
130
|
/* corrupt config */
|
|
@@ -123,6 +142,8 @@ function loadAgentConfig(configPath, dataDirOverride) {
|
|
|
123
142
|
addonsDir: path.resolve(resolvedDataDir, 'addons'),
|
|
124
143
|
logLevel: process.env.CAMSTACK_LOG_LEVEL ?? 'info',
|
|
125
144
|
secret: effectiveSecret,
|
|
145
|
+
...(fileMeshTls !== undefined ? { meshTls: fileMeshTls } : {}),
|
|
146
|
+
...(filePendingJoinToken !== undefined ? { pendingJoinToken: filePendingJoinToken } : {}),
|
|
126
147
|
configPath: configFullPath,
|
|
127
148
|
statusPort: Number(process.env.CAMSTACK_STATUS_PORT) || 4444,
|
|
128
149
|
};
|
package/dist/agent/agent-http.js
CHANGED
|
@@ -54,6 +54,7 @@ const fs = __importStar(require("node:fs"));
|
|
|
54
54
|
const path = __importStar(require("node:path"));
|
|
55
55
|
const fastify_1 = __importDefault(require("fastify"));
|
|
56
56
|
const agent_http_auth_js_1 = require("./agent-http-auth.js");
|
|
57
|
+
const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
|
|
57
58
|
const agent_update_routes_js_1 = require("./agent-update-routes.js");
|
|
58
59
|
const derive_hub_url_js_1 = require("./derive-hub-url.js");
|
|
59
60
|
Object.defineProperty(exports, "pickIpv4", { enumerable: true, get: function () { return derive_hub_url_js_1.pickIpv4; } });
|
|
@@ -211,8 +212,12 @@ function getEffectiveConfig(configPath, nodeId) {
|
|
|
211
212
|
name: typeof raw.name === 'string' ? raw.name : nodeId,
|
|
212
213
|
hubAddress: typeof raw.hubAddress === 'string' ? raw.hubAddress : null,
|
|
213
214
|
hasSecret: typeof raw.secret === 'string' && raw.secret.length > 0,
|
|
215
|
+
hasMeshTls: raw.meshTls !== null && typeof raw.meshTls === 'object',
|
|
214
216
|
};
|
|
215
217
|
}
|
|
218
|
+
/** Persisted fields that are credentials: never echoed by `GET /api/agent/config`. */
|
|
219
|
+
const NEVER_ECHOED_CONFIG_KEYS = new Set(['secret', 'meshTls', 'joinToken']);
|
|
220
|
+
const defaultRedeemJoinToken = (token, nodeId) => (0, agent_mesh_enroll_js_1.enrollAgentMesh)({ token, nodeId });
|
|
216
221
|
// ---------------------------------------------------------------------------
|
|
217
222
|
// Factory
|
|
218
223
|
// ---------------------------------------------------------------------------
|
|
@@ -288,6 +293,11 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
288
293
|
const hubConnectionState = config.getHubConnectionState
|
|
289
294
|
? config.getHubConnectionState()
|
|
290
295
|
: deriveHubConnectionState(nodes, discoveryMode);
|
|
296
|
+
const meshTransport = config.getMeshTransport
|
|
297
|
+
? config.getMeshTransport()
|
|
298
|
+
: eff.hasMeshTls
|
|
299
|
+
? 'tls'
|
|
300
|
+
: 'clear';
|
|
291
301
|
// The address the agent actually reaches the hub at (from the Moleculer
|
|
292
302
|
// registry) — lets the UI show the real endpoint even under UDP discovery
|
|
293
303
|
// where `eff.hubAddress` is null.
|
|
@@ -308,6 +318,7 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
308
318
|
hubConnectionState,
|
|
309
319
|
discoveryMode,
|
|
310
320
|
hasSecret: eff.hasSecret,
|
|
321
|
+
meshTransport,
|
|
311
322
|
discoveredNodes,
|
|
312
323
|
};
|
|
313
324
|
}
|
|
@@ -322,6 +333,7 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
322
333
|
hubConnectionState,
|
|
323
334
|
discoveryMode,
|
|
324
335
|
hasSecret: eff.hasSecret,
|
|
336
|
+
meshTransport,
|
|
325
337
|
discoveredNodes,
|
|
326
338
|
addons: [],
|
|
327
339
|
localIps: [],
|
|
@@ -360,21 +372,54 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
360
372
|
name: eff.name,
|
|
361
373
|
hubAddress: eff.hubAddress,
|
|
362
374
|
hasSecret: eff.hasSecret,
|
|
375
|
+
hasMeshTls: eff.hasMeshTls,
|
|
363
376
|
configPath: config.configPath,
|
|
364
377
|
dataDir: config.dataDir,
|
|
365
|
-
// Include all persisted fields except
|
|
366
|
-
...Object.fromEntries(Object.entries(persisted).filter(([k]) => k
|
|
378
|
+
// Include all persisted fields except credentials (secret, mesh key, pending token)
|
|
379
|
+
...Object.fromEntries(Object.entries(persisted).filter(([k]) => !NEVER_ECHOED_CONFIG_KEYS.has(k))),
|
|
367
380
|
};
|
|
368
381
|
});
|
|
369
382
|
// -- Config write (merge-patch + persist) ---------------------------
|
|
370
383
|
// Fastify (not Express) natively awaits async route handlers and serializes
|
|
371
384
|
// the returned value / catches rejections, so an async handler is correct here.
|
|
372
385
|
// eslint-disable-next-line oxc/no-async-endpoint-handlers
|
|
373
|
-
app.post('/api/agent/config', async (req) => {
|
|
374
|
-
|
|
386
|
+
app.post('/api/agent/config', async (req, reply) => {
|
|
387
|
+
// `joinToken` is redeemed, never stored; `meshTls` only ever comes from an
|
|
388
|
+
// enrollment (or is cleared via `clearMeshTls`), never pasted as PEM.
|
|
389
|
+
const body = req.body ?? {};
|
|
390
|
+
const joinToken = body['joinToken'];
|
|
391
|
+
const clearMeshTls = body['clearMeshTls'] === true;
|
|
392
|
+
const patch = Object.fromEntries(Object.entries(body).filter(([k]) => k !== 'joinToken' && k !== 'meshTls' && k !== 'clearMeshTls'));
|
|
375
393
|
const existing = readConfigFile(config.configPath);
|
|
394
|
+
// Join-token enrollment (mesh mTLS Release A): the hub address comes from
|
|
395
|
+
// the token and wins over a `hubAddress` posted in the same form. Nothing is
|
|
396
|
+
// written unless the whole enrollment verified.
|
|
397
|
+
let enrolled = null;
|
|
398
|
+
if (typeof joinToken === 'string' && joinToken.trim().length > 0) {
|
|
399
|
+
try {
|
|
400
|
+
enrolled = await (config.redeemJoinToken ?? defaultRedeemJoinToken)(joinToken, config.nodeId);
|
|
401
|
+
}
|
|
402
|
+
catch (err) {
|
|
403
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
404
|
+
console.error(`[Agent] join-token enrollment failed: ${message}`);
|
|
405
|
+
return reply
|
|
406
|
+
.status(err instanceof agent_mesh_enroll_js_1.MeshEnrollError ? 400 : 502)
|
|
407
|
+
.send({ success: false, error: message });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
376
410
|
const merged = { ...existing, ...patch };
|
|
411
|
+
if (enrolled !== null) {
|
|
412
|
+
merged['hubAddress'] = enrolled.hubAddress;
|
|
413
|
+
merged['meshTls'] = enrolled.meshTls;
|
|
414
|
+
}
|
|
415
|
+
else if (clearMeshTls) {
|
|
416
|
+
delete merged['meshTls'];
|
|
417
|
+
}
|
|
377
418
|
writeConfigFile(config.configPath, merged);
|
|
419
|
+
if (enrolled !== null) {
|
|
420
|
+
console.log(`[Agent] Enrolled in the mesh CA via join token — hub ${enrolled.hubAddress}, ` +
|
|
421
|
+
'mutual TLS on next reconnect');
|
|
422
|
+
}
|
|
378
423
|
// Apply name change immediately (no reconnect needed)
|
|
379
424
|
if (typeof patch.name === 'string' && patch.name.trim()) {
|
|
380
425
|
try {
|
|
@@ -390,10 +435,14 @@ async function createAgentHttpServer(getBroker, config) {
|
|
|
390
435
|
// `patch.hubAddress !== undefined` was always true and produced
|
|
391
436
|
// false-positive "Restart required" prompts on no-op saves.
|
|
392
437
|
const reconnectRelevant = ['hubAddress', 'secret'];
|
|
393
|
-
const needsReconnect =
|
|
438
|
+
const needsReconnect = enrolled !== null ||
|
|
439
|
+
clearMeshTls ||
|
|
440
|
+
reconnectRelevant.some((k) => Object.prototype.hasOwnProperty.call(patch, k) && patch[k] !== existing[k]);
|
|
394
441
|
return {
|
|
395
442
|
success: true,
|
|
396
443
|
restartRequired: needsReconnect,
|
|
444
|
+
...(enrolled !== null ? { enrolled: true } : {}),
|
|
445
|
+
...(clearMeshTls ? { clearedMeshTls: true } : {}),
|
|
397
446
|
};
|
|
398
447
|
});
|
|
399
448
|
// -- Agent reconnect (applies new hub/secret config) ----------------
|
|
@@ -0,0 +1,318 @@
|
|
|
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.persistEnrollment = persistEnrollment;
|
|
41
|
+
exports.discardPendingJoinToken = discardPendingJoinToken;
|
|
42
|
+
exports.enrollAndPersist = enrollAndPersist;
|
|
43
|
+
/**
|
|
44
|
+
* The agent's side of mesh mTLS enrollment (Release A).
|
|
45
|
+
*
|
|
46
|
+
* The operator mints a single-use join token on the hub (System → Cluster →
|
|
47
|
+
* Add agent) and pastes it into this agent's setup. Redeeming it:
|
|
48
|
+
*
|
|
49
|
+
* 1. decode the token's claims WITHOUT trusting them (the agent holds no CA
|
|
50
|
+
* key yet): `hubUrl`, `hubMeshAddress`, `caFingerprint`, optional `nodeId`;
|
|
51
|
+
* 2. a token pinned to another node id is refused before anything is sent;
|
|
52
|
+
* 3. generate this node's own key pair; the private key never leaves it;
|
|
53
|
+
* 4. `POST <hubUrl>/api/mesh/enroll` with `{ token, csr }`. The hub's HTTPS
|
|
54
|
+
* certificate is NOT the mesh CA, so the TLS peer is not what is pinned:
|
|
55
|
+
* 5. the answer is trusted only if its CA certificate hashes to the token's
|
|
56
|
+
* `caFingerprint` (a MITM with another CA fails here), the token's ES256
|
|
57
|
+
* signature verifies against that CA, and the issued leaf is signed by it,
|
|
58
|
+
* carries this node id as CN and matches the private key;
|
|
59
|
+
* 6. only then are the material and the hub address persisted (`agent.json`,
|
|
60
|
+
* 0600) — the dial port is NOT stored: with a certificate the broker dials
|
|
61
|
+
* the TLS port, so the address keeps the host only.
|
|
62
|
+
*/
|
|
63
|
+
const node_crypto_1 = require("node:crypto");
|
|
64
|
+
const fs = __importStar(require("node:fs"));
|
|
65
|
+
const http = __importStar(require("node:http"));
|
|
66
|
+
const https = __importStar(require("node:https"));
|
|
67
|
+
const path = __importStar(require("node:path"));
|
|
68
|
+
const system_1 = require("@camstack/system");
|
|
69
|
+
class MeshEnrollError extends Error {
|
|
70
|
+
constructor(message) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = 'MeshEnrollError';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
exports.MeshEnrollError = MeshEnrollError;
|
|
76
|
+
const ENROLL_PATH = '/api/mesh/enroll';
|
|
77
|
+
const ENROLL_TIMEOUT_MS = 15_000;
|
|
78
|
+
function base64urlJson(segment) {
|
|
79
|
+
return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
|
|
80
|
+
}
|
|
81
|
+
/** Decode the payload of a compact JWS. Untrusted: nothing here is verified. */
|
|
82
|
+
function decodeJoinTokenClaims(token) {
|
|
83
|
+
const parts = token.trim().split('.');
|
|
84
|
+
if (parts.length !== 3 || parts[1] === undefined) {
|
|
85
|
+
throw new MeshEnrollError('join token is malformed (expected a compact JWT)');
|
|
86
|
+
}
|
|
87
|
+
let payload;
|
|
88
|
+
try {
|
|
89
|
+
payload = base64urlJson(parts[1]);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new MeshEnrollError('join token payload is not valid JSON');
|
|
93
|
+
}
|
|
94
|
+
if (payload === null || typeof payload !== 'object') {
|
|
95
|
+
throw new MeshEnrollError('join token payload is not an object');
|
|
96
|
+
}
|
|
97
|
+
const hubUrl = Reflect.get(payload, 'hubUrl');
|
|
98
|
+
const hubMeshAddress = Reflect.get(payload, 'hubMeshAddress');
|
|
99
|
+
const caFingerprint = Reflect.get(payload, 'caFingerprint');
|
|
100
|
+
const nodeId = Reflect.get(payload, 'nodeId');
|
|
101
|
+
const exp = Reflect.get(payload, 'exp');
|
|
102
|
+
if (typeof hubUrl !== 'string' || hubUrl.length === 0) {
|
|
103
|
+
throw new MeshEnrollError('join token carries no hubUrl');
|
|
104
|
+
}
|
|
105
|
+
if (typeof hubMeshAddress !== 'string' || hubMeshAddress.length === 0) {
|
|
106
|
+
throw new MeshEnrollError('join token carries no hubMeshAddress');
|
|
107
|
+
}
|
|
108
|
+
if (typeof caFingerprint !== 'string' || caFingerprint.length === 0) {
|
|
109
|
+
throw new MeshEnrollError('join token carries no caFingerprint');
|
|
110
|
+
}
|
|
111
|
+
if (typeof exp !== 'number')
|
|
112
|
+
throw new MeshEnrollError('join token carries no expiry');
|
|
113
|
+
return {
|
|
114
|
+
hubUrl,
|
|
115
|
+
hubMeshAddress,
|
|
116
|
+
caFingerprint: normalizeFingerprint(caFingerprint),
|
|
117
|
+
...(typeof nodeId === 'string' && nodeId.length > 0 ? { nodeId } : {}),
|
|
118
|
+
exp,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function normalizeFingerprint(fp) {
|
|
122
|
+
return fp.replace(/:/g, '').toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
/** `host:6000` / `[::1]:6000` / `host` → the host, port dropped. */
|
|
125
|
+
function hubHostFromMeshAddress(address) {
|
|
126
|
+
const trimmed = address.trim();
|
|
127
|
+
const bracketed = /^(\[[^\]]+\])(?::\d+)?$/.exec(trimmed);
|
|
128
|
+
if (bracketed?.[1] !== undefined)
|
|
129
|
+
return bracketed[1];
|
|
130
|
+
const hostPort = /^([^:]+):\d+$/.exec(trimmed);
|
|
131
|
+
return hostPort?.[1] ?? trimmed;
|
|
132
|
+
}
|
|
133
|
+
function compactFingerprint(certPem) {
|
|
134
|
+
return (0, node_crypto_1.createHash)('sha256').update(new node_crypto_1.X509Certificate(certPem).raw).digest('hex');
|
|
135
|
+
}
|
|
136
|
+
/** ES256 compact-JWS signature check against the CA certificate's public key. */
|
|
137
|
+
function tokenSignatureValid(token, caCertPem) {
|
|
138
|
+
const parts = token.trim().split('.');
|
|
139
|
+
if (parts.length !== 3)
|
|
140
|
+
return false;
|
|
141
|
+
const [header, payload, signature] = parts;
|
|
142
|
+
if (header === undefined || payload === undefined || signature === undefined)
|
|
143
|
+
return false;
|
|
144
|
+
try {
|
|
145
|
+
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'));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Node `http(s)` POST. The hub's HTTPS leaf is not the mesh CA, so the CA pin below is the trust. */
|
|
152
|
+
const nodeEnrollFetch = (url, init) => new Promise((resolve, reject) => {
|
|
153
|
+
const target = new URL(url);
|
|
154
|
+
const request = (target.protocol === 'http:' ? http : https).request(target, {
|
|
155
|
+
method: init.method,
|
|
156
|
+
headers: { ...init.headers, 'content-length': Buffer.byteLength(init.body) },
|
|
157
|
+
timeout: ENROLL_TIMEOUT_MS,
|
|
158
|
+
...(target.protocol === 'https:' ? { rejectUnauthorized: false } : {}),
|
|
159
|
+
}, (res) => {
|
|
160
|
+
const chunks = [];
|
|
161
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
162
|
+
res.on('end', () => {
|
|
163
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
164
|
+
resolve({
|
|
165
|
+
status: res.statusCode ?? 0,
|
|
166
|
+
json: () => Promise.resolve(JSON.parse(raw)),
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
res.on('error', reject);
|
|
170
|
+
});
|
|
171
|
+
request.on('timeout', () => request.destroy(new Error(`enroll request timed out`)));
|
|
172
|
+
request.on('error', reject);
|
|
173
|
+
request.end(init.body);
|
|
174
|
+
});
|
|
175
|
+
function readAnswer(status, body) {
|
|
176
|
+
const record = body !== null && typeof body === 'object' ? body : {};
|
|
177
|
+
if (status !== 200 || Reflect.get(record, 'ok') !== true) {
|
|
178
|
+
const error = Reflect.get(record, 'error');
|
|
179
|
+
throw new MeshEnrollError(`hub refused the enrollment (HTTP ${status}): ${typeof error === 'string' ? error : 'no reason given'}`);
|
|
180
|
+
}
|
|
181
|
+
const cert = Reflect.get(record, 'cert');
|
|
182
|
+
const caCert = Reflect.get(record, 'caCert');
|
|
183
|
+
if (typeof cert !== 'string' || typeof caCert !== 'string') {
|
|
184
|
+
throw new MeshEnrollError('hub answered without a certificate and CA certificate');
|
|
185
|
+
}
|
|
186
|
+
return { cert, caCert };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Redeem a join token. Throws {@link MeshEnrollError} with an operator-readable
|
|
190
|
+
* reason; nothing is persisted here.
|
|
191
|
+
*/
|
|
192
|
+
async function enrollAgentMesh(input) {
|
|
193
|
+
const token = input.token.trim();
|
|
194
|
+
const claims = decodeJoinTokenClaims(token);
|
|
195
|
+
const nowSec = input.nowSec ?? Math.floor(Date.now() / 1000);
|
|
196
|
+
if (claims.exp <= nowSec) {
|
|
197
|
+
throw new MeshEnrollError('join token has expired — mint a new one on the hub');
|
|
198
|
+
}
|
|
199
|
+
if (claims.nodeId !== undefined && claims.nodeId !== input.nodeId) {
|
|
200
|
+
throw new MeshEnrollError(`join token is for node "${claims.nodeId}", but this agent is "${input.nodeId}"`);
|
|
201
|
+
}
|
|
202
|
+
const keys = await (0, system_1.generateMeshKeyPair)();
|
|
203
|
+
const csr = await (0, system_1.createMeshCsrPem)(keys, input.nodeId);
|
|
204
|
+
const keyPem = await (0, system_1.exportMeshPrivateKeyPem)(keys.privateKey);
|
|
205
|
+
const url = `${claims.hubUrl.replace(/\/+$/, '')}${ENROLL_PATH}`;
|
|
206
|
+
const fetchFn = input.fetchFn ?? nodeEnrollFetch;
|
|
207
|
+
let response;
|
|
208
|
+
try {
|
|
209
|
+
response = await fetchFn(url, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'content-type': 'application/json' },
|
|
212
|
+
body: JSON.stringify({ token, csr }),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
throw new MeshEnrollError(`could not reach the hub at ${claims.hubUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
217
|
+
}
|
|
218
|
+
let body;
|
|
219
|
+
try {
|
|
220
|
+
body = await response.json();
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
body = null;
|
|
224
|
+
}
|
|
225
|
+
const answer = readAnswer(response.status, body);
|
|
226
|
+
// Trust nothing the hub said until it proves it holds the CA the token names.
|
|
227
|
+
let fingerprint;
|
|
228
|
+
try {
|
|
229
|
+
fingerprint = compactFingerprint(answer.caCert);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
throw new MeshEnrollError('hub returned an unreadable CA certificate');
|
|
233
|
+
}
|
|
234
|
+
if (fingerprint !== claims.caFingerprint) {
|
|
235
|
+
throw new MeshEnrollError('the CA certificate returned by the hub does not match the token fingerprint — ' +
|
|
236
|
+
'enrollment aborted (is something between this agent and the hub?)');
|
|
237
|
+
}
|
|
238
|
+
if (!tokenSignatureValid(token, answer.caCert)) {
|
|
239
|
+
throw new MeshEnrollError('join token signature does not verify against the hub CA');
|
|
240
|
+
}
|
|
241
|
+
assertLeafIsOurs(answer, keyPem, input.nodeId);
|
|
242
|
+
return {
|
|
243
|
+
meshTls: { certPem: answer.cert, keyPem, caCertPem: answer.caCert },
|
|
244
|
+
hubAddress: hubHostFromMeshAddress(claims.hubMeshAddress),
|
|
245
|
+
claims,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function assertLeafIsOurs(answer, keyPem, nodeId) {
|
|
249
|
+
let leaf;
|
|
250
|
+
try {
|
|
251
|
+
leaf = new node_crypto_1.X509Certificate(answer.cert);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
throw new MeshEnrollError('hub returned an unreadable node certificate');
|
|
255
|
+
}
|
|
256
|
+
const ca = new node_crypto_1.X509Certificate(answer.caCert);
|
|
257
|
+
if (!leaf.verify(ca.publicKey)) {
|
|
258
|
+
throw new MeshEnrollError('the node certificate is not signed by the hub CA');
|
|
259
|
+
}
|
|
260
|
+
if (!new RegExp(`(?:^|\\n)CN=${escapeRegExp(nodeId)}(?:\\n|$)`).test(leaf.subject)) {
|
|
261
|
+
throw new MeshEnrollError(`the node certificate is not issued for "${nodeId}"`);
|
|
262
|
+
}
|
|
263
|
+
if (!leaf.checkPrivateKey((0, node_crypto_1.createPrivateKey)(keyPem))) {
|
|
264
|
+
throw new MeshEnrollError('the node certificate does not match this agent’s private key');
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function escapeRegExp(value) {
|
|
268
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
269
|
+
}
|
|
270
|
+
function readJsonFile(file) {
|
|
271
|
+
if (!fs.existsSync(file))
|
|
272
|
+
return {};
|
|
273
|
+
try {
|
|
274
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
275
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
276
|
+
? Object.fromEntries(Object.entries(parsed))
|
|
277
|
+
: {};
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return {};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function withoutJoinToken(record) {
|
|
284
|
+
return Object.fromEntries(Object.entries(record).filter(([key]) => key !== 'joinToken'));
|
|
285
|
+
}
|
|
286
|
+
function writeJsonFile0600(file, data) {
|
|
287
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
288
|
+
const tmp = `${file}.tmp`;
|
|
289
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
290
|
+
fs.renameSync(tmp, file);
|
|
291
|
+
fs.chmodSync(file, 0o600);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Write the enrollment into `agent.json` (0600): `meshTls` + the host-only
|
|
295
|
+
* `hubAddress`; a pending `joinToken` is removed. The cluster secret, if any,
|
|
296
|
+
* stays — it is still a valid join proof for one release.
|
|
297
|
+
*/
|
|
298
|
+
function persistEnrollment(configPath, enrolled) {
|
|
299
|
+
const next = {
|
|
300
|
+
...withoutJoinToken(readJsonFile(configPath)),
|
|
301
|
+
hubAddress: enrolled.hubAddress,
|
|
302
|
+
meshTls: enrolled.meshTls,
|
|
303
|
+
};
|
|
304
|
+
writeJsonFile0600(configPath, next);
|
|
305
|
+
}
|
|
306
|
+
/** Drop a pending `joinToken` from `agent.json` (consumed — or dead). */
|
|
307
|
+
function discardPendingJoinToken(configPath) {
|
|
308
|
+
const existing = readJsonFile(configPath);
|
|
309
|
+
if (!('joinToken' in existing))
|
|
310
|
+
return;
|
|
311
|
+
writeJsonFile0600(configPath, withoutJoinToken(existing));
|
|
312
|
+
}
|
|
313
|
+
/** Redeem `token` and persist the result. Throws {@link MeshEnrollError}. */
|
|
314
|
+
async function enrollAndPersist(input) {
|
|
315
|
+
const enrolled = await enrollAgentMesh(input);
|
|
316
|
+
persistEnrollment(input.configPath, enrolled);
|
|
317
|
+
return enrolled;
|
|
318
|
+
}
|
package/dist/agent/main.js
CHANGED
|
@@ -46,6 +46,7 @@ const addon_scan_js_1 = require("./addon-scan.js");
|
|
|
46
46
|
const agent_addon_updates_js_1 = require("./agent-addon-updates.js");
|
|
47
47
|
const agent_mesh_join_js_1 = require("./agent-mesh-join.js");
|
|
48
48
|
const agent_config_js_1 = require("./agent-config.js");
|
|
49
|
+
const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
|
|
49
50
|
const agent_group_runner_js_1 = require("./agent-group-runner.js");
|
|
50
51
|
const agent_http_js_1 = require("./agent-http.js");
|
|
51
52
|
const agent_infra_capabilities_js_1 = require("./agent-infra-capabilities.js");
|
|
@@ -78,8 +79,32 @@ const DATA_NODEID_CAPS = new Set(types_1.ALL_CAPABILITY_DEFINITIONS.filter((c) =
|
|
|
78
79
|
// ---------------------------------------------------------------------------
|
|
79
80
|
// Main
|
|
80
81
|
// ---------------------------------------------------------------------------
|
|
82
|
+
/**
|
|
83
|
+
* Load `agent.json`, first redeeming a join token the setup window left there
|
|
84
|
+
* (mesh mTLS Release A — the desktop setup runs before the agent does). A token
|
|
85
|
+
* is single-use, so it is dropped whether or not the hub accepted it; a refusal
|
|
86
|
+
* is logged loudly and the agent still boots (clear, or with the cluster secret).
|
|
87
|
+
*/
|
|
88
|
+
async function loadAgentConfigRedeemingJoinToken(configPath) {
|
|
89
|
+
const loaded = (0, agent_config_js_1.loadAgentConfig)(configPath);
|
|
90
|
+
if (loaded.pendingJoinToken === undefined)
|
|
91
|
+
return loaded;
|
|
92
|
+
try {
|
|
93
|
+
const enrolled = await (0, agent_mesh_enroll_js_1.enrollAndPersist)({
|
|
94
|
+
token: loaded.pendingJoinToken,
|
|
95
|
+
nodeId: loaded.nodeId,
|
|
96
|
+
configPath: loaded.configPath,
|
|
97
|
+
});
|
|
98
|
+
console.log(`[Agent] Enrolled in the mesh CA (hub ${enrolled.hubAddress}) — dialing over mutual TLS`);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
console.error(`[Agent] Join-token enrollment failed — staying on the previous transport: ${(0, types_1.errMsg)(err)}`);
|
|
102
|
+
(0, agent_mesh_enroll_js_1.discardPendingJoinToken)(loaded.configPath);
|
|
103
|
+
}
|
|
104
|
+
return (0, agent_config_js_1.loadAgentConfig)(configPath, loaded.dataDir);
|
|
105
|
+
}
|
|
81
106
|
async function startAgent(configPath) {
|
|
82
|
-
const config =
|
|
107
|
+
const config = await loadAgentConfigRedeemingJoinToken(configPath);
|
|
83
108
|
// Derive CAMSTACK_HUB_URL from the configured hub address so remote agents
|
|
84
109
|
// no longer need a second, redundant env var set by hand (the cross-node
|
|
85
110
|
// source trap). An explicit operator value ALWAYS wins — capture whether it
|
|
@@ -177,12 +202,17 @@ async function startAgent(configPath) {
|
|
|
177
202
|
}
|
|
178
203
|
},
|
|
179
204
|
});
|
|
205
|
+
// Mesh mTLS (Release A): with a certificate the broker dials the hub's TLS
|
|
206
|
+
// port; without one it keeps dialing clear 6000.
|
|
207
|
+
let currentMeshTls = config.meshTls ?? null;
|
|
208
|
+
console.log(`[Agent] Mesh transport: ${currentMeshTls !== null ? 'tls' : 'clear'}`);
|
|
180
209
|
let broker = (0, system_1.createBroker)({
|
|
181
210
|
nodeID: config.nodeId,
|
|
182
211
|
mode: 'agent',
|
|
183
212
|
hubAddress: config.hubAddress,
|
|
184
213
|
logLevel: config.logLevel,
|
|
185
214
|
middlewares: meshJoin.middlewares,
|
|
215
|
+
...(currentMeshTls !== null ? { meshTls: currentMeshTls } : {}),
|
|
186
216
|
});
|
|
187
217
|
meshJoin.attach(broker);
|
|
188
218
|
/**
|
|
@@ -200,7 +230,8 @@ async function startAgent(configPath) {
|
|
|
200
230
|
}
|
|
201
231
|
// Reload config from file (UI may have written new hubAddress/secret)
|
|
202
232
|
const fresh = (0, agent_config_js_1.loadAgentConfig)(undefined, config.dataDir);
|
|
203
|
-
console.log(`[Agent] New config: hub=${fresh.hubAddress ?? 'discovery'}, secret=${fresh.secret ? 'yes' : 'none'}`
|
|
233
|
+
console.log(`[Agent] New config: hub=${fresh.hubAddress ?? 'discovery'}, secret=${fresh.secret ? 'yes' : 'none'}, ` +
|
|
234
|
+
`mesh=${fresh.meshTls !== undefined ? 'tls' : 'clear'}`);
|
|
204
235
|
// Re-derive CAMSTACK_HUB_URL for the discovery-mode → dashboard-configured
|
|
205
236
|
// flow (config file freshly written a hubAddress). Explicit operator values
|
|
206
237
|
// still win via the boot-captured `hubUrlWasExplicit` flag.
|
|
@@ -214,12 +245,14 @@ async function startAgent(configPath) {
|
|
|
214
245
|
console.log(`[Agent] Derived CAMSTACK_HUB_URL=${freshHubUrl} from hub address "${fresh.hubAddress}"`);
|
|
215
246
|
}
|
|
216
247
|
currentSecret = fresh.secret ?? null;
|
|
248
|
+
currentMeshTls = fresh.meshTls ?? null;
|
|
217
249
|
broker = (0, system_1.createBroker)({
|
|
218
250
|
nodeID: config.nodeId,
|
|
219
251
|
mode: 'agent',
|
|
220
252
|
hubAddress: fresh.hubAddress,
|
|
221
253
|
logLevel: fresh.logLevel,
|
|
222
254
|
middlewares: meshJoin.middlewares,
|
|
255
|
+
...(currentMeshTls !== null ? { meshTls: currentMeshTls } : {}),
|
|
223
256
|
});
|
|
224
257
|
meshJoin.attach(broker);
|
|
225
258
|
// The boot-time `$node.connected` listeners belong to the old broker: let
|
|
@@ -854,6 +887,7 @@ async function startAgent(configPath) {
|
|
|
854
887
|
configPath: config.configPath,
|
|
855
888
|
onReconnect: reconnect,
|
|
856
889
|
getHubConnectionState,
|
|
890
|
+
getMeshTransport: () => (currentMeshTls !== null ? 'tls' : 'clear'),
|
|
857
891
|
updateControls,
|
|
858
892
|
});
|
|
859
893
|
// ── Phase 1: Load core infrastructure addons (in-process) ──
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RevokeSkipReasonSchema = exports.RevocableCredentialKindSchema = exports.REVOKE_CREDENTIALS_MAX = void 0;
|
|
4
|
+
exports.createRevokeCredentialsProcedures = createRevokeCredentialsProcedures;
|
|
5
|
+
const server_1 = require("@trpc/server");
|
|
6
|
+
const zod_1 = require("zod");
|
|
7
|
+
const hub_sessions_js_1 = require("../../auth/hub-sessions.js");
|
|
8
|
+
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
9
|
+
/** Upper bound of one bulk call — a page of a table, several times over. */
|
|
10
|
+
exports.REVOKE_CREDENTIALS_MAX = 500;
|
|
11
|
+
/** What a bulk revoke can target. Each has a per-row revoke on the server. */
|
|
12
|
+
exports.RevocableCredentialKindSchema = zod_1.z.enum(['share-link', 'admin-token', 'login-session']);
|
|
13
|
+
exports.RevokeSkipReasonSchema = zod_1.z.enum(['current-session', 'not-active']);
|
|
14
|
+
const RevokeCredentialsInputSchema = zod_1.z
|
|
15
|
+
.object({
|
|
16
|
+
kind: exports.RevocableCredentialKindSchema,
|
|
17
|
+
ids: zod_1.z.array(zod_1.z.string().min(1)).min(1).max(exports.REVOKE_CREDENTIALS_MAX),
|
|
18
|
+
/** The count the operator confirmed. Must equal `ids.length`. */
|
|
19
|
+
confirmCount: zod_1.z.number().int().min(1),
|
|
20
|
+
})
|
|
21
|
+
.refine((input) => new Set(input.ids).size === input.ids.length, {
|
|
22
|
+
message: 'Each id may be listed once',
|
|
23
|
+
path: ['ids'],
|
|
24
|
+
});
|
|
25
|
+
const RevokeCredentialsOutputSchema = zod_1.z.object({
|
|
26
|
+
revoked: zod_1.z.array(zod_1.z.string()),
|
|
27
|
+
skipped: zod_1.z.array(zod_1.z.object({ id: zod_1.z.string(), reason: exports.RevokeSkipReasonSchema })),
|
|
28
|
+
});
|
|
29
|
+
/** Keep the audit row bounded; the ids beyond this are counted, not listed. */
|
|
30
|
+
const AUDIT_IDS_MAX_CHARS = 4000;
|
|
31
|
+
function assertSignedInAdminSession(user) {
|
|
32
|
+
if (user.isScoped === true ||
|
|
33
|
+
user.shareView !== undefined ||
|
|
34
|
+
user.credential !== undefined ||
|
|
35
|
+
user.adminTokenId !== undefined) {
|
|
36
|
+
throw new server_1.TRPCError({
|
|
37
|
+
code: 'FORBIDDEN',
|
|
38
|
+
message: 'Revoking credentials in bulk needs a signed-in admin session',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function requireInternal(registry) {
|
|
43
|
+
const internal = registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(registry);
|
|
44
|
+
if (internal === null) {
|
|
45
|
+
throw new server_1.TRPCError({
|
|
46
|
+
code: 'PRECONDITION_FAILED',
|
|
47
|
+
message: 'User management is not available',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return internal;
|
|
51
|
+
}
|
|
52
|
+
/** The per-row revoke for `kind`, bound to the caller. `null` = revoked. */
|
|
53
|
+
function revokerFor(kind, caller, deps) {
|
|
54
|
+
if (kind === 'share-link') {
|
|
55
|
+
const service = deps.shareTokens;
|
|
56
|
+
if (service === null) {
|
|
57
|
+
throw new server_1.TRPCError({ code: 'PRECONDITION_FAILED', message: 'Share links are not available' });
|
|
58
|
+
}
|
|
59
|
+
return async (id) => (await service.revoke({ id, callerUserId: caller.id, callerIsAdmin: true }))
|
|
60
|
+
? null
|
|
61
|
+
: 'not-active';
|
|
62
|
+
}
|
|
63
|
+
const internal = requireInternal(deps.registry);
|
|
64
|
+
if (kind === 'admin-token') {
|
|
65
|
+
return async (id) => ((await internal.adminTokens.revoke(id)) ? null : 'not-active');
|
|
66
|
+
}
|
|
67
|
+
return async (id) => {
|
|
68
|
+
if (id === caller.sessionId)
|
|
69
|
+
return 'current-session';
|
|
70
|
+
if (internal.sessions.check(id).state !== 'active')
|
|
71
|
+
return 'not-active';
|
|
72
|
+
return (await internal.sessions.revoke(id, 'revoked-by-admin')) ? null : 'not-active';
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function auditIds(ids) {
|
|
76
|
+
const joined = ids.join(',');
|
|
77
|
+
return joined.length <= AUDIT_IDS_MAX_CHARS ? joined : `${joined.slice(0, AUDIT_IDS_MAX_CHARS)}…`;
|
|
78
|
+
}
|
|
79
|
+
function createRevokeCredentialsProcedures(deps) {
|
|
80
|
+
return {
|
|
81
|
+
/** ADMIN — revoke exactly `ids` of one kind; see the module header. */
|
|
82
|
+
revokeCredentials: trpc_middleware_js_1.adminProcedure
|
|
83
|
+
.input(RevokeCredentialsInputSchema)
|
|
84
|
+
.output(RevokeCredentialsOutputSchema)
|
|
85
|
+
.mutation(async ({ ctx, input }) => {
|
|
86
|
+
assertSignedInAdminSession(ctx.user);
|
|
87
|
+
if (input.confirmCount !== input.ids.length) {
|
|
88
|
+
throw new server_1.TRPCError({
|
|
89
|
+
code: 'BAD_REQUEST',
|
|
90
|
+
message: `Confirmed ${input.confirmCount} but ${input.ids.length} were listed — nothing was revoked`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const revokeOne = revokerFor(input.kind, ctx.user, deps);
|
|
94
|
+
const revoked = [];
|
|
95
|
+
const skipped = [];
|
|
96
|
+
// Sequential on purpose: the stores are single-writer, and a partial
|
|
97
|
+
// failure must leave an exact account of what already happened.
|
|
98
|
+
for (const id of input.ids) {
|
|
99
|
+
const reason = await revokeOne(id);
|
|
100
|
+
if (reason === null)
|
|
101
|
+
revoked.push(id);
|
|
102
|
+
else
|
|
103
|
+
skipped.push({ id, reason });
|
|
104
|
+
}
|
|
105
|
+
const internal = deps.registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(deps.registry);
|
|
106
|
+
if (internal !== null) {
|
|
107
|
+
const ip = deps.network.clientAddress(ctx.req).ip;
|
|
108
|
+
await internal.audit.record({
|
|
109
|
+
action: 'credentials-revoked',
|
|
110
|
+
userId: ctx.user.id,
|
|
111
|
+
...(ip !== null ? { ip } : {}),
|
|
112
|
+
detail: {
|
|
113
|
+
kind: input.kind,
|
|
114
|
+
requested: input.ids.length,
|
|
115
|
+
revoked: revoked.length,
|
|
116
|
+
skipped: skipped.length,
|
|
117
|
+
ids: auditIds(revoked),
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return { revoked, skipped };
|
|
122
|
+
}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -35,6 +35,7 @@ const principal_visibility_js_1 = require("../trpc/principal-visibility.js");
|
|
|
35
35
|
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
36
36
|
const auth_limits_js_1 = require("./auth-limits.js");
|
|
37
37
|
const auth_admin_tokens_procedures_js_1 = require("./auth-admin-tokens.procedures.js");
|
|
38
|
+
const auth_revoke_credentials_procedures_js_1 = require("./auth-revoke-credentials.procedures.js");
|
|
38
39
|
const auth_reauth_procedures_js_1 = require("./auth-reauth.procedures.js");
|
|
39
40
|
const auth_sessions_procedures_js_1 = require("./auth-sessions.procedures.js");
|
|
40
41
|
/**
|
|
@@ -1080,6 +1081,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
|
|
|
1080
1081
|
...(0, auth_sessions_procedures_js_1.createSessionProcedures)({ registry, minter, credentialProcedure }),
|
|
1081
1082
|
...(0, auth_reauth_procedures_js_1.createReauthProcedures)({ auth, registry, moleculer, limiters }),
|
|
1082
1083
|
...(0, auth_admin_tokens_procedures_js_1.createAdminTokenProcedures)({ auth, registry, network }),
|
|
1084
|
+
...(0, auth_revoke_credentials_procedures_js_1.createRevokeCredentialsProcedures)({ registry, shareTokens, network }),
|
|
1083
1085
|
// ── External sign-in identities (D782) ────────────────────────
|
|
1084
1086
|
//
|
|
1085
1087
|
// A signed-in user links an OIDC identity to THEIR OWN account. The
|
|
@@ -245,6 +245,11 @@ function buildToastProvider(toastService, ctx) {
|
|
|
245
245
|
},
|
|
246
246
|
};
|
|
247
247
|
}
|
|
248
|
+
/** `{ meshTransport }` for an online agent, `{}` when the hub cannot tell. */
|
|
249
|
+
function meshTransportField(nodeId) {
|
|
250
|
+
const meshTransport = (0, system_1.getProcessNodeMeshTransport)(nodeId);
|
|
251
|
+
return meshTransport === undefined ? {} : { meshTransport };
|
|
252
|
+
}
|
|
248
253
|
function getLocalIps() {
|
|
249
254
|
const interfaces = os.networkInterfaces();
|
|
250
255
|
const ips = [];
|
|
@@ -411,6 +416,9 @@ async function computeTopologyInternal(agentRegistry, addonRegistry, getNodeRoot
|
|
|
411
416
|
// (hub: the running @camstack/server; agents: their registerNode
|
|
412
417
|
// manifest via HubNodeRegistry). Null when unknown.
|
|
413
418
|
rootPackage: getNodeRootPackage?.(node.info.id, node.isHub) ?? null,
|
|
419
|
+
// Mesh mTLS Release A: observed by the hub's transporter (certificate CN),
|
|
420
|
+
// never self-reported by the agent. Hub row and unknown: no badge.
|
|
421
|
+
...(node.isHub ? {} : meshTransportField(node.info.id)),
|
|
414
422
|
};
|
|
415
423
|
});
|
|
416
424
|
// A2: union in OFFLINE rows for every persisted node no longer present in the
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
* routes the call exactly like any addon-provided cap.
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.REMOTE_PRIVILEGE_LOG_ONLY = exports.CORE_CAP_REMOTE_FORBIDDEN_TYPE = exports.REMOTE_DENIED_NAMESPACES = void 0;
|
|
21
|
+
exports.REMOTE_PRIVILEGE_LOG_ONLY = exports.REMOTE_PRIVILEGE_ALLOWLIST = exports.CORE_CAP_REMOTE_FORBIDDEN_TYPE = exports.REMOTE_DENIED_NAMESPACES = void 0;
|
|
22
22
|
exports.setRemotePrivilegeLogSink = setRemotePrivilegeLogSink;
|
|
23
23
|
exports.__resetRemotePrivilegeObservationForTests = __resetRemotePrivilegeObservationForTests;
|
|
24
24
|
exports.observeRemotePrivilege = observeRemotePrivilege;
|
|
25
|
+
exports.observeRemotePrivilegeRefused = observeRemotePrivilegeRefused;
|
|
25
26
|
exports.isCoreServiceCapName = isCoreServiceCapName;
|
|
26
27
|
exports.describeMeshOrigin = describeMeshOrigin;
|
|
27
28
|
exports.buildCoreCapService = buildCoreCapService;
|
|
@@ -76,11 +77,24 @@ exports.REMOTE_DENIED_NAMESPACES = new Set([
|
|
|
76
77
|
/** Moleculer error `type` of a bridged call another node may not make. */
|
|
77
78
|
exports.CORE_CAP_REMOTE_FORBIDDEN_TYPE = 'CORE_CAP_REMOTE_FORBIDDEN';
|
|
78
79
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
80
|
+
* Hub-core procedure paths another node may call after {@link REMOTE_DENIED_NAMESPACES}.
|
|
81
|
+
* Seeded from armed transport census on the live hub (2026-10): remote agents
|
|
82
|
+
* (`mesh:<agent>`) called none of these; hub runners used this surface only.
|
|
82
83
|
*/
|
|
83
|
-
exports.
|
|
84
|
+
exports.REMOTE_PRIVILEGE_ALLOWLIST = new Set([
|
|
85
|
+
'addons.listCapabilityProviders',
|
|
86
|
+
'nodes.topology',
|
|
87
|
+
'integrations.getByAddonId',
|
|
88
|
+
'addons.list',
|
|
89
|
+
'system.getLoggingSettings',
|
|
90
|
+
'addons.setCapabilityProviderEnabled',
|
|
91
|
+
'system.getSiteLocation',
|
|
92
|
+
]);
|
|
93
|
+
/**
|
|
94
|
+
* When true, remote bridged calls outside {@link REMOTE_PRIVILEGE_ALLOWLIST} are
|
|
95
|
+
* logged once per `(nodeId, path)` per process — still admin, not refused.
|
|
96
|
+
*/
|
|
97
|
+
exports.REMOTE_PRIVILEGE_LOG_ONLY = false;
|
|
84
98
|
let remotePrivilegeLogSink = null;
|
|
85
99
|
/** Attach the warn sink used by {@link observeRemotePrivilege}. */
|
|
86
100
|
function setRemotePrivilegeLogSink(sink) {
|
|
@@ -95,22 +109,31 @@ function __resetRemotePrivilegeObservationForTests() {
|
|
|
95
109
|
function remotePrivilegeKey(nodeId, path) {
|
|
96
110
|
return `${nodeId}\0${path}`;
|
|
97
111
|
}
|
|
98
|
-
|
|
99
|
-
* One warn per `(nodeId, path)` while {@link REMOTE_PRIVILEGE_LOG_ONLY} is on.
|
|
100
|
-
* Call only after {@link REMOTE_DENIED_NAMESPACES} has passed.
|
|
101
|
-
*/
|
|
102
|
-
function observeRemotePrivilege(nodeId, path) {
|
|
103
|
-
if (!exports.REMOTE_PRIVILEGE_LOG_ONLY)
|
|
104
|
-
return;
|
|
112
|
+
function warnRemotePrivilegeOnce(nodeId, path, message) {
|
|
105
113
|
const key = remotePrivilegeKey(nodeId, path);
|
|
106
114
|
if (remotePrivilegeObserved.has(key))
|
|
107
115
|
return;
|
|
108
116
|
remotePrivilegeObserved.add(key);
|
|
109
|
-
const message = `mesh privilege: node:${nodeId} called ${path} (log-only; still allowed)`;
|
|
110
117
|
remotePrivilegeLogSink?.warn(message, {
|
|
111
118
|
meta: { nodeId, path, principal: `node:${nodeId}` },
|
|
112
119
|
});
|
|
113
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* One warn per `(nodeId, path)` while {@link REMOTE_PRIVILEGE_LOG_ONLY} is on.
|
|
123
|
+
* Call only after {@link REMOTE_DENIED_NAMESPACES} has passed.
|
|
124
|
+
*/
|
|
125
|
+
function observeRemotePrivilege(nodeId, path) {
|
|
126
|
+
if (!exports.REMOTE_PRIVILEGE_LOG_ONLY)
|
|
127
|
+
return;
|
|
128
|
+
warnRemotePrivilegeOnce(nodeId, path, `mesh privilege: node:${nodeId} called ${path} (log-only; still allowed)`);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* One warn per `(nodeId, path)` when enforcement refuses a path not on
|
|
132
|
+
* {@link REMOTE_PRIVILEGE_ALLOWLIST}. Call immediately before throwing.
|
|
133
|
+
*/
|
|
134
|
+
function observeRemotePrivilegeRefused(nodeId, path) {
|
|
135
|
+
warnRemotePrivilegeOnce(nodeId, path, `mesh privilege: node:${nodeId} called ${path} (refused; not on grant)`);
|
|
136
|
+
}
|
|
114
137
|
/**
|
|
115
138
|
* camelCase → kebab-case. Mirrors `toKebab` in `trpc-links.ts` so the
|
|
116
139
|
* action name matches what `brokerTransportLink` derives from `op.path`.
|
|
@@ -259,8 +282,15 @@ function buildCoreCapService(appRouter, census = (0, http_request_census_singlet
|
|
|
259
282
|
if (remoteNode !== null && exports.REMOTE_DENIED_NAMESPACES.has(namespace)) {
|
|
260
283
|
throw new moleculer_1.Errors.MoleculerClientError(`"${path}" is not callable from another node (caller "${remoteNode}")`, 403, exports.CORE_CAP_REMOTE_FORBIDDEN_TYPE);
|
|
261
284
|
}
|
|
262
|
-
if (remoteNode !== null)
|
|
263
|
-
|
|
285
|
+
if (remoteNode !== null) {
|
|
286
|
+
if (exports.REMOTE_PRIVILEGE_LOG_ONLY) {
|
|
287
|
+
observeRemotePrivilege(remoteNode, path);
|
|
288
|
+
}
|
|
289
|
+
else if (!exports.REMOTE_PRIVILEGE_ALLOWLIST.has(path)) {
|
|
290
|
+
observeRemotePrivilegeRefused(remoteNode, path);
|
|
291
|
+
throw new moleculer_1.Errors.MoleculerClientError(`"${path}" is not on the remote privilege grant (caller "${remoteNode}")`, 403, exports.CORE_CAP_REMOTE_FORBIDDEN_TYPE);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
264
294
|
const invoke = resolveInvoker(callerFor(remoteNode === null ? 'mesh' : `node:${remoteNode}`), path);
|
|
265
295
|
if (invoke === null)
|
|
266
296
|
throw new Error(`$core-caps: "${path}" is not invocable`);
|
|
@@ -644,6 +644,8 @@ class MoleculerService {
|
|
|
644
644
|
this.removeNodeFromRegistry(node.id);
|
|
645
645
|
// D802: a node authenticates again on its next connection.
|
|
646
646
|
this.meshGuard.forget(node.id);
|
|
647
|
+
// Release A: its next connection decides again whether it is `tls` or `clear`.
|
|
648
|
+
(0, system_1.getBrokerTlsTransporter)(this.broker)?.forgetPeer(node.id);
|
|
647
649
|
});
|
|
648
650
|
// D8 reverse-leg snapshot-reconcile: on every agent (re)connect, pull
|
|
649
651
|
// the agent's own readiness snapshot (`$agent-readiness.getSnapshot`)
|
|
@@ -702,6 +704,27 @@ class MoleculerService {
|
|
|
702
704
|
await tx.enableMeshTls(material);
|
|
703
705
|
logger.info('Mesh mTLS listener enabled (dual-port with clear TCP)');
|
|
704
706
|
});
|
|
707
|
+
// Release A cutover: System → Cluster reads each agent's transport and may
|
|
708
|
+
// close the clear port through this slot (never flipped by this wiring).
|
|
709
|
+
(0, system_1.setProcessMeshClearPortControl)({
|
|
710
|
+
listOnlineAgents: () => this.listAvailableNodeIds()
|
|
711
|
+
.filter((id) => id !== this.brokerSafe.nodeID && !id.includes('/'))
|
|
712
|
+
.map((nodeId) => ({
|
|
713
|
+
nodeId,
|
|
714
|
+
transport: (0, system_1.getBrokerTlsTransporter)(this.broker)?.peerTransport(nodeId) ?? 'clear',
|
|
715
|
+
})),
|
|
716
|
+
isTlsListening: () => (0, system_1.getBrokerTlsTransporter)(this.broker)?.isTlsListening() === true,
|
|
717
|
+
transportOf: (nodeId) => (0, system_1.getBrokerTlsTransporter)(this.broker)?.peerTransport(nodeId),
|
|
718
|
+
apply: async (mode) => {
|
|
719
|
+
const tx = (0, system_1.getBrokerTlsTransporter)(this.broker);
|
|
720
|
+
if (tx === null)
|
|
721
|
+
return;
|
|
722
|
+
if (mode === 'off')
|
|
723
|
+
tx.disableClearTransport();
|
|
724
|
+
else
|
|
725
|
+
await tx.enableClearTransport();
|
|
726
|
+
},
|
|
727
|
+
});
|
|
705
728
|
await this.brokerSafe.start();
|
|
706
729
|
logger.info('Moleculer broker started (TCP transport)');
|
|
707
730
|
this.meshGuard.start(() => this.listAvailableNodeIds());
|
|
@@ -1335,6 +1358,7 @@ class MoleculerService {
|
|
|
1335
1358
|
}
|
|
1336
1359
|
async onModuleDestroy() {
|
|
1337
1360
|
(0, system_1.setProcessMeshTlsEnabler)(null);
|
|
1361
|
+
(0, system_1.setProcessMeshClearPortControl)(null);
|
|
1338
1362
|
this.meshGuard.stop();
|
|
1339
1363
|
if (this.agentReadinessPullTimer !== null) {
|
|
1340
1364
|
clearInterval(this.agentReadinessPullTimer);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.490",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"files": [
|
|
@@ -37,18 +37,18 @@
|
|
|
37
37
|
]
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
41
|
-
"@camstack/addon-agent-ui": "1.2.
|
|
40
|
+
"@camstack/addon-admin-ui": "1.2.363",
|
|
41
|
+
"@camstack/addon-agent-ui": "1.2.198",
|
|
42
42
|
"@camstack/addon-auth": "1.2.135",
|
|
43
43
|
"@camstack/addon-decoder-nodeav": "1.2.122",
|
|
44
44
|
"@camstack/addon-notifiers": "1.2.129",
|
|
45
45
|
"@camstack/addon-pipeline": "1.2.347",
|
|
46
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
46
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.301",
|
|
47
47
|
"@camstack/addon-post-analysis": "1.2.348",
|
|
48
48
|
"@camstack/sdk": "1.2.128",
|
|
49
|
-
"@camstack/system": "1.2.
|
|
50
|
-
"@camstack/types": "1.2.
|
|
51
|
-
"@camstack/ui-library": "1.2.
|
|
49
|
+
"@camstack/system": "1.2.384",
|
|
50
|
+
"@camstack/types": "1.2.334",
|
|
51
|
+
"@camstack/ui-library": "1.2.256",
|
|
52
52
|
"@fastify/compress": "^9.0.0",
|
|
53
53
|
"@fastify/cookie": "^11.0.2",
|
|
54
54
|
"@fastify/cors": "^11.2.0",
|