@camstack/server 1.2.563 → 1.2.565
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-http.js +23 -2
- package/dist/agent/agent-mesh-enroll.js +2 -0
- package/dist/agent/agent-node-certificates.js +294 -0
- package/dist/agent/agent-reconnect.js +34 -0
- package/dist/agent/agent-sniffing-listener.js +263 -0
- package/dist/agent/agent-update-service.js +4 -1
- package/dist/agent/main.js +101 -37
- package/dist/agent/serial-queue.js +49 -0
- package/dist/api/core/auth-device-cert.procedures.js +435 -0
- package/dist/api/core/auth-sessions.procedures.js +20 -2
- package/dist/api/core/auth.router.js +190 -7
- package/dist/api/core/certificates.router.js +505 -0
- package/dist/api/mesh/enroll.routes.js +7 -1
- package/dist/api/trpc/generated-cap-mounts.js +4 -1
- package/dist/api/trpc/generated-cap-routers.js +2006 -1859
- package/dist/api/trpc/trpc.router.js +45 -0
- package/dist/auth/device-enroll-proofs.js +148 -0
- package/dist/auth/hub-sessions.js +2 -0
- package/dist/auth/session-mint.js +24 -2
- package/dist/auth/sso-routes.js +1 -0
- package/dist/core/agent/agent-registry.service.js +7 -0
- package/dist/core/agent/node-certificates-client.js +38 -0
- package/dist/core/auth/handoff-code.service.js +27 -4
- package/dist/core/moleculer/moleculer.service.js +5 -0
- package/package.json +9 -9
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createCertificatesRouter = createCertificatesRouter;
|
|
4
|
+
/**
|
|
5
|
+
* Certificates inventory and management - fixed core API for Identity ->
|
|
6
|
+
* Certificates (D855, D856).
|
|
7
|
+
*
|
|
8
|
+
* Hand-written (not a `*.cap.ts`) like `cluster-nodes.router.ts`: it joins the node
|
|
9
|
+
* list (agent registry), the `local-network` provider (the hub's HTTPS leaf), the
|
|
10
|
+
* mesh CA and the registry of mesh leaves, and calls each agent's
|
|
11
|
+
* `node-certificates` capability. Device credentials are the `device-credentials`
|
|
12
|
+
* capability; this router never touches them.
|
|
13
|
+
*
|
|
14
|
+
* - HTTPS: the hub serves its own leaf (renewed with `regenerateCertificate`);
|
|
15
|
+
* every agent serves a leaf the HUB issued from its local CA, on the same port
|
|
16
|
+
* as its plain HTTP. An agent without one yet says so (`not-served`, with the
|
|
17
|
+
* reason), never an empty row that reads as "no certificate".
|
|
18
|
+
* - Mesh: every leaf the hub issues is recorded; a node enrolled earlier is
|
|
19
|
+
* recorded from the leaf it presents on its live connection (`observed`).
|
|
20
|
+
* Renewal is a handover that never leaves a node without a working leaf
|
|
21
|
+
* (`mesh-renewal.ts`); revocation puts the serial on the CRL and disconnects
|
|
22
|
+
* the node, which must re-join with a new join token. Neither forgets a node:
|
|
23
|
+
* that stays `clusterNodes.forgetNode`.
|
|
24
|
+
*/
|
|
25
|
+
const node_crypto_1 = require("node:crypto");
|
|
26
|
+
const system_1 = require("@camstack/system");
|
|
27
|
+
const server_1 = require("@trpc/server");
|
|
28
|
+
const zod_1 = require("zod");
|
|
29
|
+
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
30
|
+
const HUB_NODE_ID = 'hub';
|
|
31
|
+
const EXPIRING_SOON_MS = 7 * 86_400_000;
|
|
32
|
+
const TlsStatusRowSchema = zod_1.z.object({
|
|
33
|
+
mode: zod_1.z.enum(['generated', 'uploaded', 'disabled']),
|
|
34
|
+
leafFingerprintSha256: zod_1.z.string().nullable(),
|
|
35
|
+
caFingerprintSha256: zod_1.z.string().nullable(),
|
|
36
|
+
validTo: zod_1.z.string().nullable(),
|
|
37
|
+
sans: zod_1.z.array(zod_1.z.string()),
|
|
38
|
+
reissueError: zod_1.z.string().nullable(),
|
|
39
|
+
restartRequired: zod_1.z.boolean(),
|
|
40
|
+
});
|
|
41
|
+
const NodeHttpsSchema = zod_1.z.object({
|
|
42
|
+
nodeId: zod_1.z.string(),
|
|
43
|
+
isHub: zod_1.z.boolean(),
|
|
44
|
+
online: zod_1.z.boolean(),
|
|
45
|
+
https: zod_1.z.discriminatedUnion('kind', [
|
|
46
|
+
zod_1.z.object({ kind: zod_1.z.literal('served'), status: TlsStatusRowSchema }),
|
|
47
|
+
zod_1.z.object({ kind: zod_1.z.literal('not-served'), reason: zod_1.z.string() }),
|
|
48
|
+
/** The hub could not read it right now: say so, never present it as "none". */
|
|
49
|
+
zod_1.z.object({ kind: zod_1.z.literal('unreadable'), reason: zod_1.z.string() }),
|
|
50
|
+
]),
|
|
51
|
+
});
|
|
52
|
+
const MeshLeafRowSchema = zod_1.z.object({
|
|
53
|
+
serial: zod_1.z.string(),
|
|
54
|
+
fingerprintSha256: zod_1.z.string(),
|
|
55
|
+
notBefore: zod_1.z.number(),
|
|
56
|
+
notAfter: zod_1.z.number(),
|
|
57
|
+
issuedAt: zod_1.z.number(),
|
|
58
|
+
/** `enroll` | `renewal` | `boot` were issued by the hub; `observed` was read off a live connection. */
|
|
59
|
+
issuedVia: zod_1.z.enum(['enroll', 'renewal', 'boot', 'observed']),
|
|
60
|
+
status: zod_1.z.enum(['valid', 'expiring', 'expired', 'revoked']),
|
|
61
|
+
revokedAt: zod_1.z.number().nullable(),
|
|
62
|
+
revokedReason: zod_1.z.string().nullable(),
|
|
63
|
+
});
|
|
64
|
+
const MeshNodeSchema = zod_1.z.object({
|
|
65
|
+
nodeId: zod_1.z.string(),
|
|
66
|
+
online: zod_1.z.boolean(),
|
|
67
|
+
/** The leaf the node holds now: the newest non-revoked one. `null` when none is known. */
|
|
68
|
+
current: MeshLeafRowSchema.nullable(),
|
|
69
|
+
/** The node is connected and presents exactly `current`. `null` when it is offline. */
|
|
70
|
+
presentingCurrent: zod_1.z.boolean().nullable(),
|
|
71
|
+
/** Older and revoked leaves, newest first. */
|
|
72
|
+
history: zod_1.z.array(MeshLeafRowSchema),
|
|
73
|
+
});
|
|
74
|
+
const MeshInventorySchema = zod_1.z.object({
|
|
75
|
+
ca: zod_1.z.discriminatedUnion('state', [
|
|
76
|
+
zod_1.z.object({
|
|
77
|
+
state: zod_1.z.literal('ready'),
|
|
78
|
+
subject: zod_1.z.string(),
|
|
79
|
+
fingerprintSha256: zod_1.z.string(),
|
|
80
|
+
validTo: zod_1.z.string(),
|
|
81
|
+
}),
|
|
82
|
+
zod_1.z.object({ state: zod_1.z.literal('unavailable'), reason: zod_1.z.string() }),
|
|
83
|
+
]),
|
|
84
|
+
/** Serials the hub refuses at the mesh handshake. Persisted with the registry and replayed at boot. */
|
|
85
|
+
revokedSerials: zod_1.z.array(zod_1.z.string()),
|
|
86
|
+
/** `false` while the registry is not up (no settings store): the node list is then incomplete. */
|
|
87
|
+
registryAvailable: zod_1.z.boolean(),
|
|
88
|
+
/** The hub's mesh TLS listener: `closed` means every agent is refused (e.g. revocations not loaded). */
|
|
89
|
+
listener: zod_1.z.discriminatedUnion('state', [
|
|
90
|
+
zod_1.z.object({ state: zod_1.z.literal('open') }),
|
|
91
|
+
zod_1.z.object({ state: zod_1.z.literal('closed'), reason: zod_1.z.string(), since: zod_1.z.number() }),
|
|
92
|
+
zod_1.z.object({ state: zod_1.z.literal('not-started') }),
|
|
93
|
+
]),
|
|
94
|
+
nodes: zod_1.z.array(MeshNodeSchema),
|
|
95
|
+
});
|
|
96
|
+
const MeshRenewResultSchema = zod_1.z.discriminatedUnion('ok', [
|
|
97
|
+
zod_1.z.object({
|
|
98
|
+
ok: zod_1.z.literal(true),
|
|
99
|
+
nodeId: zod_1.z.string(),
|
|
100
|
+
oldSerial: zod_1.z.string().nullable(),
|
|
101
|
+
newSerial: zod_1.z.string(),
|
|
102
|
+
newNotAfter: zod_1.z.number(),
|
|
103
|
+
}),
|
|
104
|
+
zod_1.z.object({
|
|
105
|
+
ok: zod_1.z.literal(false),
|
|
106
|
+
nodeId: zod_1.z.string(),
|
|
107
|
+
stage: zod_1.z.enum(['prepare', 'issue', 'install', 'reconnect', 'confirm']),
|
|
108
|
+
reason: zod_1.z.string(),
|
|
109
|
+
/** The node is on its previous leaf (or never left it): nothing was broken. */
|
|
110
|
+
keptPreviousLeaf: zod_1.z.boolean(),
|
|
111
|
+
}),
|
|
112
|
+
]);
|
|
113
|
+
const MeshRevokeResultSchema = zod_1.z.object({
|
|
114
|
+
nodeId: zod_1.z.string(),
|
|
115
|
+
revokedSerials: zod_1.z.array(zod_1.z.string()),
|
|
116
|
+
disconnected: zod_1.z.number().int(),
|
|
117
|
+
/** What the operator does next. Revoking does not forget the node. */
|
|
118
|
+
next: zod_1.z.string(),
|
|
119
|
+
});
|
|
120
|
+
function leafStatus(record, at) {
|
|
121
|
+
if (record.revokedAt !== null)
|
|
122
|
+
return 'revoked';
|
|
123
|
+
if (record.notAfter <= at)
|
|
124
|
+
return 'expired';
|
|
125
|
+
return record.notAfter - at < EXPIRING_SOON_MS ? 'expiring' : 'valid';
|
|
126
|
+
}
|
|
127
|
+
function createCertificatesRouter(deps) {
|
|
128
|
+
const localNetwork = () => {
|
|
129
|
+
const provider = deps.registry?.getSingleton('local-network');
|
|
130
|
+
if (!provider) {
|
|
131
|
+
throw new server_1.TRPCError({
|
|
132
|
+
code: 'PRECONDITION_FAILED',
|
|
133
|
+
message: 'The local-network capability is not available yet',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return provider;
|
|
137
|
+
};
|
|
138
|
+
const peers = deps.peers ?? {
|
|
139
|
+
peerLeaf: (nodeId) => (0, system_1.getProcessPeerLeaf)(nodeId),
|
|
140
|
+
dropPeer: (nodeId) => (0, system_1.dropProcessPeer)(nodeId),
|
|
141
|
+
};
|
|
142
|
+
const meshRegistry = deps.meshRegistry ?? system_1.getProcessMeshCertificateRegistry;
|
|
143
|
+
const crl = deps.crl ?? system_1.processMeshCrl;
|
|
144
|
+
const meshCa = deps.meshCa ??
|
|
145
|
+
(() => {
|
|
146
|
+
const state = (0, system_1.getProcessMeshCaState)();
|
|
147
|
+
return state.state === 'ready' ? state.ca : null;
|
|
148
|
+
});
|
|
149
|
+
const issueHttps = deps.issueHttps ?? system_1.issueAgentHttpsLeaf;
|
|
150
|
+
const listenerState = deps.listenerState ??
|
|
151
|
+
(() => (0, system_1.getProcessMeshTlsListenerState)() ?? { state: 'not-started' });
|
|
152
|
+
const httpsNames = deps.httpsNames ?? system_1.getProcessAgentHttpsNames;
|
|
153
|
+
const registrations = deps.registrations ?? {
|
|
154
|
+
subscribe: (listener) => (0, system_1.onProcessNodeRegistered)(listener),
|
|
155
|
+
};
|
|
156
|
+
/** One certificate operation per node at a time: a second one is refused, not interleaved. */
|
|
157
|
+
const busy = new Set();
|
|
158
|
+
const exclusive = async (key, what, run) => {
|
|
159
|
+
if (busy.has(key)) {
|
|
160
|
+
deps.log.warn('Certificate operation refused: another one is running on this node', {
|
|
161
|
+
tags: { nodeId: key },
|
|
162
|
+
meta: { what },
|
|
163
|
+
});
|
|
164
|
+
throw new server_1.TRPCError({
|
|
165
|
+
code: 'CONFLICT',
|
|
166
|
+
message: `Another certificate operation is already running on ${key}. Wait for it to finish.`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
busy.add(key);
|
|
170
|
+
try {
|
|
171
|
+
return await run();
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
busy.delete(key);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const requireAgent = (nodeId) => {
|
|
178
|
+
if (nodeId === HUB_NODE_ID) {
|
|
179
|
+
throw new server_1.TRPCError({
|
|
180
|
+
code: 'PRECONDITION_FAILED',
|
|
181
|
+
message: 'The hub is not an agent: its HTTPS leaf is renewed with renewHttpsOnNode on "hub", and its mesh leaf is re-issued at every boot.',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const client = deps.nodeClient(nodeId);
|
|
185
|
+
if (client === null) {
|
|
186
|
+
throw new server_1.TRPCError({
|
|
187
|
+
code: 'PRECONDITION_FAILED',
|
|
188
|
+
message: `Node ${nodeId} is not reachable, or runs an agent too old to manage its certificates (update it).`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return client;
|
|
192
|
+
};
|
|
193
|
+
/** Record the leaf each connected node presents, for nodes enrolled before the registry existed. */
|
|
194
|
+
const observeConnectedNodes = async (registry) => {
|
|
195
|
+
for (const node of deps.agentRegistry.listNodeLiveness()) {
|
|
196
|
+
if (node.id === HUB_NODE_ID || !node.isOnline)
|
|
197
|
+
continue;
|
|
198
|
+
const leaf = peers.peerLeaf(node.id);
|
|
199
|
+
if (leaf === undefined)
|
|
200
|
+
continue;
|
|
201
|
+
await registry.recordObserved(node.id, {
|
|
202
|
+
cn: node.id,
|
|
203
|
+
serial: leaf.serial,
|
|
204
|
+
fingerprint: leaf.fingerprint,
|
|
205
|
+
notBefore: leaf.notBefore,
|
|
206
|
+
notAfter: leaf.notAfter,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
const statusOfLeaf = (leaf) => ({
|
|
211
|
+
mode: 'generated',
|
|
212
|
+
leafFingerprintSha256: leaf.fingerprintSha256,
|
|
213
|
+
caFingerprintSha256: null,
|
|
214
|
+
validTo: new Date(leaf.notAfter).toISOString(),
|
|
215
|
+
sans: leaf.sans,
|
|
216
|
+
reissueError: null,
|
|
217
|
+
restartRequired: false,
|
|
218
|
+
});
|
|
219
|
+
return (0, trpc_middleware_js_1.trpcRouter)({
|
|
220
|
+
/** HTTPS certificate state per node. The hub serves its own; an agent serves a hub-issued leaf. */
|
|
221
|
+
listHttpsCertificates: trpc_middleware_js_1.adminProcedure
|
|
222
|
+
.input(zod_1.z.void())
|
|
223
|
+
.output(zod_1.z.array(NodeHttpsSchema))
|
|
224
|
+
.query(async () => {
|
|
225
|
+
const nodes = deps.agentRegistry.listNodeLiveness();
|
|
226
|
+
const entries = nodes.some((n) => n.id === HUB_NODE_ID)
|
|
227
|
+
? nodes
|
|
228
|
+
: [{ id: HUB_NODE_ID, isOnline: true }, ...nodes];
|
|
229
|
+
const rows = [];
|
|
230
|
+
for (const node of entries) {
|
|
231
|
+
const isHub = node.id === HUB_NODE_ID;
|
|
232
|
+
if (isHub) {
|
|
233
|
+
try {
|
|
234
|
+
const status = await localNetwork().getTlsStatus();
|
|
235
|
+
const { caCertPem: _pem, ...rest } = status;
|
|
236
|
+
rows.push({
|
|
237
|
+
nodeId: node.id,
|
|
238
|
+
isHub,
|
|
239
|
+
online: true,
|
|
240
|
+
https: { kind: 'served', status: rest },
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
rows.push({
|
|
245
|
+
nodeId: node.id,
|
|
246
|
+
isHub,
|
|
247
|
+
online: true,
|
|
248
|
+
https: {
|
|
249
|
+
kind: 'unreadable',
|
|
250
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (!node.isOnline) {
|
|
257
|
+
rows.push({
|
|
258
|
+
nodeId: node.id,
|
|
259
|
+
isHub,
|
|
260
|
+
online: false,
|
|
261
|
+
https: {
|
|
262
|
+
kind: 'unreadable',
|
|
263
|
+
reason: 'The node is offline - its certificate cannot be read.',
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const client = deps.nodeClient(node.id);
|
|
269
|
+
if (client === null) {
|
|
270
|
+
rows.push({
|
|
271
|
+
nodeId: node.id,
|
|
272
|
+
isHub,
|
|
273
|
+
online: true,
|
|
274
|
+
https: {
|
|
275
|
+
kind: 'unreadable',
|
|
276
|
+
reason: 'The node does not provide node-certificates (update the agent).',
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const answer = await client.getCertificates();
|
|
283
|
+
rows.push({
|
|
284
|
+
nodeId: node.id,
|
|
285
|
+
isHub,
|
|
286
|
+
online: true,
|
|
287
|
+
https: answer.https.state === 'served'
|
|
288
|
+
? { kind: 'served', status: statusOfLeaf(answer.https.leaf) }
|
|
289
|
+
: { kind: 'not-served', reason: answer.https.reason },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
rows.push({
|
|
294
|
+
nodeId: node.id,
|
|
295
|
+
isHub,
|
|
296
|
+
online: true,
|
|
297
|
+
https: {
|
|
298
|
+
kind: 'unreadable',
|
|
299
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return rows;
|
|
305
|
+
}),
|
|
306
|
+
/**
|
|
307
|
+
* Issue a new HTTPS leaf. On the hub: a new leaf under the local CA
|
|
308
|
+
* (`regenerateCertificate`; refused while an uploaded certificate is served). On an
|
|
309
|
+
* agent: the agent makes a key + CSR, the hub signs the public key under the same
|
|
310
|
+
* local CA for the names the agent reports (private addresses and local-only host
|
|
311
|
+
* names), and the agent hot-swaps it - no restart, no private key on the mesh.
|
|
312
|
+
*/
|
|
313
|
+
renewHttpsOnNode: trpc_middleware_js_1.adminProcedure
|
|
314
|
+
.input(zod_1.z.object({ nodeId: zod_1.z.string().min(1), reason: zod_1.z.string().max(200).optional() }))
|
|
315
|
+
.output(TlsStatusRowSchema)
|
|
316
|
+
.mutation(async ({ input }) => {
|
|
317
|
+
if (input.nodeId === HUB_NODE_ID) {
|
|
318
|
+
const status = await localNetwork().regenerateCertificate({
|
|
319
|
+
...(input.reason !== undefined ? { reason: input.reason } : {}),
|
|
320
|
+
});
|
|
321
|
+
const { caCertPem: _pem, ...rest } = status;
|
|
322
|
+
return rest;
|
|
323
|
+
}
|
|
324
|
+
const client = requireAgent(input.nodeId);
|
|
325
|
+
const tags = { nodeId: input.nodeId };
|
|
326
|
+
return exclusive(input.nodeId, 'https', async () => {
|
|
327
|
+
try {
|
|
328
|
+
const prepared = await client.prepareHttpsCertificate();
|
|
329
|
+
// The hub signs what it knows by itself: the node id and the address of the node's
|
|
330
|
+
// LIVE mesh socket. Whatever the agent reports is dropped (and logged below).
|
|
331
|
+
const observedAddress = peers.peerLeaf(input.nodeId)?.remoteAddress;
|
|
332
|
+
if (observedAddress === undefined) {
|
|
333
|
+
throw new Error('the node has no live mesh connection the hub can read its address from - connect it first');
|
|
334
|
+
}
|
|
335
|
+
// Without the names registry the hub cannot tell whether a name is already another
|
|
336
|
+
// node's, so it signs nothing.
|
|
337
|
+
const names = httpsNames();
|
|
338
|
+
if (names === null) {
|
|
339
|
+
throw new Error('the agent HTTPS names registry is not loaded yet - try again shortly');
|
|
340
|
+
}
|
|
341
|
+
const issued = await issueHttps({
|
|
342
|
+
hubDataDir: deps.hubDataDir,
|
|
343
|
+
nodeId: input.nodeId,
|
|
344
|
+
csrPem: prepared.csrPem,
|
|
345
|
+
reportedNames: [...prepared.hostnames, ...prepared.addresses],
|
|
346
|
+
observedAddress,
|
|
347
|
+
takenByOthers: names.takenByOthers(input.nodeId),
|
|
348
|
+
});
|
|
349
|
+
// Written down BEFORE the install answer: if the answer is lost the names stay taken
|
|
350
|
+
// (the node may hold the leaf), never silently free for another node.
|
|
351
|
+
await names.record(input.nodeId, issued.accepted);
|
|
352
|
+
// The hub's local CA was rotated (D227) when the node pinned another one: say so, over
|
|
353
|
+
// the authenticated install call, so the node re-pins instead of refusing for ever.
|
|
354
|
+
const caRotation = prepared.pinnedCaFingerprintSha256 !== null &&
|
|
355
|
+
prepared.pinnedCaFingerprintSha256 !==
|
|
356
|
+
new node_crypto_1.X509Certificate(issued.caCertPem).fingerprint256;
|
|
357
|
+
deps.log.info('Agent HTTPS leaf issued', {
|
|
358
|
+
tags,
|
|
359
|
+
meta: { accepted: issued.accepted, rejected: issued.rejected },
|
|
360
|
+
});
|
|
361
|
+
const leaf = await client.installHttpsCertificate({
|
|
362
|
+
certPem: issued.certPem,
|
|
363
|
+
caCertPem: issued.caCertPem,
|
|
364
|
+
...(caRotation ? { caRotation: true } : {}),
|
|
365
|
+
});
|
|
366
|
+
deps.log.info('Agent HTTPS leaf installed', {
|
|
367
|
+
tags,
|
|
368
|
+
meta: { fingerprint: leaf.fingerprintSha256 },
|
|
369
|
+
});
|
|
370
|
+
return statusOfLeaf(leaf);
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
374
|
+
deps.log.error('Agent HTTPS renewal failed', { tags, meta: { error: message } });
|
|
375
|
+
throw new server_1.TRPCError({
|
|
376
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
377
|
+
message: `HTTPS renewal on ${input.nodeId} failed: ${message}. The node keeps serving what it had.`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
}),
|
|
382
|
+
/** The mesh CA, the revocation list, and every node's recorded leaves. */
|
|
383
|
+
getMeshCertificates: trpc_middleware_js_1.adminProcedure
|
|
384
|
+
.input(zod_1.z.void())
|
|
385
|
+
.output(MeshInventorySchema)
|
|
386
|
+
.query(async () => {
|
|
387
|
+
const state = (0, system_1.getProcessMeshCaState)();
|
|
388
|
+
const ca = state.state === 'ready'
|
|
389
|
+
? {
|
|
390
|
+
state: 'ready',
|
|
391
|
+
subject: new node_crypto_1.X509Certificate(state.ca.certPem).subject,
|
|
392
|
+
fingerprintSha256: (0, system_1.caFingerprint)(state.ca.certPem),
|
|
393
|
+
validTo: new node_crypto_1.X509Certificate(state.ca.certPem).validTo,
|
|
394
|
+
}
|
|
395
|
+
: { state: 'unavailable', reason: state.reason };
|
|
396
|
+
const registry = meshRegistry();
|
|
397
|
+
const at = Date.now();
|
|
398
|
+
const nodes = [];
|
|
399
|
+
if (registry !== null) {
|
|
400
|
+
await observeConnectedNodes(registry);
|
|
401
|
+
const liveness = new Map(deps.agentRegistry.listNodeLiveness().map((n) => [n.id, n.isOnline]));
|
|
402
|
+
const nodeIds = new Set([...registry.list().map((r) => r.nodeId), ...liveness.keys()]);
|
|
403
|
+
for (const nodeId of [...nodeIds].toSorted()) {
|
|
404
|
+
const rows = registry.list(nodeId).map((r) => ({
|
|
405
|
+
serial: r.serial,
|
|
406
|
+
fingerprintSha256: r.fingerprint,
|
|
407
|
+
notBefore: r.notBefore,
|
|
408
|
+
notAfter: r.notAfter,
|
|
409
|
+
issuedAt: r.issuedAt,
|
|
410
|
+
issuedVia: r.issuedVia,
|
|
411
|
+
status: leafStatus(r, at),
|
|
412
|
+
revokedAt: r.revokedAt,
|
|
413
|
+
revokedReason: r.revokedReason,
|
|
414
|
+
}));
|
|
415
|
+
const current = rows.find((r) => r.status !== 'revoked') ?? null;
|
|
416
|
+
const online = nodeId === HUB_NODE_ID ? true : (liveness.get(nodeId) ?? false);
|
|
417
|
+
const presented = nodeId === HUB_NODE_ID ? undefined : peers.peerLeaf(nodeId);
|
|
418
|
+
nodes.push({
|
|
419
|
+
nodeId,
|
|
420
|
+
online,
|
|
421
|
+
current,
|
|
422
|
+
presentingCurrent: !online || nodeId === HUB_NODE_ID || current === null
|
|
423
|
+
? null
|
|
424
|
+
: presented?.fingerprint === current.fingerprintSha256,
|
|
425
|
+
history: rows.filter((r) => r !== current),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
ca,
|
|
431
|
+
revokedSerials: [...crl().list()],
|
|
432
|
+
registryAvailable: registry !== null,
|
|
433
|
+
listener: listenerState(),
|
|
434
|
+
nodes,
|
|
435
|
+
};
|
|
436
|
+
}),
|
|
437
|
+
/**
|
|
438
|
+
* Replace a node's mesh leaf without ever leaving it without one: the node makes a new
|
|
439
|
+
* key and CSR, the hub signs and records it, the node swaps and reconnects, and only
|
|
440
|
+
* after the hub SEES it connected with the new leaf is the old serial revoked. A failed
|
|
441
|
+
* swap keeps the old leaf valid and says why.
|
|
442
|
+
*/
|
|
443
|
+
renewMeshOnNode: trpc_middleware_js_1.adminProcedure
|
|
444
|
+
.input(zod_1.z.object({ nodeId: zod_1.z.string().min(1) }))
|
|
445
|
+
.output(MeshRenewResultSchema)
|
|
446
|
+
.mutation(async ({ input }) => {
|
|
447
|
+
const client = requireAgent(input.nodeId);
|
|
448
|
+
const ca = meshCa();
|
|
449
|
+
const registry = meshRegistry();
|
|
450
|
+
if (ca === null || registry === null) {
|
|
451
|
+
throw new server_1.TRPCError({
|
|
452
|
+
code: 'PRECONDITION_FAILED',
|
|
453
|
+
message: 'The mesh CA or the certificate registry is not available yet.',
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
const result = await exclusive(input.nodeId, 'mesh', () => (0, system_1.renewMeshCertificate)({
|
|
457
|
+
nodeId: input.nodeId,
|
|
458
|
+
client,
|
|
459
|
+
ca,
|
|
460
|
+
registry,
|
|
461
|
+
crl: crl(),
|
|
462
|
+
peers,
|
|
463
|
+
registrations,
|
|
464
|
+
log: deps.log,
|
|
465
|
+
...(deps.reconnectTimeoutMs !== undefined
|
|
466
|
+
? { reconnectTimeoutMs: deps.reconnectTimeoutMs }
|
|
467
|
+
: {}),
|
|
468
|
+
...(deps.sleep !== undefined ? { sleep: deps.sleep } : {}),
|
|
469
|
+
...(deps.setTimer !== undefined ? { setTimer: deps.setTimer } : {}),
|
|
470
|
+
}));
|
|
471
|
+
return { ...result, nodeId: input.nodeId };
|
|
472
|
+
}),
|
|
473
|
+
/**
|
|
474
|
+
* Put the node's mesh leaf on the CRL and disconnect it. The node can no longer
|
|
475
|
+
* connect: it must re-join with a new join token. It is NOT forgotten - that stays
|
|
476
|
+
* `clusterNodes.forgetNode`.
|
|
477
|
+
*/
|
|
478
|
+
revokeMeshOnNode: trpc_middleware_js_1.adminProcedure
|
|
479
|
+
.input(zod_1.z.object({ nodeId: zod_1.z.string().min(1) }))
|
|
480
|
+
.output(MeshRevokeResultSchema)
|
|
481
|
+
.mutation(async ({ input }) => {
|
|
482
|
+
if (input.nodeId === HUB_NODE_ID) {
|
|
483
|
+
throw new server_1.TRPCError({
|
|
484
|
+
code: 'PRECONDITION_FAILED',
|
|
485
|
+
message: "The hub's own mesh leaf is re-issued at every boot; there is nothing to revoke.",
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
const registry = meshRegistry();
|
|
489
|
+
if (registry === null) {
|
|
490
|
+
throw new server_1.TRPCError({
|
|
491
|
+
code: 'PRECONDITION_FAILED',
|
|
492
|
+
message: 'The certificate registry is not available yet.',
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
const result = await (0, system_1.revokeMeshCertificate)({
|
|
496
|
+
nodeId: input.nodeId,
|
|
497
|
+
registry,
|
|
498
|
+
crl: crl(),
|
|
499
|
+
peers,
|
|
500
|
+
log: deps.log,
|
|
501
|
+
});
|
|
502
|
+
return { ...result, revokedSerials: [...result.revokedSerials] };
|
|
503
|
+
}),
|
|
504
|
+
});
|
|
505
|
+
}
|
|
@@ -12,7 +12,13 @@ function registerMeshEnrollRoute(fastify) {
|
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
14
|
const body = request.body ?? {};
|
|
15
|
-
const result = await (0, system_1.enrollMeshNode)({ token: body.token ?? '', csr: body.csr ?? '' }, {
|
|
15
|
+
const result = await (0, system_1.enrollMeshNode)({ token: body.token ?? '', csr: body.csr ?? '' }, {
|
|
16
|
+
ca: caState.ca,
|
|
17
|
+
onRecordFailed: (nodeId, error) => fastify.log.warn({ nodeId, error: error instanceof Error ? error.message : String(error) }, 'mesh enroll: the issued leaf could not be recorded'),
|
|
18
|
+
onIssued: async (nodeId, certPem) => {
|
|
19
|
+
await (0, system_1.getProcessMeshCertificateRegistry)()?.recordIssued(nodeId, certPem, 'enroll');
|
|
20
|
+
},
|
|
21
|
+
});
|
|
16
22
|
if (!result.ok) {
|
|
17
23
|
return reply.status(result.status).send({ ok: false, error: result.error });
|
|
18
24
|
}
|
|
@@ -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: 169 Skipped (legacy): 3
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
exports.LEGACY_SHAPE_SKIP = void 0;
|
|
8
8
|
exports.mountAllCaps = mountAllCaps;
|
|
@@ -188,6 +188,7 @@ function mountAllCaps(services) {
|
|
|
188
188
|
deviceAdminLink: (0, generated_cap_routers_js_1.createCapRouter_deviceAdminLink)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'device-admin-link'), remoteCapProxy),
|
|
189
189
|
deviceAdoption: (0, generated_cap_routers_js_1.createCapRouter_deviceAdoption)((_ctx) => reg?.getSingleton('device-adoption') ??
|
|
190
190
|
null, remoteCapProxy),
|
|
191
|
+
deviceCredentials: (0, generated_cap_routers_js_1.createCapRouter_deviceCredentials)((_ctx) => reg?.getSingleton('device-credentials') ?? null, remoteCapProxy),
|
|
191
192
|
deviceDiscovery: (0, generated_cap_routers_js_1.createCapRouter_deviceDiscovery)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'device-discovery'), remoteCapProxy),
|
|
192
193
|
deviceExport: (0, generated_cap_routers_js_1.createCapRouter_deviceExport)((_ctx, addonId) => {
|
|
193
194
|
if (!reg)
|
|
@@ -402,6 +403,8 @@ function mountAllCaps(services) {
|
|
|
402
403
|
networkLink: (0, generated_cap_routers_js_1.createCapRouter_networkLink)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'network-link'), remoteCapProxy),
|
|
403
404
|
networkQuality: (0, generated_cap_routers_js_1.createCapRouter_networkQuality)((_ctx) => reg?.getSingleton('network-quality') ??
|
|
404
405
|
null, remoteCapProxy),
|
|
406
|
+
nodeCertificates: (0, generated_cap_routers_js_1.createCapRouter_nodeCertificates)((_ctx) => reg?.getSingleton('node-certificates') ??
|
|
407
|
+
null, remoteCapProxy),
|
|
405
408
|
nodes: (0, generated_cap_routers_js_1.createCapRouter_nodes)((_ctx) => reg?.getSingleton('nodes') ?? null, remoteCapProxy),
|
|
406
409
|
notificationOutput: (0, generated_cap_routers_js_1.createCapRouter_notificationOutput)((_ctx, addonId) => {
|
|
407
410
|
if (!reg)
|