@camstack/server 1.2.687 → 1.2.689
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/CHANGELOG.md +17 -0
- package/dist/agent/agent-membership-watch.js +80 -0
- package/dist/agent/agent-pairing-store.js +31 -1
- package/dist/agent/agent-service.js +8 -3
- package/dist/agent/hub-membership-client.js +82 -0
- package/dist/agent/main.js +39 -0
- package/dist/api/core/cap-providers.js +5 -3
- package/dist/api/core/cluster-nodes.router.js +10 -3
- package/dist/api/mesh-membership-routes.js +50 -0
- package/dist/api/trpc/generated-cap-mounts.js +13 -1
- package/dist/api/trpc/generated-cap-routers.js +1439 -1304
- package/dist/core/addon/addon-registry.service.js +15 -0
- package/dist/core/agent/agent-registry.service.js +125 -27
- package/dist/core/agent/forgotten-node-revoker.js +34 -0
- package/dist/core/agent/node-removal-hooks.js +186 -0
- package/dist/main.js +24 -5
- package/dist/manual-boot.js +43 -4
- package/package.json +9 -9
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.2.689] - 2026-10-29
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- A node's free memory comes from the metrics provider instead of os.freemem(), which undercounts on macOS
|
|
15
|
+
- device-export gains optional methods to list, expose and unexpose actions and notification targets beside devices.
|
|
16
|
+
- Forget node now works on an online node too: its certificates are revoked, its connection is cut and calls pinned to it fail at once.
|
|
17
|
+
- Generated routers for the pet-fountain capability
|
|
18
|
+
- Agents report their own process memory in the topology (the core process showed 0), and an unreadable memory shows as unknown
|
|
19
|
+
|
|
20
|
+
## [1.2.688] - 2026-10-29
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Forgetting a node now clears its per-node capability overrides and tells every node-removal-hook owner
|
|
25
|
+
- Forgetting an offline node now revokes its mesh certificates, so it can no longer rejoin by itself; a revoked agent learns it was removed and resets to unpaired.
|
|
26
|
+
|
|
10
27
|
## [1.2.687] - 2026-10-29
|
|
11
28
|
|
|
12
29
|
### Changed
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AgentMembershipWatch = exports.DEFAULT_MEMBERSHIP_CONFIRM_DELAY_MS = exports.DEFAULT_MEMBERSHIP_ASK_INTERVAL_MS = exports.DEFAULT_MEMBERSHIP_GRACE_MS = void 0;
|
|
4
|
+
exports.DEFAULT_MEMBERSHIP_GRACE_MS = 90_000;
|
|
5
|
+
exports.DEFAULT_MEMBERSHIP_ASK_INTERVAL_MS = 60_000;
|
|
6
|
+
exports.DEFAULT_MEMBERSHIP_CONFIRM_DELAY_MS = 15_000;
|
|
7
|
+
/** `revoked` answers in a row that destroy the pairing. */
|
|
8
|
+
const REVOKED_READS_REQUIRED = 2;
|
|
9
|
+
class AgentMembershipWatch {
|
|
10
|
+
deps;
|
|
11
|
+
disconnectedSince = null;
|
|
12
|
+
lastAskAt = null;
|
|
13
|
+
revokedReads = 0;
|
|
14
|
+
asking = false;
|
|
15
|
+
constructor(deps) {
|
|
16
|
+
this.deps = deps;
|
|
17
|
+
}
|
|
18
|
+
/** One step. Resolves once any question this step asked has been handled. */
|
|
19
|
+
async tick(now) {
|
|
20
|
+
if (this.asking)
|
|
21
|
+
return;
|
|
22
|
+
if (!this.deps.isPaired() || this.deps.isConnected()) {
|
|
23
|
+
this.reset();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
this.disconnectedSince ??= now;
|
|
27
|
+
if (now - this.disconnectedSince < (this.deps.graceMs ?? exports.DEFAULT_MEMBERSHIP_GRACE_MS))
|
|
28
|
+
return;
|
|
29
|
+
const interval = this.revokedReads > 0
|
|
30
|
+
? (this.deps.confirmDelayMs ?? exports.DEFAULT_MEMBERSHIP_CONFIRM_DELAY_MS)
|
|
31
|
+
: (this.deps.askIntervalMs ?? exports.DEFAULT_MEMBERSHIP_ASK_INTERVAL_MS);
|
|
32
|
+
if (this.lastAskAt !== null && now - this.lastAskAt < interval)
|
|
33
|
+
return;
|
|
34
|
+
this.lastAskAt = now;
|
|
35
|
+
this.asking = true;
|
|
36
|
+
try {
|
|
37
|
+
await this.handle(await this.deps.ask());
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
this.asking = false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
reset() {
|
|
44
|
+
this.disconnectedSince = null;
|
|
45
|
+
this.lastAskAt = null;
|
|
46
|
+
this.revokedReads = 0;
|
|
47
|
+
}
|
|
48
|
+
async handle(result) {
|
|
49
|
+
const tags = { nodeId: this.deps.nodeId };
|
|
50
|
+
if (result.kind === 'unreachable') {
|
|
51
|
+
this.revokedReads = 0;
|
|
52
|
+
this.deps.log.info('could not ask the hub whether this agent is still a member - keeping the pairing', {
|
|
53
|
+
tags,
|
|
54
|
+
meta: { error: result.error },
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const { status, reason } = result.answer;
|
|
59
|
+
if (status !== 'revoked') {
|
|
60
|
+
this.revokedReads = 0;
|
|
61
|
+
this.deps.log.info(`the hub answered "${status}" to the membership question - keeping the pairing`, {
|
|
62
|
+
tags,
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.revokedReads += 1;
|
|
67
|
+
const why = reason ?? 'revoked';
|
|
68
|
+
if (this.revokedReads < REVOKED_READS_REQUIRED) {
|
|
69
|
+
this.deps.log.warn(`the hub says this agent's certificate is revoked (${why}) - asking once more before resetting`, {
|
|
70
|
+
tags,
|
|
71
|
+
meta: { reason: why },
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.deps.log.warn(`the hub confirmed twice that this agent's certificate is revoked (${why}) - discarding the pairing and becoming pairable again`, { tags, meta: { reason: why } });
|
|
76
|
+
this.reset();
|
|
77
|
+
await this.deps.onRevoked(why);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
exports.AgentMembershipWatch = AgentMembershipWatch;
|
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.isAgentPaired = isAgentPaired;
|
|
37
37
|
exports.pinnedMeshCaCert = pinnedMeshCaCert;
|
|
38
38
|
exports.persistPairing = persistPairing;
|
|
39
|
+
exports.readPairedHubIdentity = readPairedHubIdentity;
|
|
39
40
|
exports.forgetPairing = forgetPairing;
|
|
40
41
|
exports.pairedLeafFingerprint = pairedLeafFingerprint;
|
|
41
42
|
/**
|
|
@@ -48,7 +49,13 @@ const fs = __importStar(require("node:fs"));
|
|
|
48
49
|
const system_1 = require("@camstack/system");
|
|
49
50
|
const agent_json_file_js_1 = require("./agent-json-file.js");
|
|
50
51
|
/** Keys that exist only while this agent is paired. */
|
|
51
|
-
const PAIRED_KEYS = [
|
|
52
|
+
const PAIRED_KEYS = [
|
|
53
|
+
'meshTls',
|
|
54
|
+
'meshTlsPrevious',
|
|
55
|
+
'meshSwap',
|
|
56
|
+
'hubAddress',
|
|
57
|
+
'hubUrl',
|
|
58
|
+
];
|
|
52
59
|
/** `agent.json` holds mesh material: the agent is paired. */
|
|
53
60
|
function isAgentPaired(configPath) {
|
|
54
61
|
const meshTls = (0, agent_json_file_js_1.readJsonFile)(configPath)['meshTls'];
|
|
@@ -72,9 +79,32 @@ function persistPairing(configPath, pairing) {
|
|
|
72
79
|
(0, agent_json_file_js_1.writeJsonFile0600)(configPath, {
|
|
73
80
|
...existing,
|
|
74
81
|
hubAddress: pairing.hubAddress,
|
|
82
|
+
// The hub's API address the hub itself gave: where a refused agent asks "am I still a member?".
|
|
83
|
+
hubUrl: pairing.hubUrl,
|
|
75
84
|
meshTls: pairing.meshTls,
|
|
76
85
|
});
|
|
77
86
|
}
|
|
87
|
+
function readPairedHubIdentity(configPath) {
|
|
88
|
+
const config = (0, agent_json_file_js_1.readJsonFile)(configPath);
|
|
89
|
+
const meshTls = config['meshTls'];
|
|
90
|
+
if (meshTls === null || typeof meshTls !== 'object')
|
|
91
|
+
return null;
|
|
92
|
+
const certPem = Reflect.get(meshTls, 'certPem');
|
|
93
|
+
const keyPem = Reflect.get(meshTls, 'keyPem');
|
|
94
|
+
const caCertPem = Reflect.get(meshTls, 'caCertPem');
|
|
95
|
+
if (typeof certPem !== 'string' || typeof keyPem !== 'string' || typeof caCertPem !== 'string') {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const hubUrl = config['hubUrl'];
|
|
99
|
+
const hubAddress = config['hubAddress'];
|
|
100
|
+
return {
|
|
101
|
+
hubUrl: typeof hubUrl === 'string' && hubUrl.length > 0 ? hubUrl : null,
|
|
102
|
+
hubAddress: typeof hubAddress === 'string' && hubAddress.length > 0 ? hubAddress : null,
|
|
103
|
+
certPem,
|
|
104
|
+
keyPem,
|
|
105
|
+
caCertPem,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
78
108
|
/**
|
|
79
109
|
* Leave the hub: erase the mesh material, the hub address and the HTTPS bundle
|
|
80
110
|
* the hub issued. The agent is unpaired afterwards and accepts offers again.
|
|
@@ -240,7 +240,8 @@ function createAgentService(deps) {
|
|
|
240
240
|
const { broker } = ctx;
|
|
241
241
|
const cpus = os.cpus();
|
|
242
242
|
let cpuPercent = 0;
|
|
243
|
-
let memoryPercent =
|
|
243
|
+
let memoryPercent = null;
|
|
244
|
+
let freeMemoryBytes = null;
|
|
244
245
|
const metrics = deps.getMetricsProvider?.();
|
|
245
246
|
if (metrics) {
|
|
246
247
|
// Bounded so $agent.status never hangs (and drops the node from
|
|
@@ -249,6 +250,7 @@ function createAgentService(deps) {
|
|
|
249
250
|
if (snapshot) {
|
|
250
251
|
cpuPercent = snapshot.cpu.total;
|
|
251
252
|
memoryPercent = snapshot.memory.percent;
|
|
253
|
+
freeMemoryBytes = snapshot.memory.availableBytes;
|
|
252
254
|
}
|
|
253
255
|
}
|
|
254
256
|
const mem = process.memoryUsage();
|
|
@@ -262,9 +264,12 @@ function createAgentService(deps) {
|
|
|
262
264
|
cpuCores: cpus.length,
|
|
263
265
|
cpuModel: cpus[0]?.model,
|
|
264
266
|
totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024),
|
|
265
|
-
freeMemoryMB: Math.round(
|
|
267
|
+
freeMemoryMB: freeMemoryBytes === null ? null : Math.round(freeMemoryBytes / 1024 / 1024),
|
|
266
268
|
cpuPercent,
|
|
267
269
|
memoryPercent,
|
|
270
|
+
// The agent's own (core) process, so a topology row can attribute
|
|
271
|
+
// the largest process on the node instead of showing 0.
|
|
272
|
+
processRssBytes: process.memoryUsage().rss,
|
|
268
273
|
uptime: os.uptime(),
|
|
269
274
|
// The agent *process* itself (distinct from the host `uptime` /
|
|
270
275
|
// memory above) — surfaced so the UI can show how long THIS agent
|
|
@@ -298,7 +303,7 @@ function createAgentService(deps) {
|
|
|
298
303
|
handler: async (ctx) => {
|
|
299
304
|
const { broker } = ctx;
|
|
300
305
|
let cpuPercent = 0;
|
|
301
|
-
let memoryPercent =
|
|
306
|
+
let memoryPercent = null;
|
|
302
307
|
const metrics = deps.getMetricsProvider?.();
|
|
303
308
|
if (metrics) {
|
|
304
309
|
try {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.membershipFetch = void 0;
|
|
4
|
+
exports.askHubMembership = askHubMembership;
|
|
5
|
+
/**
|
|
6
|
+
* The agent asks its hub "am I still a member?" (D857).
|
|
7
|
+
*
|
|
8
|
+
* Two plain HTTPS calls (`MEMBERSHIP_PATHS`): fetch a nonce, then prove possession of the mesh leaf's
|
|
9
|
+
* private key by signing it. The hub's HTTPS certificate is NOT what this trusts - the hub may serve a
|
|
10
|
+
* private one - the answer is a JWS signed by the mesh CA key, verified here against the CA this agent
|
|
11
|
+
* pinned at pairing, bound to the nonce and the leaf. The key never leaves the process: it signs, and
|
|
12
|
+
* only the signature travels (no argv, no env).
|
|
13
|
+
*
|
|
14
|
+
* Every doubt is `unreachable`, never an answer: a hub that cannot be reached, a status other than
|
|
15
|
+
* 200, a token that does not verify. The caller changes nothing on `unreachable` (D49).
|
|
16
|
+
*/
|
|
17
|
+
const node_crypto_1 = require("node:crypto");
|
|
18
|
+
const system_1 = require("@camstack/system");
|
|
19
|
+
const undici_1 = require("undici");
|
|
20
|
+
const REQUEST_TIMEOUT_MS = 8_000;
|
|
21
|
+
/** Production transport: JSON POST with a timeout, TLS not verified (the answer is CA-signed). */
|
|
22
|
+
const membershipFetch = (url, init) => (0, undici_1.fetch)(url, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'content-type': 'application/json' },
|
|
25
|
+
...(init.body !== undefined ? { body: init.body } : {}),
|
|
26
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
27
|
+
dispatcher: new undici_1.Agent({ connect: { rejectUnauthorized: false } }),
|
|
28
|
+
});
|
|
29
|
+
exports.membershipFetch = membershipFetch;
|
|
30
|
+
function errorMessage(err) {
|
|
31
|
+
return err instanceof Error ? err.message : String(err);
|
|
32
|
+
}
|
|
33
|
+
async function askHubMembership(query, doFetch = exports.membershipFetch) {
|
|
34
|
+
try {
|
|
35
|
+
const base = query.hubUrl.replace(/\/+$/, '');
|
|
36
|
+
const challenge = await doFetch(`${base}${system_1.MEMBERSHIP_PATHS.challenge}`, {
|
|
37
|
+
body: '{}',
|
|
38
|
+
});
|
|
39
|
+
if (challenge.status !== 200) {
|
|
40
|
+
return {
|
|
41
|
+
kind: 'unreachable',
|
|
42
|
+
error: `challenge answered HTTP ${challenge.status}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const nonce = Reflect.get(Object(await challenge.json()), 'nonce');
|
|
46
|
+
if (typeof nonce !== 'string' || nonce.length === 0) {
|
|
47
|
+
return { kind: 'unreachable', error: 'challenge carried no nonce' };
|
|
48
|
+
}
|
|
49
|
+
const response = await doFetch(`${base}${system_1.MEMBERSHIP_PATHS.query}`, {
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
nodeId: query.nodeId,
|
|
52
|
+
certPem: query.certPem,
|
|
53
|
+
nonce,
|
|
54
|
+
signature: (0, system_1.signMembershipProof)(query.keyPem, query.nodeId, nonce),
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
if (response.status !== 200) {
|
|
58
|
+
return {
|
|
59
|
+
kind: 'unreachable',
|
|
60
|
+
error: `membership answered HTTP ${response.status}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const token = Reflect.get(Object(await response.json()), 'token');
|
|
64
|
+
if (typeof token !== 'string')
|
|
65
|
+
return { kind: 'unreachable', error: 'answer carried no token' };
|
|
66
|
+
const answer = (0, system_1.verifyMembershipAnswer)(query.caCertPem, token, {
|
|
67
|
+
nodeId: query.nodeId,
|
|
68
|
+
nonce,
|
|
69
|
+
leafFingerprint: new node_crypto_1.X509Certificate(query.certPem).fingerprint256,
|
|
70
|
+
});
|
|
71
|
+
if (answer === null) {
|
|
72
|
+
return {
|
|
73
|
+
kind: 'unreachable',
|
|
74
|
+
error: 'the answer is not signed by the pinned mesh CA',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return { kind: 'answer', answer };
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
return { kind: 'unreachable', error: errorMessage(err) };
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/agent/main.js
CHANGED
|
@@ -49,7 +49,9 @@ const agent_addon_updates_js_1 = require("./agent-addon-updates.js");
|
|
|
49
49
|
const agent_hub_registration_js_1 = require("./agent-hub-registration.js");
|
|
50
50
|
const agent_mesh_gate_js_1 = require("./agent-mesh-gate.js");
|
|
51
51
|
const agent_pairing_local_js_1 = require("./agent-pairing-local.js");
|
|
52
|
+
const agent_membership_watch_js_1 = require("./agent-membership-watch.js");
|
|
52
53
|
const agent_pairing_store_js_1 = require("./agent-pairing-store.js");
|
|
54
|
+
const hub_membership_client_js_1 = require("./hub-membership-client.js");
|
|
53
55
|
const agent_config_js_1 = require("./agent-config.js");
|
|
54
56
|
const agent_group_runner_js_1 = require("./agent-group-runner.js");
|
|
55
57
|
const agent_http_js_1 = require("./agent-http.js");
|
|
@@ -76,6 +78,8 @@ const register_agent_cap_dispatch_js_1 = require("./register-agent-cap-dispatch.
|
|
|
76
78
|
const system_2 = require("@camstack/system");
|
|
77
79
|
/** A reconnect (stop + rebuild + start) that takes longer than this is abandoned. */
|
|
78
80
|
const RECONNECT_DEADLINE_MS = 90_000;
|
|
81
|
+
/** How often the agent re-evaluates whether to ask its hub "am I still a member?" (D857). */
|
|
82
|
+
const MEMBERSHIP_TICK_MS = 15_000;
|
|
79
83
|
const agentLogManager = new system_2.LogManager(5000);
|
|
80
84
|
/**
|
|
81
85
|
* Caps whose `nodeId` arg is provider DATA (`nodeIdMode: 'data'` —
|
|
@@ -992,6 +996,41 @@ async function startAgent(configPath) {
|
|
|
992
996
|
advertise();
|
|
993
997
|
};
|
|
994
998
|
advertise();
|
|
999
|
+
// A hub that forgot or unpaired this agent refuses its leaf without a word. While paired but not
|
|
1000
|
+
// connected, ask the hub whether the leaf is still a member; two CA-signed `revoked` answers in a
|
|
1001
|
+
// row discard the pairing, anything else changes nothing (D857, D49).
|
|
1002
|
+
const membershipWatch = new agent_membership_watch_js_1.AgentMembershipWatch({
|
|
1003
|
+
nodeId: config.nodeId,
|
|
1004
|
+
isPaired: () => (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath),
|
|
1005
|
+
isConnected: () => getHubConnectionState() === 'connected',
|
|
1006
|
+
ask: async () => {
|
|
1007
|
+
const identity = (0, agent_pairing_store_js_1.readPairedHubIdentity)(config.configPath);
|
|
1008
|
+
const hubUrl = identity?.hubUrl ??
|
|
1009
|
+
(identity?.hubAddress != null ? (0, derive_hub_url_js_1.deriveHubUrlFromHubAddress)(identity.hubAddress) : undefined);
|
|
1010
|
+
if (identity === null || hubUrl === undefined) {
|
|
1011
|
+
return { kind: 'unreachable', error: 'no hub address on record' };
|
|
1012
|
+
}
|
|
1013
|
+
return (0, hub_membership_client_js_1.askHubMembership)({
|
|
1014
|
+
hubUrl,
|
|
1015
|
+
nodeId: config.nodeId,
|
|
1016
|
+
certPem: identity.certPem,
|
|
1017
|
+
keyPem: identity.keyPem,
|
|
1018
|
+
caCertPem: identity.caCertPem,
|
|
1019
|
+
});
|
|
1020
|
+
},
|
|
1021
|
+
onRevoked: (reason) => forgetHubNow(`the hub revoked this agent's certificate (${reason})`),
|
|
1022
|
+
log: {
|
|
1023
|
+
// The console line has no tag column: the node id rides in the text as well.
|
|
1024
|
+
warn: (message, extras) => consoleLogger.warn(`${message} [nodeId=${config.nodeId}]`, extras),
|
|
1025
|
+
info: (message, extras) => consoleLogger.info(`${message} [nodeId=${config.nodeId}]`, extras),
|
|
1026
|
+
},
|
|
1027
|
+
});
|
|
1028
|
+
const membershipTimer = setInterval(() => {
|
|
1029
|
+
void membershipWatch
|
|
1030
|
+
.tick(Date.now())
|
|
1031
|
+
.catch((err) => consoleLogger.warn(`membership check failed: ${(0, types_2.errMsg)(err)}`));
|
|
1032
|
+
}, MEMBERSHIP_TICK_MS);
|
|
1033
|
+
membershipTimer.unref();
|
|
995
1034
|
void (0, agent_http_js_1.startAgentHttpServer)(getBrokerFn, {
|
|
996
1035
|
log: {
|
|
997
1036
|
info: (message, extras) => consoleLogger.info(message, extras),
|
|
@@ -349,7 +349,9 @@ async function computeTopologyInternal(agentRegistry, addonRegistry, getNodeRoot
|
|
|
349
349
|
name: `${node.info.id} (core)`,
|
|
350
350
|
state: 'running',
|
|
351
351
|
cpuPercent: node.status?.cpuPercent ?? 0,
|
|
352
|
-
|
|
352
|
+
// The agent's own process RSS from $agent.status; 0 only for an agent
|
|
353
|
+
// that predates the field.
|
|
354
|
+
memoryRss: node.status?.processRssBytes ?? 0,
|
|
353
355
|
uptimeSeconds: Math.floor((Date.now() - node.connectedSince) / 1000),
|
|
354
356
|
services: mainProcessServices,
|
|
355
357
|
};
|
|
@@ -414,7 +416,7 @@ async function computeTopologyInternal(agentRegistry, addonRegistry, getNodeRoot
|
|
|
414
416
|
isHub: node.isHub,
|
|
415
417
|
isOnline: node.isOnline !== false,
|
|
416
418
|
cpuPercent: node.status?.cpuPercent ?? 0,
|
|
417
|
-
memoryPercent: node.status?.memoryPercent ??
|
|
419
|
+
memoryPercent: node.status?.memoryPercent ?? null,
|
|
418
420
|
uptime: Date.now() - node.connectedSince,
|
|
419
421
|
// Fix (was hardcoded `new Date().toISOString()` — always "now", a bug
|
|
420
422
|
// even for online nodes): report the persisted `lastActive` when we have
|
|
@@ -456,7 +458,7 @@ async function computeTopologyInternal(agentRegistry, addonRegistry, getNodeRoot
|
|
|
456
458
|
isHub: persisted.isHub,
|
|
457
459
|
isOnline: false,
|
|
458
460
|
cpuPercent: 0,
|
|
459
|
-
memoryPercent:
|
|
461
|
+
memoryPercent: null,
|
|
460
462
|
uptime: 0,
|
|
461
463
|
lastSeen: new Date(persisted.lastActive).toISOString(),
|
|
462
464
|
localIps: [...persisted.localIps],
|
|
@@ -35,6 +35,13 @@ const ForgetNodeResultSchema = zod_1.z.object({
|
|
|
35
35
|
registryRemoved: zod_1.z.boolean(),
|
|
36
36
|
/** Read back AFTER the attempt: the node is still in the cluster registry. */
|
|
37
37
|
stillListed: zod_1.z.boolean(),
|
|
38
|
+
/** What happened to the node's mesh certificates: `registry-unavailable` / `failed` = NOT revoked. */
|
|
39
|
+
certificates: zod_1.z.discriminatedUnion('outcome', [
|
|
40
|
+
zod_1.z.object({ outcome: zod_1.z.literal('not-attempted') }),
|
|
41
|
+
zod_1.z.object({ outcome: zod_1.z.literal('revoked'), serials: zod_1.z.array(zod_1.z.string()) }),
|
|
42
|
+
zod_1.z.object({ outcome: zod_1.z.literal('registry-unavailable') }),
|
|
43
|
+
zod_1.z.object({ outcome: zod_1.z.literal('failed'), error: zod_1.z.string() }),
|
|
44
|
+
]),
|
|
38
45
|
/** One operator-grade sentence naming what happened. */
|
|
39
46
|
message: zod_1.z.string(),
|
|
40
47
|
});
|
|
@@ -62,9 +69,9 @@ function createClusterNodesRouter(agentRegistry) {
|
|
|
62
69
|
await agentRegistry.repairNodeAddons(input.nodeId);
|
|
63
70
|
return { success: true };
|
|
64
71
|
}),
|
|
65
|
-
//
|
|
66
|
-
// `pipelineOrchestrator.removeAgentSettings` on the frontend
|
|
67
|
-
//
|
|
72
|
+
// Forget a node, online or offline: revoke its certificates, cut it from routing, purge its
|
|
73
|
+
// persisted history. Pairs with `pipelineOrchestrator.removeAgentSettings` on the frontend
|
|
74
|
+
// (per-node pipeline assignments).
|
|
68
75
|
forgetNode: trpc_middleware_js_1.adminProcedure
|
|
69
76
|
.input(ForgetNodeInputSchema)
|
|
70
77
|
.output(ForgetNodeResultSchema)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createProcessMembershipResponder = createProcessMembershipResponder;
|
|
4
|
+
exports.registerMeshMembershipRoutes = registerMeshMembershipRoutes;
|
|
5
|
+
const system_1 = require("@camstack/system");
|
|
6
|
+
const auth_rate_limit_1 = require("../auth/auth-rate-limit");
|
|
7
|
+
const WINDOW_MS = 60_000;
|
|
8
|
+
const DEFAULT_PER_MINUTE = 60;
|
|
9
|
+
function createProcessMembershipResponder(logger) {
|
|
10
|
+
return new system_1.MembershipResponder({
|
|
11
|
+
registry: system_1.getProcessMeshCertificateRegistry,
|
|
12
|
+
crl: system_1.processMeshCrl,
|
|
13
|
+
caKeyPem: () => {
|
|
14
|
+
const state = (0, system_1.getProcessMeshCaState)();
|
|
15
|
+
return state.state === 'ready' ? state.ca.keyPem : null;
|
|
16
|
+
},
|
|
17
|
+
log: (message, meta) => logger.warn(message, { tags: { nodeId: meta.nodeId } }),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function registerMeshMembershipRoutes(fastify, options) {
|
|
21
|
+
const responder = options.responder ?? createProcessMembershipResponder(options.logger);
|
|
22
|
+
const limiter = (0, auth_rate_limit_1.createRateLimiter)({
|
|
23
|
+
windowMs: WINDOW_MS,
|
|
24
|
+
max: options.perMinute ?? DEFAULT_PER_MINUTE,
|
|
25
|
+
...(options.now !== undefined ? { now: options.now } : {}),
|
|
26
|
+
});
|
|
27
|
+
fastify.post(system_1.MEMBERSHIP_PATHS.challenge, async (request, reply) => {
|
|
28
|
+
const verdict = limiter.check(request.socket.remoteAddress ?? 'unknown');
|
|
29
|
+
if (!verdict.allowed) {
|
|
30
|
+
return reply
|
|
31
|
+
.status(429)
|
|
32
|
+
.header('retry-after', String(verdict.retryAfterSeconds))
|
|
33
|
+
.send({ ok: false });
|
|
34
|
+
}
|
|
35
|
+
return { nonce: responder.issueChallenge() };
|
|
36
|
+
});
|
|
37
|
+
fastify.post(system_1.MEMBERSHIP_PATHS.query, async (request, reply) => {
|
|
38
|
+
const verdict = limiter.check(request.socket.remoteAddress ?? 'unknown');
|
|
39
|
+
if (!verdict.allowed) {
|
|
40
|
+
return reply
|
|
41
|
+
.status(429)
|
|
42
|
+
.header('retry-after', String(verdict.retryAfterSeconds))
|
|
43
|
+
.send({ ok: false });
|
|
44
|
+
}
|
|
45
|
+
const outcome = responder.answer(request.body);
|
|
46
|
+
if (!outcome.ok)
|
|
47
|
+
return reply.status(outcome.status).send({ ok: false });
|
|
48
|
+
return { token: outcome.token };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// AUTO-GENERATED by scripts/generate-cap-mounts.ts — DO NOT EDIT
|
|
3
3
|
// Re-run: npx tsx scripts/generate-cap-mounts.ts
|
|
4
4
|
//
|
|
5
|
-
// Mounted:
|
|
5
|
+
// Mounted: 189 Skipped (legacy): 3
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
exports.LEGACY_SHAPE_SKIP = void 0;
|
|
8
8
|
exports.mountAllCaps = mountAllCaps;
|
|
@@ -231,6 +231,8 @@ function mountAllCaps(services) {
|
|
|
231
231
|
...first,
|
|
232
232
|
listSupportedDeviceKinds: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listSupportedDeviceKinds'),
|
|
233
233
|
listExposedDevices: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listExposedDevices'),
|
|
234
|
+
listSupportedEntityKinds: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listSupportedEntityKinds'),
|
|
235
|
+
listExposedEntities: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listExposedEntities'),
|
|
234
236
|
};
|
|
235
237
|
}, remoteCapProxy),
|
|
236
238
|
deviceExtension: (0, generated_cap_routers_js_1.createCapRouter_deviceExtension)((_ctx) => (0, device_collection_dispatch_js_1.requireDeviceCollection)(reg, 'device-extension'), remoteCapProxy),
|
|
@@ -456,6 +458,15 @@ function mountAllCaps(services) {
|
|
|
456
458
|
null, remoteCapProxy),
|
|
457
459
|
nodeCertificates: (0, generated_cap_routers_js_1.createCapRouter_nodeCertificates)((_ctx) => reg?.getSingleton('node-certificates') ??
|
|
458
460
|
null, remoteCapProxy),
|
|
461
|
+
nodeRemovalHook: (0, generated_cap_routers_js_1.createCapRouter_nodeRemovalHook)((_ctx, addonId) => {
|
|
462
|
+
if (!reg)
|
|
463
|
+
return null;
|
|
464
|
+
if (addonId !== undefined) {
|
|
465
|
+
return reg.getProviderByAddonId('node-removal-hook', addonId);
|
|
466
|
+
}
|
|
467
|
+
const entries = reg.getCollectionEntries('node-removal-hook');
|
|
468
|
+
return entries[0]?.[1] ?? null;
|
|
469
|
+
}, remoteCapProxy),
|
|
459
470
|
nodes: (0, generated_cap_routers_js_1.createCapRouter_nodes)((_ctx) => reg?.getSingleton('nodes') ?? null, remoteCapProxy),
|
|
460
471
|
notificationOutput: (0, generated_cap_routers_js_1.createCapRouter_notificationOutput)((_ctx, addonId) => {
|
|
461
472
|
if (!reg)
|
|
@@ -492,6 +503,7 @@ function mountAllCaps(services) {
|
|
|
492
503
|
osd: (0, generated_cap_routers_js_1.createCapRouter_osd)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'osd'), remoteCapProxy),
|
|
493
504
|
osdManager: (0, generated_cap_routers_js_1.createCapRouter_osdManager)((_ctx) => reg?.getSingleton('osd-manager') ?? null, remoteCapProxy),
|
|
494
505
|
petFeeder: (0, generated_cap_routers_js_1.createCapRouter_petFeeder)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'pet-feeder'), remoteCapProxy),
|
|
506
|
+
petFountain: (0, generated_cap_routers_js_1.createCapRouter_petFountain)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'pet-fountain'), remoteCapProxy),
|
|
495
507
|
pipelineAnalytics: (0, generated_cap_routers_js_1.createCapRouter_pipelineAnalytics)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'pipeline-analytics'), remoteCapProxy),
|
|
496
508
|
pipelineExecutor: (0, generated_cap_routers_js_1.createCapRouter_pipelineExecutor)((_ctx) => reg?.getSingleton('pipeline-executor') ??
|
|
497
509
|
null, remoteCapProxy),
|