@forgezero/agent 0.1.57 → 0.1.59
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/README.md +17 -15
- package/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap.d.ts +22 -11
- package/dist/bootstrap.js +257 -159
- package/dist/cli/cloudflare-bootstrap.d.ts +14 -6
- package/dist/cloudflare-bootstrap.d.ts +10 -29
- package/dist/cloudflare-bootstrap.js +114 -81
- package/dist/cloudflare-edge.d.ts +28 -0
- package/dist/cloudflare-edge.js +78 -3
- package/dist/credential-schema.d.ts +9 -9
- package/dist/credential-schema.js +7 -7
- package/dist/fz-agent.js +27 -22
- package/dist/fz.js +392 -278
- package/dist/metal-bootstrap.js +1 -1
- package/dist/operator-bootstrap.d.ts +4 -1
- package/dist/operator-bootstrap.js +274 -179
- package/dist/platform-bootstrap-runtime.d.ts +1 -1
- package/dist/platform-bootstrap-runtime.js +3 -2
- package/dist/platform-fleet-verification.js +21 -14
- package/dist/provision.js +12 -6
- package/dist/recovery-host.js +2 -2
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -22,7 +22,7 @@ function privateDatabaseHostRoute(value) {
|
|
|
22
22
|
return `${address}/${isIP(address) === 4 ? 32 : 128}`;
|
|
23
23
|
}
|
|
24
24
|
var endpoint = "https://api.cloudflare.com/client/v4";
|
|
25
|
-
async function
|
|
25
|
+
async function cfEnvelope(config, path, init = {}, fetcher = fetch) {
|
|
26
26
|
const response = await fetcher(`${endpoint}${path}`, {
|
|
27
27
|
...init,
|
|
28
28
|
headers: {
|
|
@@ -35,7 +35,68 @@ async function cf(config, path, init = {}, fetcher = fetch) {
|
|
|
35
35
|
if (!response.ok || body.success !== true) {
|
|
36
36
|
throw new Error(body.errors?.map(({ message }) => message).filter(Boolean).join("; ") || `Cloudflare returned HTTP ${response.status}`);
|
|
37
37
|
}
|
|
38
|
-
return body
|
|
38
|
+
return body;
|
|
39
|
+
}
|
|
40
|
+
async function cf(config, path, init = {}, fetcher = fetch) {
|
|
41
|
+
return (await cfEnvelope(config, path, init, fetcher)).result;
|
|
42
|
+
}
|
|
43
|
+
async function cfPages(config, path, perPage, fetcher) {
|
|
44
|
+
const output = [];
|
|
45
|
+
for (let page = 1;page <= 100; page += 1) {
|
|
46
|
+
const separator = path.includes("?") ? "&" : "?";
|
|
47
|
+
const envelope = await cfEnvelope(config, `${path}${separator}page=${page}&per_page=${perPage}`, {}, fetcher);
|
|
48
|
+
const result = Array.isArray(envelope.result) ? envelope.result : [];
|
|
49
|
+
output.push(...result);
|
|
50
|
+
const totalPages = envelope.result_info?.total_pages;
|
|
51
|
+
if (Number.isInteger(totalPages) ? page >= totalPages : result.length < perPage)
|
|
52
|
+
return output;
|
|
53
|
+
}
|
|
54
|
+
throw new Error("Cloudflare pagination exceeded the reviewed 100-page bound");
|
|
55
|
+
}
|
|
56
|
+
var exactHexId = (value, label) => {
|
|
57
|
+
const normalized = String(value ?? "").trim().toLowerCase();
|
|
58
|
+
if (!/^[a-f0-9]{32}$/.test(normalized))
|
|
59
|
+
throw new Error(`${label} is malformed`);
|
|
60
|
+
return normalized;
|
|
61
|
+
};
|
|
62
|
+
async function discoverCloudflareBootstrapResources(config, fetcher = fetch) {
|
|
63
|
+
const zoneName = config.zoneName.trim().toLowerCase().replace(/\.$/, "");
|
|
64
|
+
const kvNamespaceTitle = config.kvNamespaceTitle.trim();
|
|
65
|
+
if (!/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(zoneName)) {
|
|
66
|
+
throw new Error("Cloudflare zone name is invalid");
|
|
67
|
+
}
|
|
68
|
+
if (!kvNamespaceTitle || kvNamespaceTitle.length > 512) {
|
|
69
|
+
throw new Error("Cloudflare KV namespace title is invalid");
|
|
70
|
+
}
|
|
71
|
+
const [managementStatus, runtimeStatus] = await Promise.all([
|
|
72
|
+
cf({ apiToken: config.tunnelToken }, "/user/tokens/verify", {}, fetcher),
|
|
73
|
+
cf({ apiToken: config.apiToken }, "/user/tokens/verify", {}, fetcher)
|
|
74
|
+
]);
|
|
75
|
+
if (managementStatus.status !== "active")
|
|
76
|
+
throw new Error("CF_TUNNEL_TOKEN is not active");
|
|
77
|
+
if (runtimeStatus.status !== "active")
|
|
78
|
+
throw new Error("CF_API_TOKEN is not active");
|
|
79
|
+
const zones = await cfPages({ apiToken: config.tunnelToken }, `/zones?name=${encodeURIComponent(zoneName)}&match=all&status=active`, 50, fetcher);
|
|
80
|
+
const matchingZones = zones.filter(({ name }) => name?.trim().toLowerCase() === zoneName);
|
|
81
|
+
if (matchingZones.length !== 1) {
|
|
82
|
+
throw new Error(`Cloudflare zone ${zoneName} must resolve to exactly one active zone`);
|
|
83
|
+
}
|
|
84
|
+
const zoneId = exactHexId(matchingZones[0].id, "Cloudflare zone id");
|
|
85
|
+
const accountId = exactHexId(matchingZones[0].account?.id, "Cloudflare account id");
|
|
86
|
+
const namespaces = await cfPages({ apiToken: config.apiToken }, `/accounts/${accountId}/storage/kv/namespaces?order=title&direction=asc`, 1000, fetcher);
|
|
87
|
+
const matchingNamespaces = namespaces.filter(({ title }) => title === kvNamespaceTitle);
|
|
88
|
+
if (matchingNamespaces.length !== 1) {
|
|
89
|
+
throw new Error(`Cloudflare KV namespace ${kvNamespaceTitle} must resolve to exactly one namespace`);
|
|
90
|
+
}
|
|
91
|
+
const kvNamespaceId = exactHexId(matchingNamespaces[0].id, "Cloudflare KV namespace id");
|
|
92
|
+
if (config.workerScriptName) {
|
|
93
|
+
await verifyCloudflareWorkerDurableObjects({
|
|
94
|
+
accountId,
|
|
95
|
+
scriptName: config.workerScriptName,
|
|
96
|
+
apiToken: config.apiToken
|
|
97
|
+
}, fetcher);
|
|
98
|
+
}
|
|
99
|
+
return { accountId, zoneId, kvNamespaceId };
|
|
39
100
|
}
|
|
40
101
|
async function ensureCloudflarePrivateRoute(config, fetcher = fetch) {
|
|
41
102
|
const [address, prefixText, ...extra] = config.network.split("/");
|
|
@@ -211,7 +272,7 @@ async function verifyCloudflareWorkerDurableObjects(config, fetcher = fetch) {
|
|
|
211
272
|
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/.test(config.scriptName)) {
|
|
212
273
|
throw new Error("Cloudflare Worker script name is invalid");
|
|
213
274
|
}
|
|
214
|
-
const namespaces = await
|
|
275
|
+
const namespaces = await cfPages(config, `/accounts/${config.accountId}/workers/durable_objects/namespaces`, 1000, fetcher);
|
|
215
276
|
const owned = namespaces.filter(({ script }) => script === config.scriptName);
|
|
216
277
|
if (!owned.length)
|
|
217
278
|
throw new Error(`Cloudflare Worker ${config.scriptName} has no Durable Object namespace`);
|
|
@@ -283,18 +344,28 @@ async function ensureCloudflareTunnel(config, fetcher = fetch) {
|
|
|
283
344
|
}
|
|
284
345
|
return { tunnel, connectorToken, created };
|
|
285
346
|
}
|
|
347
|
+
async function retrieveCloudflareConnectorTokens(config, fetcher = fetch) {
|
|
348
|
+
if (!/^[a-f0-9]{32}$/i.test(config.accountId) || !/^[0-9a-f-]{36}$/i.test(config.tunnelId) || config.meshConnectorId && !/^[0-9a-f-]{36}$/i.test(config.meshConnectorId)) {
|
|
349
|
+
throw new Error("Cloudflare connector retrieval coordinates are invalid");
|
|
350
|
+
}
|
|
351
|
+
const connectorToken = await cf(config, `/accounts/${config.accountId}/cfd_tunnel/${encodeURIComponent(config.tunnelId)}/token`, {}, fetcher);
|
|
352
|
+
const meshConnectorToken = config.meshConnectorId ? await cf(config, `/accounts/${config.accountId}/warp_connector/${encodeURIComponent(config.meshConnectorId)}/token`, {}, fetcher) : undefined;
|
|
353
|
+
for (const value of [connectorToken, meshConnectorToken]) {
|
|
354
|
+
if (value !== undefined && (!value || value.length > 16384))
|
|
355
|
+
throw new Error("Cloudflare returned an invalid connector token");
|
|
356
|
+
}
|
|
357
|
+
return { connectorToken, ...meshConnectorToken ? { meshConnectorToken } : {} };
|
|
358
|
+
}
|
|
286
359
|
|
|
287
360
|
// src/cloudflare-bootstrap.ts
|
|
288
361
|
import { constants } from "fs";
|
|
289
|
-
import {
|
|
362
|
+
import { createHmac, randomUUID } from "crypto";
|
|
290
363
|
import { chmod, lstat, mkdir, open, readdir, rename, rmdir, stat, unlink } from "fs/promises";
|
|
291
364
|
import { dirname, join, resolve } from "path";
|
|
292
365
|
import { isIP as isIP2 } from "net";
|
|
293
366
|
var TOKEN = /^[A-Za-z0-9._-]{40,80}$/;
|
|
294
|
-
var CONNECTOR_TOKEN = /^[A-Za-z0-9._-]{40,16384}$/;
|
|
295
367
|
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
296
368
|
var HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
297
|
-
var REALTIME_SECRET = /^[A-Za-z0-9_-]{64,128}$/;
|
|
298
369
|
var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
299
370
|
async function assertOwnerOnlyHandle(path, handle, maximumBytes) {
|
|
300
371
|
const metadata = await handle.stat();
|
|
@@ -328,26 +399,17 @@ async function readOwnerOnlyFile(path, maximumBytes) {
|
|
|
328
399
|
await handle?.close();
|
|
329
400
|
}
|
|
330
401
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
async function readCloudflareBootstrapTokens(files) {
|
|
338
|
-
const unsupported = Object.keys(files).filter((key) => ![
|
|
339
|
-
"tunnelTokenFile",
|
|
340
|
-
"apiTokenFile"
|
|
341
|
-
].includes(key));
|
|
402
|
+
function validateCloudflareBootstrapTokens(input) {
|
|
403
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
404
|
+
throw new Error("Cloudflare bootstrap tokens must be an object");
|
|
405
|
+
const source = input;
|
|
406
|
+
const unsupported = Object.keys(source).filter((key) => !["tunnelToken", "apiToken"].includes(key));
|
|
342
407
|
if (unsupported.length)
|
|
343
|
-
throw new Error(`Cloudflare bootstrap
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
readOwnerApiToken(files.tunnelTokenFile),
|
|
349
|
-
readOwnerApiToken(files.apiTokenFile)
|
|
350
|
-
]);
|
|
408
|
+
throw new Error(`Cloudflare bootstrap tokens contain unsupported field ${unsupported[0]}`);
|
|
409
|
+
const tunnelToken = typeof source.tunnelToken === "string" ? source.tunnelToken.trim() : "";
|
|
410
|
+
const apiToken = typeof source.apiToken === "string" ? source.apiToken.trim() : "";
|
|
411
|
+
if (!TOKEN.test(tunnelToken) || !TOKEN.test(apiToken))
|
|
412
|
+
throw new Error("Cloudflare bootstrap requires two valid API tokens");
|
|
351
413
|
if (tunnelToken === apiToken) {
|
|
352
414
|
throw new Error("CF_TUNNEL_TOKEN and CF_API_TOKEN must be distinct least-privilege tokens");
|
|
353
415
|
}
|
|
@@ -523,7 +585,7 @@ function planCloudflareBootstrap(input, outputPath) {
|
|
|
523
585
|
return {
|
|
524
586
|
format: 1,
|
|
525
587
|
kind: "forgezero-cloudflare-bootstrap-plan",
|
|
526
|
-
mode: "attended-
|
|
588
|
+
mode: "attended-hidden-input",
|
|
527
589
|
outputFile: resolve(outputPath),
|
|
528
590
|
coordinates,
|
|
529
591
|
operations: [
|
|
@@ -531,19 +593,19 @@ function planCloudflareBootstrap(input, outputPath) {
|
|
|
531
593
|
...coordinates.realtime ? [
|
|
532
594
|
"prove the existing Worker owns a Durable Object namespace and install its publish/ticket secrets with CF_API_TOKEN"
|
|
533
595
|
] : [],
|
|
534
|
-
"create or reuse one remotely-managed Tunnel per node and checkpoint
|
|
596
|
+
"create or reuse one remotely-managed Tunnel per node and checkpoint only its non-secret id",
|
|
535
597
|
...coordinates.nodes.some(({ mesh }) => mesh) ? [
|
|
536
|
-
"create or reuse one Mesh/WARP Connector per declared private-network node and checkpoint its
|
|
598
|
+
"create or reuse one Mesh/WARP Connector per declared private-network node and checkpoint only its non-secret id",
|
|
537
599
|
"reconcile every unique private CIDR to its Mesh connector and include all declared CIDRs in the dedicated Mesh device profile"
|
|
538
600
|
] : [],
|
|
539
601
|
"preflight each exact DNS hostname, refuse ambiguous or incompatible records, and update its existing CNAME or create it only when absent",
|
|
540
602
|
"reconcile each Tunnel public-hostname ingress rule to the declared loopback API service",
|
|
541
|
-
"write one
|
|
603
|
+
"write one secret-free node handoff containing only bound Cloudflare ids and public coordinates"
|
|
542
604
|
],
|
|
543
605
|
secrets: [
|
|
544
|
-
"API tokens are
|
|
545
|
-
"
|
|
546
|
-
"the
|
|
606
|
+
"API tokens are accepted only from a hidden prompt and are never placed in JSON, files, argv or stdout",
|
|
607
|
+
"checkpoint and host handoffs contain no connector, management, runtime or realtime secret",
|
|
608
|
+
"the target Agent retrieves its connector tokens and seals both Vault fallback tokens directly"
|
|
547
609
|
]
|
|
548
610
|
};
|
|
549
611
|
}
|
|
@@ -608,25 +670,15 @@ async function readExistingOutput(path) {
|
|
|
608
670
|
const resources = output.resources;
|
|
609
671
|
const unsupportedResource = Object.keys(resources).filter((key) => ![
|
|
610
672
|
"kvNamespaceId",
|
|
611
|
-
"apiToken",
|
|
612
|
-
"realtime",
|
|
613
673
|
"nodes"
|
|
614
674
|
].includes(key));
|
|
615
675
|
if (unsupportedResource.length) {
|
|
616
676
|
throw new Error(`${resolve(path)} resources contain unsupported field ${unsupportedResource[0]}`);
|
|
617
677
|
}
|
|
618
678
|
const coordinates = validateCloudflareBootstrapCoordinates(output.coordinates);
|
|
619
|
-
if (resources.kvNamespaceId !== coordinates.kvNamespaceId || !
|
|
679
|
+
if (resources.kvNamespaceId !== coordinates.kvNamespaceId || !Array.isArray(resources.nodes) || resources.nodes.length > coordinates.nodes.length) {
|
|
620
680
|
throw new Error(`${resolve(path)} has malformed Cloudflare bootstrap resources`);
|
|
621
681
|
}
|
|
622
|
-
if (coordinates.realtime) {
|
|
623
|
-
const realtime = resources.realtime;
|
|
624
|
-
if (!realtime || !REALTIME_SECRET.test(String(realtime.publishSecret ?? "")) || !REALTIME_SECRET.test(String(realtime.ticketSecret ?? "")) || realtime.publishSecret === realtime.ticketSecret) {
|
|
625
|
-
throw new Error(`${resolve(path)} has malformed Cloudflare realtime resources`);
|
|
626
|
-
}
|
|
627
|
-
} else if (resources.realtime !== undefined) {
|
|
628
|
-
throw new Error(`${resolve(path)} contains undeclared Cloudflare realtime resources`);
|
|
629
|
-
}
|
|
630
682
|
const seen = new Set;
|
|
631
683
|
for (const item of resources.nodes) {
|
|
632
684
|
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
@@ -639,16 +691,15 @@ async function readExistingOutput(path) {
|
|
|
639
691
|
"service",
|
|
640
692
|
"tunnelName",
|
|
641
693
|
"tunnelId",
|
|
642
|
-
"connectorToken",
|
|
643
694
|
"mesh"
|
|
644
695
|
].includes(key));
|
|
645
696
|
const expected = coordinates.nodes.find((candidate) => candidate.nodeName === node.nodeName);
|
|
646
|
-
if (unknownNode.length || !expected || seen.has(expected.nodeName) || node.hostname !== expected.hostname || node.service !== expected.service || node.tunnelName !== expected.tunnelName || !UUID.test(String(node.tunnelId ?? ""))
|
|
697
|
+
if (unknownNode.length || !expected || seen.has(expected.nodeName) || node.hostname !== expected.hostname || node.service !== expected.service || node.tunnelName !== expected.tunnelName || !UUID.test(String(node.tunnelId ?? ""))) {
|
|
647
698
|
throw new Error(`${resolve(path)} has a malformed or unbound Cloudflare node resource`);
|
|
648
699
|
}
|
|
649
700
|
if (expected.mesh) {
|
|
650
701
|
const mesh = node.mesh;
|
|
651
|
-
if (!mesh || mesh.connectorName !== expected.mesh.connectorName || JSON.stringify(mesh.routes) !== JSON.stringify(expected.mesh.routes) || mesh.highAvailability !== expected.mesh.highAvailability || !UUID.test(String(mesh.connectorId ?? ""))
|
|
702
|
+
if (!mesh || mesh.connectorName !== expected.mesh.connectorName || JSON.stringify(mesh.routes) !== JSON.stringify(expected.mesh.routes) || mesh.highAvailability !== expected.mesh.highAvailability || !UUID.test(String(mesh.connectorId ?? ""))) {
|
|
652
703
|
throw new Error(`${resolve(path)} has a malformed or unbound Cloudflare Mesh resource`);
|
|
653
704
|
}
|
|
654
705
|
} else if (node.mesh !== undefined) {
|
|
@@ -689,7 +740,7 @@ async function readCloudflareConnectorHandoff(checkpointPath, nodeName) {
|
|
|
689
740
|
throw new Error(`Cloudflare connector handoff has no unique completed node ${normalizedNodeName}`);
|
|
690
741
|
}
|
|
691
742
|
const resource = matches[0];
|
|
692
|
-
if (resource.hostname !== expected.hostname || resource.service !== expected.service || resource.tunnelName !== expected.tunnelName || !UUID.test(resource.tunnelId)
|
|
743
|
+
if (resource.hostname !== expected.hostname || resource.service !== expected.service || resource.tunnelName !== expected.tunnelName || !UUID.test(resource.tunnelId)) {
|
|
693
744
|
throw new Error(`Cloudflare connector handoff for ${normalizedNodeName} is malformed or incomplete`);
|
|
694
745
|
}
|
|
695
746
|
return {
|
|
@@ -697,10 +748,8 @@ async function readCloudflareConnectorHandoff(checkpointPath, nodeName) {
|
|
|
697
748
|
hostname: resource.hostname,
|
|
698
749
|
service: resource.service,
|
|
699
750
|
tunnelId: resource.tunnelId,
|
|
700
|
-
connectorToken: resource.connectorToken,
|
|
701
751
|
...resource.mesh ? { mesh: {
|
|
702
752
|
connectorId: resource.mesh.connectorId,
|
|
703
|
-
connectorToken: resource.mesh.connectorToken,
|
|
704
753
|
routes: resource.mesh.routes
|
|
705
754
|
} } : {}
|
|
706
755
|
};
|
|
@@ -724,11 +773,9 @@ async function readCloudflareHostHandoff(handoffPath, nodeName) {
|
|
|
724
773
|
"hostname",
|
|
725
774
|
"service",
|
|
726
775
|
"tunnelId",
|
|
727
|
-
"connectorToken",
|
|
728
776
|
"accountId",
|
|
729
777
|
"zoneId",
|
|
730
778
|
"kvNamespaceId",
|
|
731
|
-
"apiToken",
|
|
732
779
|
"mesh",
|
|
733
780
|
"realtime"
|
|
734
781
|
].includes(key));
|
|
@@ -739,11 +786,11 @@ async function readCloudflareHostHandoff(handoffPath, nodeName) {
|
|
|
739
786
|
try {
|
|
740
787
|
service = normalizeService(output.service ?? "");
|
|
741
788
|
} catch {}
|
|
742
|
-
if (output.format !== 1 || output.kind !== "forgezero-cloudflare-host-handoff" || output.nodeName !== normalizedNodeName || !HOSTNAME.test(output.hostname ?? "") || !service || !UUID.test(output.tunnelId ?? "") ||
|
|
789
|
+
if (output.format !== 1 || output.kind !== "forgezero-cloudflare-host-handoff" || output.nodeName !== normalizedNodeName || !HOSTNAME.test(output.hostname ?? "") || !service || !UUID.test(output.tunnelId ?? "") || !/^[a-f0-9]{32}$/i.test(output.accountId ?? "") || !/^[a-f0-9]{32}$/i.test(output.zoneId ?? "") || !/^[a-f0-9]{32}$/i.test(output.kvNamespaceId ?? "")) {
|
|
743
790
|
throw new Error("Cloudflare host handoff is malformed or belongs to another node");
|
|
744
791
|
}
|
|
745
792
|
if (output.mesh !== undefined) {
|
|
746
|
-
if (!output.mesh || typeof output.mesh !== "object" || !UUID.test(output.mesh.connectorId) || !
|
|
793
|
+
if (!output.mesh || typeof output.mesh !== "object" || !UUID.test(output.mesh.connectorId) || !Array.isArray(output.mesh.routes) || output.mesh.routes.length < 1 || output.mesh.routes.length > 64 || output.mesh.routes.some((route) => {
|
|
747
794
|
try {
|
|
748
795
|
return privateMeshCidr(route) !== route;
|
|
749
796
|
} catch {
|
|
@@ -772,7 +819,7 @@ async function readCloudflareHostHandoff(handoffPath, nodeName) {
|
|
|
772
819
|
} catch {
|
|
773
820
|
throw new Error("Cloudflare host handoff contains malformed realtime coordinates");
|
|
774
821
|
}
|
|
775
|
-
if (
|
|
822
|
+
if (realtime.workerScriptName !== output.realtime.workerScriptName || realtime.endpoint !== output.realtime.endpoint || realtime.producer !== output.realtime.producer) {
|
|
776
823
|
throw new Error("Cloudflare host handoff contains malformed realtime credentials");
|
|
777
824
|
}
|
|
778
825
|
}
|
|
@@ -837,18 +884,12 @@ async function writeCloudflareHostHandoffs(checkpointPath, output) {
|
|
|
837
884
|
hostname: node.hostname,
|
|
838
885
|
service: node.service,
|
|
839
886
|
tunnelId: node.tunnelId,
|
|
840
|
-
connectorToken: node.connectorToken,
|
|
841
887
|
accountId: output.coordinates.accountId,
|
|
842
888
|
zoneId: output.coordinates.zoneId,
|
|
843
889
|
kvNamespaceId: output.resources.kvNamespaceId,
|
|
844
|
-
|
|
845
|
-
...output.coordinates.realtime && output.resources.realtime ? { realtime: {
|
|
846
|
-
...output.coordinates.realtime,
|
|
847
|
-
...output.resources.realtime
|
|
848
|
-
} } : {},
|
|
890
|
+
...output.coordinates.realtime ? { realtime: output.coordinates.realtime } : {},
|
|
849
891
|
...node.mesh ? { mesh: {
|
|
850
892
|
connectorId: node.mesh.connectorId,
|
|
851
|
-
connectorToken: node.mesh.connectorToken,
|
|
852
893
|
routes: node.mesh.routes
|
|
853
894
|
} } : {}
|
|
854
895
|
};
|
|
@@ -856,7 +897,10 @@ async function writeCloudflareHostHandoffs(checkpointPath, output) {
|
|
|
856
897
|
}
|
|
857
898
|
}
|
|
858
899
|
var sameCoordinates = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
859
|
-
var
|
|
900
|
+
var deriveCloudflareRealtimeSecrets = (apiToken) => ({
|
|
901
|
+
publishSecret: createHmac("sha512", apiToken).update("forgezero/realtime/publish/v1").digest("base64url"),
|
|
902
|
+
ticketSecret: createHmac("sha512", apiToken).update("forgezero/realtime/ticket/v1").digest("base64url")
|
|
903
|
+
});
|
|
860
904
|
async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fetch) {
|
|
861
905
|
const coordinates = validateCloudflareBootstrapCoordinates(input);
|
|
862
906
|
const absoluteOutput = resolve(outputPath);
|
|
@@ -874,10 +918,7 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
874
918
|
throw new Error("CF_TUNNEL_TOKEN and CF_API_TOKEN must be distinct least-privilege capabilities");
|
|
875
919
|
}
|
|
876
920
|
await preflightCloudflareKvRuntime(coordinates, apiToken, fetcher);
|
|
877
|
-
const realtime = coordinates.realtime ?
|
|
878
|
-
publishSecret: newRealtimeSecret(),
|
|
879
|
-
ticketSecret: newRealtimeSecret()
|
|
880
|
-
} : undefined;
|
|
921
|
+
const realtime = coordinates.realtime ? deriveCloudflareRealtimeSecrets(apiToken) : undefined;
|
|
881
922
|
if (coordinates.realtime && realtime) {
|
|
882
923
|
await verifyCloudflareWorkerDurableObjects({
|
|
883
924
|
accountId: coordinates.accountId,
|
|
@@ -892,8 +933,6 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
892
933
|
coordinates,
|
|
893
934
|
resources: {
|
|
894
935
|
kvNamespaceId: coordinates.kvNamespaceId,
|
|
895
|
-
apiToken,
|
|
896
|
-
realtime,
|
|
897
936
|
nodes: existing?.resources.nodes ?? []
|
|
898
937
|
}
|
|
899
938
|
});
|
|
@@ -912,7 +951,7 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
912
951
|
let resource;
|
|
913
952
|
let created = false;
|
|
914
953
|
if (checkpointed) {
|
|
915
|
-
if (checkpointed.hostname !== node.hostname || checkpointed.service !== node.service || checkpointed.tunnelName !== node.tunnelName || !UUID.test(checkpointed.tunnelId)
|
|
954
|
+
if (checkpointed.hostname !== node.hostname || checkpointed.service !== node.service || checkpointed.tunnelName !== node.tunnelName || !UUID.test(checkpointed.tunnelId)) {
|
|
916
955
|
throw new Error(`checkpointed Cloudflare node ${node.nodeName} is malformed`);
|
|
917
956
|
}
|
|
918
957
|
resource = checkpointed;
|
|
@@ -933,15 +972,13 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
933
972
|
}, fetcher);
|
|
934
973
|
mesh = {
|
|
935
974
|
...node.mesh,
|
|
936
|
-
connectorId: ensured.connector.id
|
|
937
|
-
connectorToken: ensured.connectorToken
|
|
975
|
+
connectorId: ensured.connector.id
|
|
938
976
|
};
|
|
939
977
|
}
|
|
940
978
|
const { mesh: _declaredMesh, ...publicNode } = node;
|
|
941
979
|
resource = {
|
|
942
980
|
...publicNode,
|
|
943
981
|
tunnelId: tunnel.tunnel.id,
|
|
944
|
-
connectorToken: tunnel.connectorToken,
|
|
945
982
|
...mesh ? { mesh } : {}
|
|
946
983
|
};
|
|
947
984
|
}
|
|
@@ -955,8 +992,6 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
955
992
|
coordinates,
|
|
956
993
|
resources: {
|
|
957
994
|
kvNamespaceId: coordinates.kvNamespaceId,
|
|
958
|
-
apiToken,
|
|
959
|
-
...realtime ? { realtime } : {},
|
|
960
995
|
nodes: [...nodeResources]
|
|
961
996
|
}
|
|
962
997
|
});
|
|
@@ -1000,8 +1035,6 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
1000
1035
|
coordinates,
|
|
1001
1036
|
resources: {
|
|
1002
1037
|
kvNamespaceId: coordinates.kvNamespaceId,
|
|
1003
|
-
apiToken,
|
|
1004
|
-
...realtime ? { realtime } : {},
|
|
1005
1038
|
nodes: nodeResources
|
|
1006
1039
|
},
|
|
1007
1040
|
created: {
|
|
@@ -1023,10 +1056,10 @@ async function runAttendedCloudflareBootstrap(request, dependencies = {}) {
|
|
|
1023
1056
|
nodes: plan.coordinates.nodes.map(({ nodeName, hostname }) => ({ nodeName, hostname }))
|
|
1024
1057
|
};
|
|
1025
1058
|
}
|
|
1026
|
-
if (!request.
|
|
1027
|
-
throw new Error("Cloudflare apply requires exactly two
|
|
1059
|
+
if (!request.tokens) {
|
|
1060
|
+
throw new Error("Cloudflare apply requires exactly two attended API tokens");
|
|
1028
1061
|
}
|
|
1029
|
-
const tokens =
|
|
1062
|
+
const tokens = validateCloudflareBootstrapTokens(request.tokens);
|
|
1030
1063
|
const output = await applyCloudflareBootstrap(plan.coordinates, tokens, plan.outputFile, dependencies.fetcher ?? fetch);
|
|
1031
1064
|
return {
|
|
1032
1065
|
format: 1,
|
|
@@ -1177,7 +1210,7 @@ async function finalizeCloudflareBootstrapAcceptance(request, fetcher = fetch) {
|
|
|
1177
1210
|
}
|
|
1178
1211
|
|
|
1179
1212
|
// src/bootstrap.ts
|
|
1180
|
-
import { createHash, createHmac, randomBytes as
|
|
1213
|
+
import { createHash, createHmac as createHmac2, randomBytes as randomBytes2 } from "crypto";
|
|
1181
1214
|
import {
|
|
1182
1215
|
chmodSync as chmodSync2,
|
|
1183
1216
|
existsSync as existsSync4,
|
|
@@ -1207,7 +1240,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
|
|
|
1207
1240
|
var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
|
|
1208
1241
|
|
|
1209
1242
|
// src/version.ts
|
|
1210
|
-
var VERSION = "0.1.
|
|
1243
|
+
var VERSION = "0.1.59";
|
|
1211
1244
|
|
|
1212
1245
|
// src/software.ts
|
|
1213
1246
|
var PINNED_BUN_VERSION = "1.3.14";
|
|
@@ -2117,10 +2150,11 @@ function planProvision(options) {
|
|
|
2117
2150
|
const warpEnabled = warpValues.every(Boolean);
|
|
2118
2151
|
if (warpValues.some(Boolean) && !warpEnabled)
|
|
2119
2152
|
throw new Error("WARP configuration must be supplied together");
|
|
2120
|
-
const enrolmentEnabled = Boolean(options.
|
|
2121
|
-
if (Boolean(options.
|
|
2122
|
-
throw new Error("direct enrolment paths must be supplied together");
|
|
2123
|
-
|
|
2153
|
+
const enrolmentEnabled = Boolean(options.enrolTokenCredentialPath && options.enrolStatePath);
|
|
2154
|
+
if (Boolean(options.enrolTokenCredentialPath) !== Boolean(options.enrolStatePath) || options.enrolTokenSourcePath && !enrolmentEnabled) {
|
|
2155
|
+
throw new Error("direct enrolment credential and state paths must be supplied together");
|
|
2156
|
+
}
|
|
2157
|
+
const enrolTokenSourcePath = options.enrolTokenSourcePath ? systemdPath(options.enrolTokenSourcePath, "enrolment source") : undefined;
|
|
2124
2158
|
const enrolTokenCredentialPath = enrolmentEnabled ? systemdPath(options.enrolTokenCredentialPath, "enrolment credential") : undefined;
|
|
2125
2159
|
const enrolStatePath = enrolmentEnabled ? systemdPath(options.enrolStatePath, "enrolment state") : undefined;
|
|
2126
2160
|
const enrolStateDir = enrolStatePath?.replace(/\/[^/]+$/, "");
|
|
@@ -2248,7 +2282,12 @@ function planProvision(options) {
|
|
|
2248
2282
|
] : [],
|
|
2249
2283
|
...enrolmentEnabled ? [
|
|
2250
2284
|
step("enrolment state directory", { kind: "directories", directories: [{ path: enrolStateDir, mode: 448, owner: user, group: user }] }),
|
|
2251
|
-
step("encrypted one-time enrolment capability", {
|
|
2285
|
+
...enrolTokenSourcePath ? [step("encrypted one-time enrolment capability", {
|
|
2286
|
+
kind: "ensure-enrolment",
|
|
2287
|
+
state: enrolStatePath,
|
|
2288
|
+
source: enrolTokenSourcePath,
|
|
2289
|
+
credential: enrolTokenCredentialPath
|
|
2290
|
+
})] : []
|
|
2252
2291
|
] : [],
|
|
2253
2292
|
...deploymentEnabled ? [step("deployment directories", { kind: "directories", directories: [
|
|
2254
2293
|
{ path: deployRoot, mode: 493, owner: "root", group: "root" },
|
|
@@ -2292,7 +2331,7 @@ function planProvision(options) {
|
|
|
2292
2331
|
}
|
|
2293
2332
|
|
|
2294
2333
|
// src/cli/agent-install.ts
|
|
2295
|
-
import { randomBytes
|
|
2334
|
+
import { randomBytes } from "crypto";
|
|
2296
2335
|
import {
|
|
2297
2336
|
chmodSync,
|
|
2298
2337
|
copyFileSync,
|
|
@@ -2388,7 +2427,7 @@ var runProvisionOperation = async (operation) => {
|
|
|
2388
2427
|
if (operation.kind === "ensure-seed") {
|
|
2389
2428
|
if (existsSync3(operation.credential) && lstatSync(operation.credential).size > 0)
|
|
2390
2429
|
return { stdout: "", exitCode: 0 };
|
|
2391
|
-
const seed =
|
|
2430
|
+
const seed = randomBytes(32).toString("base64url");
|
|
2392
2431
|
const result = await fixed(["/usr/bin/systemd-creds", "encrypt", "--name=agent-seed", "-", operation.credential], seed);
|
|
2393
2432
|
if (result.exitCode === 0)
|
|
2394
2433
|
chmodSync(operation.credential, 256);
|
|
@@ -2781,9 +2820,10 @@ function renderPlatformSharedEnvironment(input) {
|
|
|
2781
2820
|
}
|
|
2782
2821
|
function platformApiCredentialSpecs(options) {
|
|
2783
2822
|
const optional = [
|
|
2784
|
-
["
|
|
2785
|
-
["
|
|
2823
|
+
["fz_smtp.password", options.emailProvider === "smtp"],
|
|
2824
|
+
["fz_jetemail.apiKey", options.emailProvider === "jetemail"],
|
|
2786
2825
|
["CF_API_TOKEN", options.cloudflareKv],
|
|
2826
|
+
["CF_TUNNEL_TOKEN", options.cloudflareKv],
|
|
2787
2827
|
["REALTIME_PUBLISH_SECRET", options.realtime],
|
|
2788
2828
|
["REALTIME_TICKET_SECRET", options.realtime]
|
|
2789
2829
|
];
|
|
@@ -3118,6 +3158,78 @@ var PLATFORM_BOOTSTRAP_PROFILES = [
|
|
|
3118
3158
|
"platform-db-api",
|
|
3119
3159
|
"platform-api"
|
|
3120
3160
|
];
|
|
3161
|
+
function validateCloudflareBootstrapSecretPair(input) {
|
|
3162
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
3163
|
+
throw new Error("Cloudflare credential input must be an object");
|
|
3164
|
+
const source = input;
|
|
3165
|
+
const cloudflareTunnelToken = typeof source.cloudflareTunnelToken === "string" ? source.cloudflareTunnelToken.trim() : "";
|
|
3166
|
+
const cloudflareApiToken = typeof source.cloudflareApiToken === "string" ? source.cloudflareApiToken.trim() : "";
|
|
3167
|
+
if (!/^[A-Za-z0-9._-]{40,80}$/.test(cloudflareTunnelToken) || !/^[A-Za-z0-9._-]{40,80}$/.test(cloudflareApiToken) || cloudflareTunnelToken === cloudflareApiToken) {
|
|
3168
|
+
throw new Error("Cloudflare bootstrap credentials must be distinct valid API tokens");
|
|
3169
|
+
}
|
|
3170
|
+
return { cloudflareTunnelToken, cloudflareApiToken };
|
|
3171
|
+
}
|
|
3172
|
+
function validateEnrolledComputeBootstrapSecrets(config, input) {
|
|
3173
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
3174
|
+
throw new Error("compute activation credential input must be an object");
|
|
3175
|
+
const source = input;
|
|
3176
|
+
const allowed = ["enrolmentToken", "cloudflareTunnelToken", "cloudflareApiToken"];
|
|
3177
|
+
const unknown = Object.keys(source).filter((key) => !allowed.includes(key));
|
|
3178
|
+
if (unknown.length)
|
|
3179
|
+
throw new Error(`compute activation credential input contains unsupported field ${unknown[0]}`);
|
|
3180
|
+
const enrolmentToken = typeof source.enrolmentToken === "string" ? source.enrolmentToken.trim() : "";
|
|
3181
|
+
if (!/^fze_[A-Za-z0-9_-]{32,128}$/.test(enrolmentToken))
|
|
3182
|
+
throw new Error("compute enrolment token is malformed");
|
|
3183
|
+
const cloudflareConfigured = Boolean(config.cloudflareHandoff);
|
|
3184
|
+
if (cloudflareConfigured !== Boolean(source.cloudflareTunnelToken && source.cloudflareApiToken)) {
|
|
3185
|
+
throw new Error("Cloudflare compute activation requires CF_TUNNEL_TOKEN and CF_API_TOKEN together");
|
|
3186
|
+
}
|
|
3187
|
+
return {
|
|
3188
|
+
enrolmentToken,
|
|
3189
|
+
...cloudflareConfigured ? validateCloudflareBootstrapSecretPair(source) : {}
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
function validatePlatformBootstrapSecrets(config, input) {
|
|
3193
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
3194
|
+
throw new Error("bootstrap credential input must be an object");
|
|
3195
|
+
const source = input;
|
|
3196
|
+
const allowed = ["clusterBootstrapCode", "emailSecret", "enrolmentToken", "backupS3Secret", "cloudflareTunnelToken", "cloudflareApiToken"];
|
|
3197
|
+
const unknown = Object.keys(source).filter((key) => !allowed.includes(key));
|
|
3198
|
+
if (unknown.length)
|
|
3199
|
+
throw new Error(`bootstrap credential input contains unsupported field ${unknown[0]}`);
|
|
3200
|
+
const clusterBootstrapCode = typeof source.clusterBootstrapCode === "string" ? source.clusterBootstrapCode.trim() : "";
|
|
3201
|
+
const emailSecret = typeof source.emailSecret === "string" ? source.emailSecret.trim() : "";
|
|
3202
|
+
const enrolmentToken = typeof source.enrolmentToken === "string" ? source.enrolmentToken.trim() : undefined;
|
|
3203
|
+
const backupS3Secret = typeof source.backupS3Secret === "string" ? source.backupS3Secret.trim() : undefined;
|
|
3204
|
+
const cloudflareTunnelToken = typeof source.cloudflareTunnelToken === "string" ? source.cloudflareTunnelToken.trim() : undefined;
|
|
3205
|
+
const cloudflareApiToken = typeof source.cloudflareApiToken === "string" ? source.cloudflareApiToken.trim() : undefined;
|
|
3206
|
+
if (!/^[a-f0-9]{64}$/i.test(clusterBootstrapCode))
|
|
3207
|
+
throw new Error("cluster bootstrap code must contain exactly 64 hexadecimal characters");
|
|
3208
|
+
if (!emailSecret || emailSecret.length > 16384 || /[\r\n\0]/.test(emailSecret))
|
|
3209
|
+
throw new Error("bootstrap email credential is malformed");
|
|
3210
|
+
if (config.enrolment.source === "api-token" !== Boolean(enrolmentToken) || enrolmentToken && !/^fze_[A-Za-z0-9_-]{32,128}$/.test(enrolmentToken)) {
|
|
3211
|
+
throw new Error("platform enrolment source and attended token disagree");
|
|
3212
|
+
}
|
|
3213
|
+
if (Boolean(config.runtime.environment.backup) !== Boolean(backupS3Secret)) {
|
|
3214
|
+
throw new Error("backup configuration and its attended credential must be supplied together");
|
|
3215
|
+
}
|
|
3216
|
+
if (backupS3Secret && (backupS3Secret.length > 16384 || /[\r\n\0]/.test(backupS3Secret))) {
|
|
3217
|
+
throw new Error("backup credential is malformed");
|
|
3218
|
+
}
|
|
3219
|
+
const cloudflareConfigured = Boolean(config.cloudflareHandoff || config.runtime.environment.cloudflare);
|
|
3220
|
+
if (cloudflareConfigured !== Boolean(cloudflareTunnelToken && cloudflareApiToken)) {
|
|
3221
|
+
throw new Error("Cloudflare configuration requires attended CF_TUNNEL_TOKEN and CF_API_TOKEN together");
|
|
3222
|
+
}
|
|
3223
|
+
if (cloudflareTunnelToken)
|
|
3224
|
+
validateCloudflareBootstrapSecretPair({ cloudflareTunnelToken, cloudflareApiToken });
|
|
3225
|
+
return {
|
|
3226
|
+
clusterBootstrapCode,
|
|
3227
|
+
emailSecret,
|
|
3228
|
+
...enrolmentToken ? { enrolmentToken } : {},
|
|
3229
|
+
...backupS3Secret ? { backupS3Secret } : {},
|
|
3230
|
+
...cloudflareTunnelToken ? { cloudflareTunnelToken, cloudflareApiToken } : {}
|
|
3231
|
+
};
|
|
3232
|
+
}
|
|
3121
3233
|
var platformBootstrapRunner = (config) => config.kind === "platform" && config.database.role === "master";
|
|
3122
3234
|
function resolveInstalledBootstrapKind(states) {
|
|
3123
3235
|
if (states.compute && states.metal) {
|
|
@@ -3133,6 +3245,7 @@ var JWT_CREDENTIAL = `${CREDS}/arangodb-jwt.cred`;
|
|
|
3133
3245
|
var ENROL_CREDENTIAL = `${CREDS}/enrol-token.cred`;
|
|
3134
3246
|
var TUNNEL_CREDENTIAL = `${CREDS}/CF_TUNNEL_CONNECTOR_TOKEN.cred`;
|
|
3135
3247
|
var CF_API_CREDENTIAL = `${CREDS}/CF_API_TOKEN.cred`;
|
|
3248
|
+
var CF_TUNNEL_API_CREDENTIAL = `${CREDS}/CF_TUNNEL_TOKEN.cred`;
|
|
3136
3249
|
var WARP_CONNECTOR_CREDENTIAL = `${CREDS}/CF_WARP_CONNECTOR_TOKEN.cred`;
|
|
3137
3250
|
var REALTIME_PUBLISH_CREDENTIAL = `${CREDS}/REALTIME_PUBLISH_SECRET.cred`;
|
|
3138
3251
|
var REALTIME_TICKET_CREDENTIAL = `${CREDS}/REALTIME_TICKET_SECRET.cred`;
|
|
@@ -3141,7 +3254,6 @@ var BACKUP_RECOVERY_CREDENTIAL = `${CREDS}/backup-recovery-root.cred`;
|
|
|
3141
3254
|
var BOOTSTRAP_SSH_CREDENTIAL = `${CREDS}/bootstrap-ssh-key.cred`;
|
|
3142
3255
|
var BOOTSTRAP_SSH_PUBLIC_KEY = "/etc/forgezero/bootstrap/runner.pub";
|
|
3143
3256
|
var GIT_PUBLIC_KEY = "/etc/forgezero/git/deploy.pub";
|
|
3144
|
-
var PLATFORM_ENROL_SOURCE = "/run/forgezero-platform-enrol-token";
|
|
3145
3257
|
var DB_MODE_EVIDENCE = "/var/lib/forgezero-cluster/server-mode.json";
|
|
3146
3258
|
var LIFECYCLE_PROFILE = "/etc/forgezero/lifecycle.json";
|
|
3147
3259
|
var CONTROL_SOCKET = "/run/forgezero/control.sock";
|
|
@@ -3219,8 +3331,6 @@ function validateBootstrapConfig(value) {
|
|
|
3219
3331
|
if (!/^https:\/\//.test(value.apiUrl) && !/^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/.test(value.apiUrl)) {
|
|
3220
3332
|
throw new Error("tenant API must be public HTTPS or loopback HTTP");
|
|
3221
3333
|
}
|
|
3222
|
-
if (!value.enrolTokenFile)
|
|
3223
|
-
throw new Error("enrolled-compute activation requires an enrolment-token file");
|
|
3224
3334
|
if (value.bootstrapRunner) {
|
|
3225
3335
|
if (value.bootstrapRunner.sshPrivateKeyFile !== undefined && (!value.bootstrapRunner.sshPrivateKeyFile.startsWith("/") || /[\r\n]/.test(value.bootstrapRunner.sshPrivateKeyFile))) {
|
|
3226
3336
|
throw new Error("bootstrap runner SSH private-key file must be absolute");
|
|
@@ -3264,8 +3374,6 @@ function validateBootstrapConfig(value) {
|
|
|
3264
3374
|
if (!/^[a-z0-9](?:[a-z0-9:_-]{0,126}[a-z0-9])?$/.test(value.computeReference)) {
|
|
3265
3375
|
throw new Error("platform compute reference is malformed");
|
|
3266
3376
|
}
|
|
3267
|
-
if (!value.database.bootstrapSecretFile)
|
|
3268
|
-
throw new Error("platform bootstrap requires the shared cluster bootstrap-code file");
|
|
3269
3377
|
const write = value.database.coordinators.map(privateOrigin);
|
|
3270
3378
|
if (write.length < 1 || write.length > 16 || new Set(write).size !== write.length) {
|
|
3271
3379
|
throw new Error("database coordinators must contain 1-16 unique private origins");
|
|
@@ -3280,12 +3388,6 @@ function validateBootstrapConfig(value) {
|
|
|
3280
3388
|
}
|
|
3281
3389
|
if (runtime.deployProfile !== value.environment)
|
|
3282
3390
|
throw new Error("runtime deployment profile disagrees with bootstrap environment");
|
|
3283
|
-
if (Boolean(runtime.email) !== Boolean(value.runtime.credentialFiles?.emailSecret)) {
|
|
3284
|
-
throw new Error("Bootstrap email configuration and its owner-only credential file must be supplied together");
|
|
3285
|
-
}
|
|
3286
|
-
if (value.runtime.credentialFiles?.emailSecret && (!value.runtime.credentialFiles.emailSecret.startsWith("/") || /[\r\n]/.test(value.runtime.credentialFiles.emailSecret))) {
|
|
3287
|
-
throw new Error("Bootstrap email credential file must be an absolute single-line path");
|
|
3288
|
-
}
|
|
3289
3391
|
if (value.database.address !== runtime.databaseAddress || value.database.master !== runtime.databaseMaster) {
|
|
3290
3392
|
throw new Error("runtime database topology disagrees with bootstrap topology");
|
|
3291
3393
|
}
|
|
@@ -3296,9 +3398,8 @@ function validateBootstrapConfig(value) {
|
|
|
3296
3398
|
if (value.firewall.enabled && (value.firewall.privateCidrs.length < 1 || new Set(value.firewall.privateCidrs).size !== value.firewall.privateCidrs.length)) {
|
|
3297
3399
|
throw new Error("enabled firewall requires unique private cluster CIDRs");
|
|
3298
3400
|
}
|
|
3299
|
-
if (!["genesis-derived", "api-token"].includes(value.enrolment.source)
|
|
3300
|
-
throw new Error("platform enrolment source
|
|
3301
|
-
}
|
|
3401
|
+
if (!["genesis-derived", "api-token"].includes(value.enrolment.source))
|
|
3402
|
+
throw new Error("platform enrolment source is invalid");
|
|
3302
3403
|
value.runtime.environment = runtime;
|
|
3303
3404
|
return value;
|
|
3304
3405
|
}
|
|
@@ -3512,7 +3613,7 @@ async function waitForCloudflaredTunnel(host, expectedTunnelId) {
|
|
|
3512
3613
|
var derive = (root, label) => {
|
|
3513
3614
|
if (!/^[a-f0-9]{64}$/i.test(root))
|
|
3514
3615
|
throw new Error("cluster bootstrap code must contain exactly 64 hexadecimal characters");
|
|
3515
|
-
return
|
|
3616
|
+
return createHmac2("sha256", Buffer.from(root, "hex")).update(label).digest("hex");
|
|
3516
3617
|
};
|
|
3517
3618
|
async function seal(host, name, destination2, value) {
|
|
3518
3619
|
if (host.exists(destination2))
|
|
@@ -3802,15 +3903,10 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
3802
3903
|
problems.push("durable Agent enrolment state is missing");
|
|
3803
3904
|
return { initialized: problems.length === 0, kind: state.kind, profile: state.profile, services, problems };
|
|
3804
3905
|
}
|
|
3805
|
-
async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
3906
|
+
async function applyBootstrap(input, host = localBootstrapHost(), secrets, dependencies = {}) {
|
|
3806
3907
|
const config = validateBootstrapConfig(structuredClone(input));
|
|
3807
3908
|
if (host.uid() !== 0)
|
|
3808
3909
|
throw new Error("fz bootstrap --apply must run as root");
|
|
3809
|
-
if (config.kind === "enrolled-compute") {
|
|
3810
|
-
const token = privateFile(host, config.enrolTokenFile, "tenant enrolment token");
|
|
3811
|
-
if (!/^fze_[A-Za-z0-9_-]{32,128}$/.test(token))
|
|
3812
|
-
throw new Error("tenant enrolment token is malformed");
|
|
3813
|
-
}
|
|
3814
3910
|
let installed;
|
|
3815
3911
|
if (host.exists(STATE_PATH))
|
|
3816
3912
|
installed = parseStoredState(host.read(STATE_PATH));
|
|
@@ -3868,11 +3964,21 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3868
3964
|
throw new Error("bootstrap repair coordinates do not match the installed host identity");
|
|
3869
3965
|
}
|
|
3870
3966
|
}
|
|
3871
|
-
const platformPrivate = config.kind === "platform" ? {
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3967
|
+
const platformPrivate = config.kind === "platform" ? (() => {
|
|
3968
|
+
if (!secrets)
|
|
3969
|
+
throw new Error("platform apply requires attended credentials on stdin");
|
|
3970
|
+
const checked3 = validatePlatformBootstrapSecrets(config, secrets);
|
|
3971
|
+
return {
|
|
3972
|
+
root: checked3.clusterBootstrapCode,
|
|
3973
|
+
email: checked3.emailSecret,
|
|
3974
|
+
enrolmentToken: checked3.enrolmentToken,
|
|
3975
|
+
backup: checked3.backupS3Secret,
|
|
3976
|
+
cloudflareTunnelToken: checked3.cloudflareTunnelToken,
|
|
3977
|
+
cloudflareApiToken: checked3.cloudflareApiToken
|
|
3978
|
+
};
|
|
3979
|
+
})() : undefined;
|
|
3980
|
+
const enrolledPrivate = config.kind === "enrolled-compute" ? validateEnrolledComputeBootstrapSecrets(config, secrets) : undefined;
|
|
3981
|
+
const cloudflarePrivate = cloudflare ? config.kind === "platform" ? { cloudflareTunnelToken: platformPrivate.cloudflareTunnelToken, cloudflareApiToken: platformPrivate.cloudflareApiToken } : validateCloudflareBootstrapSecretPair(enrolledPrivate) : undefined;
|
|
3876
3982
|
if (config.kind === "enrolled-compute" && config.bootstrapRunner?.sshPrivateKeyFile && !host.exists(BOOTSTRAP_SSH_CREDENTIAL)) {
|
|
3877
3983
|
privateFile(host, config.bootstrapRunner.sshPrivateKeyFile, "bootstrap runner SSH private key");
|
|
3878
3984
|
}
|
|
@@ -3881,17 +3987,31 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3881
3987
|
const alreadyEnrolled = host.exists("/var/lib/forgezero/enrolment.json");
|
|
3882
3988
|
host.mkdir(CREDS, 448);
|
|
3883
3989
|
host.mkdir("/var/lib/forgezero", 448);
|
|
3884
|
-
if (
|
|
3885
|
-
|
|
3990
|
+
if (!alreadyEnrolled && !host.exists(ENROL_CREDENTIAL)) {
|
|
3991
|
+
const enrolmentToken = config.kind === "platform" ? config.enrolment.source === "api-token" ? platformPrivate.enrolmentToken : `fze_${derive(derive(platformPrivate.root, "forgezero/cluster/arangodb-jwt/v1"), `forgezero/platform-enrolment/v1/${config.computeReference}`)}` : enrolledPrivate.enrolmentToken;
|
|
3992
|
+
await seal(host, "enrol-token", ENROL_CREDENTIAL, enrolmentToken);
|
|
3993
|
+
}
|
|
3994
|
+
const connectorCapabilities = cloudflare ? await retrieveCloudflareConnectorTokens({
|
|
3995
|
+
accountId: cloudflare.accountId,
|
|
3996
|
+
tunnelId: cloudflare.tunnelId,
|
|
3997
|
+
...cloudflare.mesh ? { meshConnectorId: cloudflare.mesh.connectorId } : {},
|
|
3998
|
+
apiToken: cloudflarePrivate.cloudflareTunnelToken
|
|
3999
|
+
}, dependencies.fetcher ?? fetch) : undefined;
|
|
4000
|
+
if (cloudflarePrivate && !host.exists(CF_API_CREDENTIAL)) {
|
|
4001
|
+
await seal(host, "CF_API_TOKEN", CF_API_CREDENTIAL, cloudflarePrivate.cloudflareApiToken);
|
|
4002
|
+
}
|
|
4003
|
+
if (config.kind === "platform" && platformPrivate.cloudflareTunnelToken && !host.exists(CF_TUNNEL_API_CREDENTIAL)) {
|
|
4004
|
+
await seal(host, "CF_TUNNEL_TOKEN", CF_TUNNEL_API_CREDENTIAL, platformPrivate.cloudflareTunnelToken);
|
|
3886
4005
|
}
|
|
3887
|
-
|
|
3888
|
-
|
|
4006
|
+
const realtimeSecrets = cloudflare?.realtime && cloudflarePrivate ? deriveCloudflareRealtimeSecrets(cloudflarePrivate.cloudflareApiToken) : undefined;
|
|
4007
|
+
if (realtimeSecrets && !host.exists(REALTIME_PUBLISH_CREDENTIAL)) {
|
|
4008
|
+
await seal(host, "REALTIME_PUBLISH_SECRET", REALTIME_PUBLISH_CREDENTIAL, realtimeSecrets.publishSecret);
|
|
3889
4009
|
}
|
|
3890
|
-
if (
|
|
3891
|
-
await seal(host, "REALTIME_TICKET_SECRET", REALTIME_TICKET_CREDENTIAL,
|
|
4010
|
+
if (realtimeSecrets && !host.exists(REALTIME_TICKET_CREDENTIAL)) {
|
|
4011
|
+
await seal(host, "REALTIME_TICKET_SECRET", REALTIME_TICKET_CREDENTIAL, realtimeSecrets.ticketSecret);
|
|
3892
4012
|
}
|
|
3893
|
-
if (
|
|
3894
|
-
await seal(host, "CF_WARP_CONNECTOR_TOKEN", WARP_CONNECTOR_CREDENTIAL,
|
|
4013
|
+
if (connectorCapabilities?.meshConnectorToken && !host.exists(WARP_CONNECTOR_CREDENTIAL)) {
|
|
4014
|
+
await seal(host, "CF_WARP_CONNECTOR_TOKEN", WARP_CONNECTOR_CREDENTIAL, connectorCapabilities.meshConnectorToken);
|
|
3895
4015
|
}
|
|
3896
4016
|
await host.installAgent(config);
|
|
3897
4017
|
if (config.kind === "platform" && config.firewall.enabled) {
|
|
@@ -3918,22 +4038,15 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3918
4038
|
}
|
|
3919
4039
|
await seal(host, "seed-sync-root", SEED_CREDENTIAL, derive(root, "forgezero/cluster/seed-mesh/v1"));
|
|
3920
4040
|
await seal(host, "backup-recovery-root", BACKUP_RECOVERY_CREDENTIAL, derive(root, "forgezero/backup/recovery-root/v1"));
|
|
3921
|
-
const
|
|
3922
|
-
const emailCredentialName = config.runtime.environment.email?.provider === "smtp" ? "bootstrap-smtp-password" : config.runtime.environment.email?.provider === "jetemail" ? "bootstrap-jetemail-api-key" : undefined;
|
|
3923
|
-
if (Boolean(config.runtime.environment.email) !== Boolean(credentialFiles.emailSecret)) {
|
|
3924
|
-
throw new Error("Bootstrap email configuration and its owner-only credential file must be supplied together");
|
|
3925
|
-
}
|
|
4041
|
+
const emailCredentialName = config.runtime.environment.email?.provider === "smtp" ? "fz_smtp.password" : config.runtime.environment.email?.provider === "jetemail" ? "fz_jetemail.apiKey" : undefined;
|
|
3926
4042
|
for (const [name, source] of Object.entries({
|
|
3927
4043
|
...emailCredentialName ? { [emailCredentialName]: platformPrivate.email } : {},
|
|
3928
|
-
"backup
|
|
4044
|
+
"backup.s3.secretAccessKey": platformPrivate.backup
|
|
3929
4045
|
})) {
|
|
3930
4046
|
if (source) {
|
|
3931
4047
|
const destination2 = `${CREDS}/${name}.cred`;
|
|
3932
4048
|
if (!host.exists(destination2)) {
|
|
3933
4049
|
await seal(host, name, destination2, source);
|
|
3934
|
-
const sourcePath = name === "backup-s3-secret" ? credentialFiles.backupS3Secret : credentialFiles.emailSecret;
|
|
3935
|
-
if (sourcePath)
|
|
3936
|
-
host.remove(sourcePath);
|
|
3937
4050
|
}
|
|
3938
4051
|
}
|
|
3939
4052
|
}
|
|
@@ -4003,7 +4116,7 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
4003
4116
|
if (config.database.role === "master") {
|
|
4004
4117
|
const invite = `${runtime.environment.sharedDirectory}/platform-invite.token`;
|
|
4005
4118
|
if (!host.exists(invite)) {
|
|
4006
|
-
host.write(invite, `plt_${
|
|
4119
|
+
host.write(invite, `plt_${randomBytes2(24).toString("hex")}
|
|
4007
4120
|
`, 384);
|
|
4008
4121
|
await checked2(host, ["chown", `${runtime.serviceUser}:${runtime.serviceUser}`, invite], "platform invite ownership");
|
|
4009
4122
|
}
|
|
@@ -4023,13 +4136,6 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
4023
4136
|
verifiedAt: new Date().toISOString()
|
|
4024
4137
|
};
|
|
4025
4138
|
host.write(DB_MODE_EVIDENCE, `${JSON.stringify(evidence, null, 2)}
|
|
4026
|
-
`, 384);
|
|
4027
|
-
}
|
|
4028
|
-
if (!alreadyEnrolled) {
|
|
4029
|
-
const enrolToken = config.enrolment.source === "api-token" ? privateFile(host, config.enrolment.tokenFile, "platform enrolment token") : `fze_${derive(derive(root, "forgezero/cluster/arangodb-jwt/v1"), `forgezero/platform-enrolment/v1/${config.computeReference}`)}`;
|
|
4030
|
-
if (!/^fze_[A-Za-z0-9_-]{32,128}$/.test(enrolToken))
|
|
4031
|
-
throw new Error("platform enrolment token is malformed");
|
|
4032
|
-
host.write(PLATFORM_ENROL_SOURCE, `${enrolToken}
|
|
4033
4139
|
`, 384);
|
|
4034
4140
|
}
|
|
4035
4141
|
await checked2(host, [
|
|
@@ -4041,15 +4147,10 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
4041
4147
|
"deploy",
|
|
4042
4148
|
...config.database.role === "master" ? ["--release-executor"] : []
|
|
4043
4149
|
], "initial Agent deployment");
|
|
4044
|
-
if (!alreadyEnrolled) {
|
|
4045
|
-
await host.installAgent(config, PLATFORM_ENROL_SOURCE);
|
|
4046
|
-
if (config.enrolment.source === "api-token")
|
|
4047
|
-
host.remove(config.enrolment.tokenFile);
|
|
4048
|
-
}
|
|
4049
4150
|
}
|
|
4050
4151
|
if (config.cloudflareHandoff) {
|
|
4051
|
-
if (!host.exists(TUNNEL_CREDENTIAL) &&
|
|
4052
|
-
await seal(host, "CF_TUNNEL_CONNECTOR_TOKEN", TUNNEL_CREDENTIAL,
|
|
4152
|
+
if (!host.exists(TUNNEL_CREDENTIAL) && connectorCapabilities) {
|
|
4153
|
+
await seal(host, "CF_TUNNEL_CONNECTOR_TOKEN", TUNNEL_CREDENTIAL, connectorCapabilities.connectorToken);
|
|
4053
4154
|
}
|
|
4054
4155
|
if (!host.exists(TUNNEL_CREDENTIAL))
|
|
4055
4156
|
throw new Error("sealed cloudflared connector credential is missing");
|
|
@@ -4123,7 +4224,6 @@ function strictBootstrapDocument(value) {
|
|
|
4123
4224
|
"installWarp",
|
|
4124
4225
|
"cloudflareHandoff",
|
|
4125
4226
|
"realm",
|
|
4126
|
-
"enrolTokenFile",
|
|
4127
4227
|
"software",
|
|
4128
4228
|
"deploymentCredentials",
|
|
4129
4229
|
"bootstrapRunner"
|
|
@@ -4132,8 +4232,8 @@ function strictBootstrapDocument(value) {
|
|
|
4132
4232
|
exactKeys(root.firewall, ["enabled", "sshPort", "privateCidrs"], "firewall config");
|
|
4133
4233
|
if (root.cloudflareHandoff !== undefined)
|
|
4134
4234
|
exactKeys(root.cloudflareHandoff, ["handoffFile", "nodeName"], "Cloudflare handoff");
|
|
4135
|
-
exactKeys(root.database, ["role", "agency", "serverMode", "address", "master", "coordinators"
|
|
4136
|
-
exactKeys(root.enrolment, ["source"
|
|
4235
|
+
exactKeys(root.database, ["role", "agency", "serverMode", "address", "master", "coordinators"], "database config");
|
|
4236
|
+
exactKeys(root.enrolment, ["source"], "platform enrolment config");
|
|
4137
4237
|
const runtime = exactKeys(root.runtime, [
|
|
4138
4238
|
"environment",
|
|
4139
4239
|
"serviceUser",
|
|
@@ -4141,8 +4241,7 @@ function strictBootstrapDocument(value) {
|
|
|
4141
4241
|
"bluePort",
|
|
4142
4242
|
"greenPort",
|
|
4143
4243
|
"healthPath",
|
|
4144
|
-
"keepReleases"
|
|
4145
|
-
"credentialFiles"
|
|
4244
|
+
"keepReleases"
|
|
4146
4245
|
], "runtime config");
|
|
4147
4246
|
exactKeys(runtime.environment, [
|
|
4148
4247
|
"softwareProfile",
|
|
@@ -4180,8 +4279,6 @@ function strictBootstrapDocument(value) {
|
|
|
4180
4279
|
"cloudflare",
|
|
4181
4280
|
"realtime"
|
|
4182
4281
|
], "runtime environment");
|
|
4183
|
-
if (runtime.credentialFiles !== undefined)
|
|
4184
|
-
exactKeys(runtime.credentialFiles, ["emailSecret", "backupS3Secret"], "runtime credential files");
|
|
4185
4282
|
const environment = runtime.environment;
|
|
4186
4283
|
if (environment.email !== undefined) {
|
|
4187
4284
|
const email = exactKeys(environment.email, ["provider", "host", "port", "user", "from", "eu"], "email config");
|
|
@@ -4265,10 +4362,10 @@ function localBootstrapHost() {
|
|
|
4265
4362
|
throw new Error(`Agent software requirements failed: ${result.output.trim()}`);
|
|
4266
4363
|
return result;
|
|
4267
4364
|
},
|
|
4268
|
-
async installAgent(config
|
|
4365
|
+
async installAgent(config) {
|
|
4269
4366
|
const capabilities = await readCapabilities(localRunner);
|
|
4270
4367
|
const deployRoot = config.deployRoot ?? "/opt/forgezero";
|
|
4271
|
-
const hasBinding = config.kind === "enrolled-compute" ||
|
|
4368
|
+
const hasBinding = config.kind === "enrolled-compute" || existsSync4(ENROL_CREDENTIAL) || existsSync4("/var/lib/forgezero/enrolment.json");
|
|
4272
4369
|
if (config.kind === "platform") {
|
|
4273
4370
|
const lifecycle = config.database.role === "none" ? {
|
|
4274
4371
|
apiUnits: ["forgezero@blue.service", "forgezero@green.service"],
|
|
@@ -4311,8 +4408,7 @@ function localBootstrapHost() {
|
|
|
4311
4408
|
telemetryEndpoint: config.telemetryEndpoint,
|
|
4312
4409
|
binPath: "/usr/local/lib/forgezero/agent/fz-agent",
|
|
4313
4410
|
sourceBinPath: PACKAGED_AGENT_BIN,
|
|
4314
|
-
...
|
|
4315
|
-
enrolTokenSourcePath: config.kind === "enrolled-compute" ? config.enrolTokenFile : enrolTokenSourcePath,
|
|
4411
|
+
...hasBinding ? {
|
|
4316
4412
|
enrolTokenCredentialPath: ENROL_CREDENTIAL,
|
|
4317
4413
|
enrolStatePath: "/var/lib/forgezero/enrolment.json",
|
|
4318
4414
|
apiUrl: config.apiUrl,
|
|
@@ -4332,7 +4428,7 @@ function localBootstrapHost() {
|
|
|
4332
4428
|
}
|
|
4333
4429
|
|
|
4334
4430
|
// src/metal-bootstrap.ts
|
|
4335
|
-
import { createHash as createHash2, randomBytes as
|
|
4431
|
+
import { createHash as createHash2, randomBytes as randomBytes3 } from "crypto";
|
|
4336
4432
|
import {
|
|
4337
4433
|
chmodSync as chmodSync3,
|
|
4338
4434
|
chownSync,
|
|
@@ -5068,7 +5164,7 @@ async function applyMetalBootstrap(config, options) {
|
|
|
5068
5164
|
atomicWrite(PROFILE_PATH, `${JSON.stringify(persistedProfile, null, 2)}
|
|
5069
5165
|
`, 384);
|
|
5070
5166
|
if (!existsSync5(SEED_CREDENTIAL_PATH)) {
|
|
5071
|
-
const seed = config.agentSeedFile ? readFileSync5(config.agentSeedFile, "utf8").trim() :
|
|
5167
|
+
const seed = config.agentSeedFile ? readFileSync5(config.agentSeedFile, "utf8").trim() : randomBytes3(32).toString("base64url");
|
|
5072
5168
|
if (seed.length < 32 || /[\0\r\n]/.test(seed))
|
|
5073
5169
|
throw new MetalBootstrapError("metal Agent seed is invalid");
|
|
5074
5170
|
await runChecked(exec, ["/usr/bin/systemd-creds", "encrypt", "--name=metal-agent-seed", "-", SEED_CREDENTIAL_PATH], `${seed}
|
|
@@ -5234,7 +5330,7 @@ async function metalBootstrapStatus(exec = defaultExec2) {
|
|
|
5234
5330
|
}
|
|
5235
5331
|
|
|
5236
5332
|
// src/operator-bootstrap.ts
|
|
5237
|
-
import { createHash as createHash3, randomBytes as
|
|
5333
|
+
import { createHash as createHash3, randomBytes as randomBytes4 } from "crypto";
|
|
5238
5334
|
import { lstatSync as lstatSync4, mkdtempSync, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync7 } from "fs";
|
|
5239
5335
|
import { isIP as isIP6 } from "net";
|
|
5240
5336
|
import { tmpdir } from "os";
|
|
@@ -5522,12 +5618,15 @@ function planOperatorMetalBootstrap(request, mode) {
|
|
|
5522
5618
|
};
|
|
5523
5619
|
}
|
|
5524
5620
|
var secretSources = (config) => [
|
|
5525
|
-
["cluster-code", config.database.bootstrapSecretFile],
|
|
5526
|
-
...config.enrolment.tokenFile ? [["enrol-token", config.enrolment.tokenFile]] : [],
|
|
5527
|
-
...config.runtime.credentialFiles?.emailSecret ? [["email-secret", config.runtime.credentialFiles.emailSecret]] : [],
|
|
5528
|
-
...config.runtime.credentialFiles?.backupS3Secret ? [["backup-s3-secret", config.runtime.credentialFiles.backupS3Secret]] : [],
|
|
5529
5621
|
...config.cloudflareHandoff ? [["cloudflare-handoff", config.cloudflareHandoff.handoffFile]] : []
|
|
5530
5622
|
];
|
|
5623
|
+
var attendedSecretNames = (config) => [
|
|
5624
|
+
"cluster-bootstrap-code",
|
|
5625
|
+
config.runtime.environment.email?.provider === "jetemail" ? "fz_jetemail.apiKey" : "fz_smtp.password",
|
|
5626
|
+
...config.enrolment.source === "api-token" ? ["enrol-token"] : [],
|
|
5627
|
+
...config.runtime.environment.backup ? ["backup.s3.secretAccessKey"] : [],
|
|
5628
|
+
...config.cloudflareHandoff || config.runtime.environment.cloudflare ? ["CF_TUNNEL_TOKEN", "CF_API_TOKEN"] : []
|
|
5629
|
+
];
|
|
5531
5630
|
function planOperatorPlatformBootstrap(request, mode) {
|
|
5532
5631
|
const config = readBootstrapConfig(request.platformConfigFile);
|
|
5533
5632
|
if (config.kind !== "platform")
|
|
@@ -5549,7 +5648,7 @@ function planOperatorPlatformBootstrap(request, mode) {
|
|
|
5549
5648
|
`run typed fz bootstrap platform${mode === "prepare" ? " prepare" : ""} --apply`,
|
|
5550
5649
|
"remove transient local and remote staging data"
|
|
5551
5650
|
],
|
|
5552
|
-
secretInputs: secretSources(config).map(([name]) => name)
|
|
5651
|
+
secretInputs: mode === "apply" ? [...attendedSecretNames(config), ...secretSources(config).map(([name]) => name)] : []
|
|
5553
5652
|
};
|
|
5554
5653
|
}
|
|
5555
5654
|
var defaultExec3 = async (argv, options = {}) => {
|
|
@@ -5666,15 +5765,7 @@ function stageConfig(config, directory) {
|
|
|
5666
5765
|
writeFileSync7(local, bytes, { mode: 384, flag: "wx" });
|
|
5667
5766
|
staged.push(name);
|
|
5668
5767
|
const remotePath = `${REMOTE_STAGE}/${name}`;
|
|
5669
|
-
if (name === "
|
|
5670
|
-
rewritten.database.bootstrapSecretFile = remotePath;
|
|
5671
|
-
else if (name === "enrol-token")
|
|
5672
|
-
rewritten.enrolment.tokenFile = remotePath;
|
|
5673
|
-
else if (name === "email-secret")
|
|
5674
|
-
rewritten.runtime.credentialFiles.emailSecret = remotePath;
|
|
5675
|
-
else if (name === "backup-s3-secret")
|
|
5676
|
-
rewritten.runtime.credentialFiles.backupS3Secret = remotePath;
|
|
5677
|
-
else if (name === "cloudflare-handoff")
|
|
5768
|
+
if (name === "cloudflare-handoff")
|
|
5678
5769
|
rewritten.cloudflareHandoff.handoffFile = remotePath;
|
|
5679
5770
|
}
|
|
5680
5771
|
const path = join6(directory, "platform-config.json");
|
|
@@ -5758,7 +5849,7 @@ async function applyOperatorPlatformBootstrap(request, mode, options = {}) {
|
|
|
5758
5849
|
socketPath(request.target.agentSocket);
|
|
5759
5850
|
const exec = options.exec ?? defaultExec3;
|
|
5760
5851
|
const directory = mkdtempSync(join6(tmpdir(), "forgezero-operator-bootstrap-"));
|
|
5761
|
-
const remoteTemp = `/tmp/forgezero-operator-${
|
|
5852
|
+
const remoteTemp = `/tmp/forgezero-operator-${randomBytes4(12).toString("hex")}`;
|
|
5762
5853
|
let knownHosts = "";
|
|
5763
5854
|
try {
|
|
5764
5855
|
knownHosts = writeKnownHosts(request, directory);
|
|
@@ -5769,6 +5860,7 @@ async function applyOperatorPlatformBootstrap(request, mode, options = {}) {
|
|
|
5769
5860
|
const config = readBootstrapConfig(request.platformConfigFile);
|
|
5770
5861
|
if (config.kind !== "platform")
|
|
5771
5862
|
throw new Error("operator bootstrap requires a platform config");
|
|
5863
|
+
const secrets = mode === "apply" ? validatePlatformBootstrapSecrets(config, options.secrets) : undefined;
|
|
5772
5864
|
const staged = stageConfig(config, directory);
|
|
5773
5865
|
await installPackagedAgent(request, knownHosts, exec, directory, remoteTemp, options);
|
|
5774
5866
|
await copy(exec, request, knownHosts, staged.path, `${remoteTemp}/platform-config.json`, true);
|
|
@@ -5779,8 +5871,11 @@ async function applyOperatorPlatformBootstrap(request, mode, options = {}) {
|
|
|
5779
5871
|
const command = ["/usr/bin/sudo", "-n", "/usr/local/bin/fz", "bootstrap", "platform"];
|
|
5780
5872
|
if (mode === "prepare")
|
|
5781
5873
|
command.push("prepare");
|
|
5874
|
+
else
|
|
5875
|
+
command.push("credentials-stdin");
|
|
5782
5876
|
command.push("--bootstrap-config", `${REMOTE_STAGE}/platform-config.json`, "--apply");
|
|
5783
|
-
const output = await remote(exec, request, knownHosts, command, "remote typed bootstrap")
|
|
5877
|
+
const output = await remote(exec, request, knownHosts, command, "remote typed bootstrap", mode === "apply", secrets ? `${JSON.stringify(secrets)}
|
|
5878
|
+
` : undefined);
|
|
5784
5879
|
return { plan, output };
|
|
5785
5880
|
} finally {
|
|
5786
5881
|
if (knownHosts) {
|
|
@@ -5797,7 +5892,7 @@ async function applyOperatorMetalBootstrap(request, mode, options = {}) {
|
|
|
5797
5892
|
socketPath(request.target.agentSocket);
|
|
5798
5893
|
const exec = options.exec ?? defaultExec3;
|
|
5799
5894
|
const directory = mkdtempSync(join6(tmpdir(), "forgezero-operator-metal-"));
|
|
5800
|
-
const remoteTemp = `/tmp/forgezero-operator-${
|
|
5895
|
+
const remoteTemp = `/tmp/forgezero-operator-${randomBytes4(12).toString("hex")}`;
|
|
5801
5896
|
let knownHosts = "";
|
|
5802
5897
|
try {
|
|
5803
5898
|
knownHosts = writeKnownHosts(request, directory);
|