@forgezero/agent 0.1.58 → 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 +13 -12
- package/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap.d.ts +22 -11
- package/dist/bootstrap.js +193 -156
- package/dist/cli/cloudflare-bootstrap.d.ts +5 -7
- package/dist/cloudflare-bootstrap.d.ts +10 -29
- package/dist/cloudflare-bootstrap.js +50 -78
- package/dist/cloudflare-edge.d.ts +10 -0
- package/dist/cloudflare-edge.js +13 -0
- 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 +308 -272
- package/dist/metal-bootstrap.js +1 -1
- package/dist/operator-bootstrap.d.ts +4 -1
- package/dist/operator-bootstrap.js +210 -176
- 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
package/dist/bootstrap.js
CHANGED
|
@@ -344,18 +344,28 @@ async function ensureCloudflareTunnel(config, fetcher = fetch) {
|
|
|
344
344
|
}
|
|
345
345
|
return { tunnel, connectorToken, created };
|
|
346
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
|
+
}
|
|
347
359
|
|
|
348
360
|
// src/cloudflare-bootstrap.ts
|
|
349
361
|
import { constants } from "fs";
|
|
350
|
-
import {
|
|
362
|
+
import { createHmac, randomUUID } from "crypto";
|
|
351
363
|
import { chmod, lstat, mkdir, open, readdir, rename, rmdir, stat, unlink } from "fs/promises";
|
|
352
364
|
import { dirname, join, resolve } from "path";
|
|
353
365
|
import { isIP as isIP2 } from "net";
|
|
354
366
|
var TOKEN = /^[A-Za-z0-9._-]{40,80}$/;
|
|
355
|
-
var CONNECTOR_TOKEN = /^[A-Za-z0-9._-]{40,16384}$/;
|
|
356
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;
|
|
357
368
|
var HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
358
|
-
var REALTIME_SECRET = /^[A-Za-z0-9_-]{64,128}$/;
|
|
359
369
|
var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
360
370
|
async function assertOwnerOnlyHandle(path, handle, maximumBytes) {
|
|
361
371
|
const metadata = await handle.stat();
|
|
@@ -389,26 +399,17 @@ async function readOwnerOnlyFile(path, maximumBytes) {
|
|
|
389
399
|
await handle?.close();
|
|
390
400
|
}
|
|
391
401
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
async function readCloudflareBootstrapTokens(files) {
|
|
399
|
-
const unsupported = Object.keys(files).filter((key) => ![
|
|
400
|
-
"tunnelTokenFile",
|
|
401
|
-
"apiTokenFile"
|
|
402
|
-
].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));
|
|
403
407
|
if (unsupported.length)
|
|
404
|
-
throw new Error(`Cloudflare bootstrap
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
readOwnerApiToken(files.tunnelTokenFile),
|
|
410
|
-
readOwnerApiToken(files.apiTokenFile)
|
|
411
|
-
]);
|
|
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");
|
|
412
413
|
if (tunnelToken === apiToken) {
|
|
413
414
|
throw new Error("CF_TUNNEL_TOKEN and CF_API_TOKEN must be distinct least-privilege tokens");
|
|
414
415
|
}
|
|
@@ -584,7 +585,7 @@ function planCloudflareBootstrap(input, outputPath) {
|
|
|
584
585
|
return {
|
|
585
586
|
format: 1,
|
|
586
587
|
kind: "forgezero-cloudflare-bootstrap-plan",
|
|
587
|
-
mode: "attended-
|
|
588
|
+
mode: "attended-hidden-input",
|
|
588
589
|
outputFile: resolve(outputPath),
|
|
589
590
|
coordinates,
|
|
590
591
|
operations: [
|
|
@@ -592,19 +593,19 @@ function planCloudflareBootstrap(input, outputPath) {
|
|
|
592
593
|
...coordinates.realtime ? [
|
|
593
594
|
"prove the existing Worker owns a Durable Object namespace and install its publish/ticket secrets with CF_API_TOKEN"
|
|
594
595
|
] : [],
|
|
595
|
-
"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",
|
|
596
597
|
...coordinates.nodes.some(({ mesh }) => mesh) ? [
|
|
597
|
-
"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",
|
|
598
599
|
"reconcile every unique private CIDR to its Mesh connector and include all declared CIDRs in the dedicated Mesh device profile"
|
|
599
600
|
] : [],
|
|
600
601
|
"preflight each exact DNS hostname, refuse ambiguous or incompatible records, and update its existing CNAME or create it only when absent",
|
|
601
602
|
"reconcile each Tunnel public-hostname ingress rule to the declared loopback API service",
|
|
602
|
-
"write one
|
|
603
|
+
"write one secret-free node handoff containing only bound Cloudflare ids and public coordinates"
|
|
603
604
|
],
|
|
604
605
|
secrets: [
|
|
605
|
-
"API tokens are
|
|
606
|
-
"
|
|
607
|
-
"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"
|
|
608
609
|
]
|
|
609
610
|
};
|
|
610
611
|
}
|
|
@@ -669,25 +670,15 @@ async function readExistingOutput(path) {
|
|
|
669
670
|
const resources = output.resources;
|
|
670
671
|
const unsupportedResource = Object.keys(resources).filter((key) => ![
|
|
671
672
|
"kvNamespaceId",
|
|
672
|
-
"apiToken",
|
|
673
|
-
"realtime",
|
|
674
673
|
"nodes"
|
|
675
674
|
].includes(key));
|
|
676
675
|
if (unsupportedResource.length) {
|
|
677
676
|
throw new Error(`${resolve(path)} resources contain unsupported field ${unsupportedResource[0]}`);
|
|
678
677
|
}
|
|
679
678
|
const coordinates = validateCloudflareBootstrapCoordinates(output.coordinates);
|
|
680
|
-
if (resources.kvNamespaceId !== coordinates.kvNamespaceId || !
|
|
679
|
+
if (resources.kvNamespaceId !== coordinates.kvNamespaceId || !Array.isArray(resources.nodes) || resources.nodes.length > coordinates.nodes.length) {
|
|
681
680
|
throw new Error(`${resolve(path)} has malformed Cloudflare bootstrap resources`);
|
|
682
681
|
}
|
|
683
|
-
if (coordinates.realtime) {
|
|
684
|
-
const realtime = resources.realtime;
|
|
685
|
-
if (!realtime || !REALTIME_SECRET.test(String(realtime.publishSecret ?? "")) || !REALTIME_SECRET.test(String(realtime.ticketSecret ?? "")) || realtime.publishSecret === realtime.ticketSecret) {
|
|
686
|
-
throw new Error(`${resolve(path)} has malformed Cloudflare realtime resources`);
|
|
687
|
-
}
|
|
688
|
-
} else if (resources.realtime !== undefined) {
|
|
689
|
-
throw new Error(`${resolve(path)} contains undeclared Cloudflare realtime resources`);
|
|
690
|
-
}
|
|
691
682
|
const seen = new Set;
|
|
692
683
|
for (const item of resources.nodes) {
|
|
693
684
|
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
@@ -700,16 +691,15 @@ async function readExistingOutput(path) {
|
|
|
700
691
|
"service",
|
|
701
692
|
"tunnelName",
|
|
702
693
|
"tunnelId",
|
|
703
|
-
"connectorToken",
|
|
704
694
|
"mesh"
|
|
705
695
|
].includes(key));
|
|
706
696
|
const expected = coordinates.nodes.find((candidate) => candidate.nodeName === node.nodeName);
|
|
707
|
-
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 ?? ""))) {
|
|
708
698
|
throw new Error(`${resolve(path)} has a malformed or unbound Cloudflare node resource`);
|
|
709
699
|
}
|
|
710
700
|
if (expected.mesh) {
|
|
711
701
|
const mesh = node.mesh;
|
|
712
|
-
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 ?? ""))) {
|
|
713
703
|
throw new Error(`${resolve(path)} has a malformed or unbound Cloudflare Mesh resource`);
|
|
714
704
|
}
|
|
715
705
|
} else if (node.mesh !== undefined) {
|
|
@@ -750,7 +740,7 @@ async function readCloudflareConnectorHandoff(checkpointPath, nodeName) {
|
|
|
750
740
|
throw new Error(`Cloudflare connector handoff has no unique completed node ${normalizedNodeName}`);
|
|
751
741
|
}
|
|
752
742
|
const resource = matches[0];
|
|
753
|
-
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)) {
|
|
754
744
|
throw new Error(`Cloudflare connector handoff for ${normalizedNodeName} is malformed or incomplete`);
|
|
755
745
|
}
|
|
756
746
|
return {
|
|
@@ -758,10 +748,8 @@ async function readCloudflareConnectorHandoff(checkpointPath, nodeName) {
|
|
|
758
748
|
hostname: resource.hostname,
|
|
759
749
|
service: resource.service,
|
|
760
750
|
tunnelId: resource.tunnelId,
|
|
761
|
-
connectorToken: resource.connectorToken,
|
|
762
751
|
...resource.mesh ? { mesh: {
|
|
763
752
|
connectorId: resource.mesh.connectorId,
|
|
764
|
-
connectorToken: resource.mesh.connectorToken,
|
|
765
753
|
routes: resource.mesh.routes
|
|
766
754
|
} } : {}
|
|
767
755
|
};
|
|
@@ -785,11 +773,9 @@ async function readCloudflareHostHandoff(handoffPath, nodeName) {
|
|
|
785
773
|
"hostname",
|
|
786
774
|
"service",
|
|
787
775
|
"tunnelId",
|
|
788
|
-
"connectorToken",
|
|
789
776
|
"accountId",
|
|
790
777
|
"zoneId",
|
|
791
778
|
"kvNamespaceId",
|
|
792
|
-
"apiToken",
|
|
793
779
|
"mesh",
|
|
794
780
|
"realtime"
|
|
795
781
|
].includes(key));
|
|
@@ -800,11 +786,11 @@ async function readCloudflareHostHandoff(handoffPath, nodeName) {
|
|
|
800
786
|
try {
|
|
801
787
|
service = normalizeService(output.service ?? "");
|
|
802
788
|
} catch {}
|
|
803
|
-
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 ?? "")) {
|
|
804
790
|
throw new Error("Cloudflare host handoff is malformed or belongs to another node");
|
|
805
791
|
}
|
|
806
792
|
if (output.mesh !== undefined) {
|
|
807
|
-
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) => {
|
|
808
794
|
try {
|
|
809
795
|
return privateMeshCidr(route) !== route;
|
|
810
796
|
} catch {
|
|
@@ -833,7 +819,7 @@ async function readCloudflareHostHandoff(handoffPath, nodeName) {
|
|
|
833
819
|
} catch {
|
|
834
820
|
throw new Error("Cloudflare host handoff contains malformed realtime coordinates");
|
|
835
821
|
}
|
|
836
|
-
if (
|
|
822
|
+
if (realtime.workerScriptName !== output.realtime.workerScriptName || realtime.endpoint !== output.realtime.endpoint || realtime.producer !== output.realtime.producer) {
|
|
837
823
|
throw new Error("Cloudflare host handoff contains malformed realtime credentials");
|
|
838
824
|
}
|
|
839
825
|
}
|
|
@@ -898,18 +884,12 @@ async function writeCloudflareHostHandoffs(checkpointPath, output) {
|
|
|
898
884
|
hostname: node.hostname,
|
|
899
885
|
service: node.service,
|
|
900
886
|
tunnelId: node.tunnelId,
|
|
901
|
-
connectorToken: node.connectorToken,
|
|
902
887
|
accountId: output.coordinates.accountId,
|
|
903
888
|
zoneId: output.coordinates.zoneId,
|
|
904
889
|
kvNamespaceId: output.resources.kvNamespaceId,
|
|
905
|
-
|
|
906
|
-
...output.coordinates.realtime && output.resources.realtime ? { realtime: {
|
|
907
|
-
...output.coordinates.realtime,
|
|
908
|
-
...output.resources.realtime
|
|
909
|
-
} } : {},
|
|
890
|
+
...output.coordinates.realtime ? { realtime: output.coordinates.realtime } : {},
|
|
910
891
|
...node.mesh ? { mesh: {
|
|
911
892
|
connectorId: node.mesh.connectorId,
|
|
912
|
-
connectorToken: node.mesh.connectorToken,
|
|
913
893
|
routes: node.mesh.routes
|
|
914
894
|
} } : {}
|
|
915
895
|
};
|
|
@@ -917,7 +897,10 @@ async function writeCloudflareHostHandoffs(checkpointPath, output) {
|
|
|
917
897
|
}
|
|
918
898
|
}
|
|
919
899
|
var sameCoordinates = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
920
|
-
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
|
+
});
|
|
921
904
|
async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fetch) {
|
|
922
905
|
const coordinates = validateCloudflareBootstrapCoordinates(input);
|
|
923
906
|
const absoluteOutput = resolve(outputPath);
|
|
@@ -935,10 +918,7 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
935
918
|
throw new Error("CF_TUNNEL_TOKEN and CF_API_TOKEN must be distinct least-privilege capabilities");
|
|
936
919
|
}
|
|
937
920
|
await preflightCloudflareKvRuntime(coordinates, apiToken, fetcher);
|
|
938
|
-
const realtime = coordinates.realtime ?
|
|
939
|
-
publishSecret: newRealtimeSecret(),
|
|
940
|
-
ticketSecret: newRealtimeSecret()
|
|
941
|
-
} : undefined;
|
|
921
|
+
const realtime = coordinates.realtime ? deriveCloudflareRealtimeSecrets(apiToken) : undefined;
|
|
942
922
|
if (coordinates.realtime && realtime) {
|
|
943
923
|
await verifyCloudflareWorkerDurableObjects({
|
|
944
924
|
accountId: coordinates.accountId,
|
|
@@ -953,8 +933,6 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
953
933
|
coordinates,
|
|
954
934
|
resources: {
|
|
955
935
|
kvNamespaceId: coordinates.kvNamespaceId,
|
|
956
|
-
apiToken,
|
|
957
|
-
realtime,
|
|
958
936
|
nodes: existing?.resources.nodes ?? []
|
|
959
937
|
}
|
|
960
938
|
});
|
|
@@ -973,7 +951,7 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
973
951
|
let resource;
|
|
974
952
|
let created = false;
|
|
975
953
|
if (checkpointed) {
|
|
976
|
-
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)) {
|
|
977
955
|
throw new Error(`checkpointed Cloudflare node ${node.nodeName} is malformed`);
|
|
978
956
|
}
|
|
979
957
|
resource = checkpointed;
|
|
@@ -994,15 +972,13 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
994
972
|
}, fetcher);
|
|
995
973
|
mesh = {
|
|
996
974
|
...node.mesh,
|
|
997
|
-
connectorId: ensured.connector.id
|
|
998
|
-
connectorToken: ensured.connectorToken
|
|
975
|
+
connectorId: ensured.connector.id
|
|
999
976
|
};
|
|
1000
977
|
}
|
|
1001
978
|
const { mesh: _declaredMesh, ...publicNode } = node;
|
|
1002
979
|
resource = {
|
|
1003
980
|
...publicNode,
|
|
1004
981
|
tunnelId: tunnel.tunnel.id,
|
|
1005
|
-
connectorToken: tunnel.connectorToken,
|
|
1006
982
|
...mesh ? { mesh } : {}
|
|
1007
983
|
};
|
|
1008
984
|
}
|
|
@@ -1016,8 +992,6 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
1016
992
|
coordinates,
|
|
1017
993
|
resources: {
|
|
1018
994
|
kvNamespaceId: coordinates.kvNamespaceId,
|
|
1019
|
-
apiToken,
|
|
1020
|
-
...realtime ? { realtime } : {},
|
|
1021
995
|
nodes: [...nodeResources]
|
|
1022
996
|
}
|
|
1023
997
|
});
|
|
@@ -1061,8 +1035,6 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
1061
1035
|
coordinates,
|
|
1062
1036
|
resources: {
|
|
1063
1037
|
kvNamespaceId: coordinates.kvNamespaceId,
|
|
1064
|
-
apiToken,
|
|
1065
|
-
...realtime ? { realtime } : {},
|
|
1066
1038
|
nodes: nodeResources
|
|
1067
1039
|
},
|
|
1068
1040
|
created: {
|
|
@@ -1084,10 +1056,10 @@ async function runAttendedCloudflareBootstrap(request, dependencies = {}) {
|
|
|
1084
1056
|
nodes: plan.coordinates.nodes.map(({ nodeName, hostname }) => ({ nodeName, hostname }))
|
|
1085
1057
|
};
|
|
1086
1058
|
}
|
|
1087
|
-
if (!request.
|
|
1088
|
-
throw new Error("Cloudflare apply requires exactly two
|
|
1059
|
+
if (!request.tokens) {
|
|
1060
|
+
throw new Error("Cloudflare apply requires exactly two attended API tokens");
|
|
1089
1061
|
}
|
|
1090
|
-
const tokens =
|
|
1062
|
+
const tokens = validateCloudflareBootstrapTokens(request.tokens);
|
|
1091
1063
|
const output = await applyCloudflareBootstrap(plan.coordinates, tokens, plan.outputFile, dependencies.fetcher ?? fetch);
|
|
1092
1064
|
return {
|
|
1093
1065
|
format: 1,
|
|
@@ -1238,7 +1210,7 @@ async function finalizeCloudflareBootstrapAcceptance(request, fetcher = fetch) {
|
|
|
1238
1210
|
}
|
|
1239
1211
|
|
|
1240
1212
|
// src/bootstrap.ts
|
|
1241
|
-
import { createHash, createHmac, randomBytes as
|
|
1213
|
+
import { createHash, createHmac as createHmac2, randomBytes as randomBytes2 } from "crypto";
|
|
1242
1214
|
import {
|
|
1243
1215
|
chmodSync as chmodSync2,
|
|
1244
1216
|
existsSync as existsSync4,
|
|
@@ -1268,7 +1240,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
|
|
|
1268
1240
|
var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
|
|
1269
1241
|
|
|
1270
1242
|
// src/version.ts
|
|
1271
|
-
var VERSION = "0.1.
|
|
1243
|
+
var VERSION = "0.1.59";
|
|
1272
1244
|
|
|
1273
1245
|
// src/software.ts
|
|
1274
1246
|
var PINNED_BUN_VERSION = "1.3.14";
|
|
@@ -2178,10 +2150,11 @@ function planProvision(options) {
|
|
|
2178
2150
|
const warpEnabled = warpValues.every(Boolean);
|
|
2179
2151
|
if (warpValues.some(Boolean) && !warpEnabled)
|
|
2180
2152
|
throw new Error("WARP configuration must be supplied together");
|
|
2181
|
-
const enrolmentEnabled = Boolean(options.
|
|
2182
|
-
if (Boolean(options.
|
|
2183
|
-
throw new Error("direct enrolment paths must be supplied together");
|
|
2184
|
-
|
|
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;
|
|
2185
2158
|
const enrolTokenCredentialPath = enrolmentEnabled ? systemdPath(options.enrolTokenCredentialPath, "enrolment credential") : undefined;
|
|
2186
2159
|
const enrolStatePath = enrolmentEnabled ? systemdPath(options.enrolStatePath, "enrolment state") : undefined;
|
|
2187
2160
|
const enrolStateDir = enrolStatePath?.replace(/\/[^/]+$/, "");
|
|
@@ -2309,7 +2282,12 @@ function planProvision(options) {
|
|
|
2309
2282
|
] : [],
|
|
2310
2283
|
...enrolmentEnabled ? [
|
|
2311
2284
|
step("enrolment state directory", { kind: "directories", directories: [{ path: enrolStateDir, mode: 448, owner: user, group: user }] }),
|
|
2312
|
-
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
|
+
})] : []
|
|
2313
2291
|
] : [],
|
|
2314
2292
|
...deploymentEnabled ? [step("deployment directories", { kind: "directories", directories: [
|
|
2315
2293
|
{ path: deployRoot, mode: 493, owner: "root", group: "root" },
|
|
@@ -2353,7 +2331,7 @@ function planProvision(options) {
|
|
|
2353
2331
|
}
|
|
2354
2332
|
|
|
2355
2333
|
// src/cli/agent-install.ts
|
|
2356
|
-
import { randomBytes
|
|
2334
|
+
import { randomBytes } from "crypto";
|
|
2357
2335
|
import {
|
|
2358
2336
|
chmodSync,
|
|
2359
2337
|
copyFileSync,
|
|
@@ -2449,7 +2427,7 @@ var runProvisionOperation = async (operation) => {
|
|
|
2449
2427
|
if (operation.kind === "ensure-seed") {
|
|
2450
2428
|
if (existsSync3(operation.credential) && lstatSync(operation.credential).size > 0)
|
|
2451
2429
|
return { stdout: "", exitCode: 0 };
|
|
2452
|
-
const seed =
|
|
2430
|
+
const seed = randomBytes(32).toString("base64url");
|
|
2453
2431
|
const result = await fixed(["/usr/bin/systemd-creds", "encrypt", "--name=agent-seed", "-", operation.credential], seed);
|
|
2454
2432
|
if (result.exitCode === 0)
|
|
2455
2433
|
chmodSync(operation.credential, 256);
|
|
@@ -2842,9 +2820,10 @@ function renderPlatformSharedEnvironment(input) {
|
|
|
2842
2820
|
}
|
|
2843
2821
|
function platformApiCredentialSpecs(options) {
|
|
2844
2822
|
const optional = [
|
|
2845
|
-
["
|
|
2846
|
-
["
|
|
2823
|
+
["fz_smtp.password", options.emailProvider === "smtp"],
|
|
2824
|
+
["fz_jetemail.apiKey", options.emailProvider === "jetemail"],
|
|
2847
2825
|
["CF_API_TOKEN", options.cloudflareKv],
|
|
2826
|
+
["CF_TUNNEL_TOKEN", options.cloudflareKv],
|
|
2848
2827
|
["REALTIME_PUBLISH_SECRET", options.realtime],
|
|
2849
2828
|
["REALTIME_TICKET_SECRET", options.realtime]
|
|
2850
2829
|
];
|
|
@@ -3179,6 +3158,78 @@ var PLATFORM_BOOTSTRAP_PROFILES = [
|
|
|
3179
3158
|
"platform-db-api",
|
|
3180
3159
|
"platform-api"
|
|
3181
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
|
+
}
|
|
3182
3233
|
var platformBootstrapRunner = (config) => config.kind === "platform" && config.database.role === "master";
|
|
3183
3234
|
function resolveInstalledBootstrapKind(states) {
|
|
3184
3235
|
if (states.compute && states.metal) {
|
|
@@ -3194,6 +3245,7 @@ var JWT_CREDENTIAL = `${CREDS}/arangodb-jwt.cred`;
|
|
|
3194
3245
|
var ENROL_CREDENTIAL = `${CREDS}/enrol-token.cred`;
|
|
3195
3246
|
var TUNNEL_CREDENTIAL = `${CREDS}/CF_TUNNEL_CONNECTOR_TOKEN.cred`;
|
|
3196
3247
|
var CF_API_CREDENTIAL = `${CREDS}/CF_API_TOKEN.cred`;
|
|
3248
|
+
var CF_TUNNEL_API_CREDENTIAL = `${CREDS}/CF_TUNNEL_TOKEN.cred`;
|
|
3197
3249
|
var WARP_CONNECTOR_CREDENTIAL = `${CREDS}/CF_WARP_CONNECTOR_TOKEN.cred`;
|
|
3198
3250
|
var REALTIME_PUBLISH_CREDENTIAL = `${CREDS}/REALTIME_PUBLISH_SECRET.cred`;
|
|
3199
3251
|
var REALTIME_TICKET_CREDENTIAL = `${CREDS}/REALTIME_TICKET_SECRET.cred`;
|
|
@@ -3202,7 +3254,6 @@ var BACKUP_RECOVERY_CREDENTIAL = `${CREDS}/backup-recovery-root.cred`;
|
|
|
3202
3254
|
var BOOTSTRAP_SSH_CREDENTIAL = `${CREDS}/bootstrap-ssh-key.cred`;
|
|
3203
3255
|
var BOOTSTRAP_SSH_PUBLIC_KEY = "/etc/forgezero/bootstrap/runner.pub";
|
|
3204
3256
|
var GIT_PUBLIC_KEY = "/etc/forgezero/git/deploy.pub";
|
|
3205
|
-
var PLATFORM_ENROL_SOURCE = "/run/forgezero-platform-enrol-token";
|
|
3206
3257
|
var DB_MODE_EVIDENCE = "/var/lib/forgezero-cluster/server-mode.json";
|
|
3207
3258
|
var LIFECYCLE_PROFILE = "/etc/forgezero/lifecycle.json";
|
|
3208
3259
|
var CONTROL_SOCKET = "/run/forgezero/control.sock";
|
|
@@ -3280,8 +3331,6 @@ function validateBootstrapConfig(value) {
|
|
|
3280
3331
|
if (!/^https:\/\//.test(value.apiUrl) && !/^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/.test(value.apiUrl)) {
|
|
3281
3332
|
throw new Error("tenant API must be public HTTPS or loopback HTTP");
|
|
3282
3333
|
}
|
|
3283
|
-
if (!value.enrolTokenFile)
|
|
3284
|
-
throw new Error("enrolled-compute activation requires an enrolment-token file");
|
|
3285
3334
|
if (value.bootstrapRunner) {
|
|
3286
3335
|
if (value.bootstrapRunner.sshPrivateKeyFile !== undefined && (!value.bootstrapRunner.sshPrivateKeyFile.startsWith("/") || /[\r\n]/.test(value.bootstrapRunner.sshPrivateKeyFile))) {
|
|
3287
3336
|
throw new Error("bootstrap runner SSH private-key file must be absolute");
|
|
@@ -3325,8 +3374,6 @@ function validateBootstrapConfig(value) {
|
|
|
3325
3374
|
if (!/^[a-z0-9](?:[a-z0-9:_-]{0,126}[a-z0-9])?$/.test(value.computeReference)) {
|
|
3326
3375
|
throw new Error("platform compute reference is malformed");
|
|
3327
3376
|
}
|
|
3328
|
-
if (!value.database.bootstrapSecretFile)
|
|
3329
|
-
throw new Error("platform bootstrap requires the shared cluster bootstrap-code file");
|
|
3330
3377
|
const write = value.database.coordinators.map(privateOrigin);
|
|
3331
3378
|
if (write.length < 1 || write.length > 16 || new Set(write).size !== write.length) {
|
|
3332
3379
|
throw new Error("database coordinators must contain 1-16 unique private origins");
|
|
@@ -3341,12 +3388,6 @@ function validateBootstrapConfig(value) {
|
|
|
3341
3388
|
}
|
|
3342
3389
|
if (runtime.deployProfile !== value.environment)
|
|
3343
3390
|
throw new Error("runtime deployment profile disagrees with bootstrap environment");
|
|
3344
|
-
if (Boolean(runtime.email) !== Boolean(value.runtime.credentialFiles?.emailSecret)) {
|
|
3345
|
-
throw new Error("Bootstrap email configuration and its owner-only credential file must be supplied together");
|
|
3346
|
-
}
|
|
3347
|
-
if (value.runtime.credentialFiles?.emailSecret && (!value.runtime.credentialFiles.emailSecret.startsWith("/") || /[\r\n]/.test(value.runtime.credentialFiles.emailSecret))) {
|
|
3348
|
-
throw new Error("Bootstrap email credential file must be an absolute single-line path");
|
|
3349
|
-
}
|
|
3350
3391
|
if (value.database.address !== runtime.databaseAddress || value.database.master !== runtime.databaseMaster) {
|
|
3351
3392
|
throw new Error("runtime database topology disagrees with bootstrap topology");
|
|
3352
3393
|
}
|
|
@@ -3357,9 +3398,8 @@ function validateBootstrapConfig(value) {
|
|
|
3357
3398
|
if (value.firewall.enabled && (value.firewall.privateCidrs.length < 1 || new Set(value.firewall.privateCidrs).size !== value.firewall.privateCidrs.length)) {
|
|
3358
3399
|
throw new Error("enabled firewall requires unique private cluster CIDRs");
|
|
3359
3400
|
}
|
|
3360
|
-
if (!["genesis-derived", "api-token"].includes(value.enrolment.source)
|
|
3361
|
-
throw new Error("platform enrolment source
|
|
3362
|
-
}
|
|
3401
|
+
if (!["genesis-derived", "api-token"].includes(value.enrolment.source))
|
|
3402
|
+
throw new Error("platform enrolment source is invalid");
|
|
3363
3403
|
value.runtime.environment = runtime;
|
|
3364
3404
|
return value;
|
|
3365
3405
|
}
|
|
@@ -3573,7 +3613,7 @@ async function waitForCloudflaredTunnel(host, expectedTunnelId) {
|
|
|
3573
3613
|
var derive = (root, label) => {
|
|
3574
3614
|
if (!/^[a-f0-9]{64}$/i.test(root))
|
|
3575
3615
|
throw new Error("cluster bootstrap code must contain exactly 64 hexadecimal characters");
|
|
3576
|
-
return
|
|
3616
|
+
return createHmac2("sha256", Buffer.from(root, "hex")).update(label).digest("hex");
|
|
3577
3617
|
};
|
|
3578
3618
|
async function seal(host, name, destination2, value) {
|
|
3579
3619
|
if (host.exists(destination2))
|
|
@@ -3863,15 +3903,10 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
3863
3903
|
problems.push("durable Agent enrolment state is missing");
|
|
3864
3904
|
return { initialized: problems.length === 0, kind: state.kind, profile: state.profile, services, problems };
|
|
3865
3905
|
}
|
|
3866
|
-
async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
3906
|
+
async function applyBootstrap(input, host = localBootstrapHost(), secrets, dependencies = {}) {
|
|
3867
3907
|
const config = validateBootstrapConfig(structuredClone(input));
|
|
3868
3908
|
if (host.uid() !== 0)
|
|
3869
3909
|
throw new Error("fz bootstrap --apply must run as root");
|
|
3870
|
-
if (config.kind === "enrolled-compute") {
|
|
3871
|
-
const token = privateFile(host, config.enrolTokenFile, "tenant enrolment token");
|
|
3872
|
-
if (!/^fze_[A-Za-z0-9_-]{32,128}$/.test(token))
|
|
3873
|
-
throw new Error("tenant enrolment token is malformed");
|
|
3874
|
-
}
|
|
3875
3910
|
let installed;
|
|
3876
3911
|
if (host.exists(STATE_PATH))
|
|
3877
3912
|
installed = parseStoredState(host.read(STATE_PATH));
|
|
@@ -3929,11 +3964,21 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3929
3964
|
throw new Error("bootstrap repair coordinates do not match the installed host identity");
|
|
3930
3965
|
}
|
|
3931
3966
|
}
|
|
3932
|
-
const platformPrivate = config.kind === "platform" ? {
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
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;
|
|
3937
3982
|
if (config.kind === "enrolled-compute" && config.bootstrapRunner?.sshPrivateKeyFile && !host.exists(BOOTSTRAP_SSH_CREDENTIAL)) {
|
|
3938
3983
|
privateFile(host, config.bootstrapRunner.sshPrivateKeyFile, "bootstrap runner SSH private key");
|
|
3939
3984
|
}
|
|
@@ -3942,17 +3987,31 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3942
3987
|
const alreadyEnrolled = host.exists("/var/lib/forgezero/enrolment.json");
|
|
3943
3988
|
host.mkdir(CREDS, 448);
|
|
3944
3989
|
host.mkdir("/var/lib/forgezero", 448);
|
|
3945
|
-
if (
|
|
3946
|
-
|
|
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);
|
|
3947
4002
|
}
|
|
3948
|
-
if (
|
|
3949
|
-
await seal(host, "
|
|
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);
|
|
3950
4005
|
}
|
|
3951
|
-
|
|
3952
|
-
|
|
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);
|
|
3953
4009
|
}
|
|
3954
|
-
if (
|
|
3955
|
-
await seal(host, "
|
|
4010
|
+
if (realtimeSecrets && !host.exists(REALTIME_TICKET_CREDENTIAL)) {
|
|
4011
|
+
await seal(host, "REALTIME_TICKET_SECRET", REALTIME_TICKET_CREDENTIAL, realtimeSecrets.ticketSecret);
|
|
4012
|
+
}
|
|
4013
|
+
if (connectorCapabilities?.meshConnectorToken && !host.exists(WARP_CONNECTOR_CREDENTIAL)) {
|
|
4014
|
+
await seal(host, "CF_WARP_CONNECTOR_TOKEN", WARP_CONNECTOR_CREDENTIAL, connectorCapabilities.meshConnectorToken);
|
|
3956
4015
|
}
|
|
3957
4016
|
await host.installAgent(config);
|
|
3958
4017
|
if (config.kind === "platform" && config.firewall.enabled) {
|
|
@@ -3979,22 +4038,15 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3979
4038
|
}
|
|
3980
4039
|
await seal(host, "seed-sync-root", SEED_CREDENTIAL, derive(root, "forgezero/cluster/seed-mesh/v1"));
|
|
3981
4040
|
await seal(host, "backup-recovery-root", BACKUP_RECOVERY_CREDENTIAL, derive(root, "forgezero/backup/recovery-root/v1"));
|
|
3982
|
-
const
|
|
3983
|
-
const emailCredentialName = config.runtime.environment.email?.provider === "smtp" ? "bootstrap-smtp-password" : config.runtime.environment.email?.provider === "jetemail" ? "bootstrap-jetemail-api-key" : undefined;
|
|
3984
|
-
if (Boolean(config.runtime.environment.email) !== Boolean(credentialFiles.emailSecret)) {
|
|
3985
|
-
throw new Error("Bootstrap email configuration and its owner-only credential file must be supplied together");
|
|
3986
|
-
}
|
|
4041
|
+
const emailCredentialName = config.runtime.environment.email?.provider === "smtp" ? "fz_smtp.password" : config.runtime.environment.email?.provider === "jetemail" ? "fz_jetemail.apiKey" : undefined;
|
|
3987
4042
|
for (const [name, source] of Object.entries({
|
|
3988
4043
|
...emailCredentialName ? { [emailCredentialName]: platformPrivate.email } : {},
|
|
3989
|
-
"backup
|
|
4044
|
+
"backup.s3.secretAccessKey": platformPrivate.backup
|
|
3990
4045
|
})) {
|
|
3991
4046
|
if (source) {
|
|
3992
4047
|
const destination2 = `${CREDS}/${name}.cred`;
|
|
3993
4048
|
if (!host.exists(destination2)) {
|
|
3994
4049
|
await seal(host, name, destination2, source);
|
|
3995
|
-
const sourcePath = name === "backup-s3-secret" ? credentialFiles.backupS3Secret : credentialFiles.emailSecret;
|
|
3996
|
-
if (sourcePath)
|
|
3997
|
-
host.remove(sourcePath);
|
|
3998
4050
|
}
|
|
3999
4051
|
}
|
|
4000
4052
|
}
|
|
@@ -4064,7 +4116,7 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
4064
4116
|
if (config.database.role === "master") {
|
|
4065
4117
|
const invite = `${runtime.environment.sharedDirectory}/platform-invite.token`;
|
|
4066
4118
|
if (!host.exists(invite)) {
|
|
4067
|
-
host.write(invite, `plt_${
|
|
4119
|
+
host.write(invite, `plt_${randomBytes2(24).toString("hex")}
|
|
4068
4120
|
`, 384);
|
|
4069
4121
|
await checked2(host, ["chown", `${runtime.serviceUser}:${runtime.serviceUser}`, invite], "platform invite ownership");
|
|
4070
4122
|
}
|
|
@@ -4084,13 +4136,6 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
4084
4136
|
verifiedAt: new Date().toISOString()
|
|
4085
4137
|
};
|
|
4086
4138
|
host.write(DB_MODE_EVIDENCE, `${JSON.stringify(evidence, null, 2)}
|
|
4087
|
-
`, 384);
|
|
4088
|
-
}
|
|
4089
|
-
if (!alreadyEnrolled) {
|
|
4090
|
-
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}`)}`;
|
|
4091
|
-
if (!/^fze_[A-Za-z0-9_-]{32,128}$/.test(enrolToken))
|
|
4092
|
-
throw new Error("platform enrolment token is malformed");
|
|
4093
|
-
host.write(PLATFORM_ENROL_SOURCE, `${enrolToken}
|
|
4094
4139
|
`, 384);
|
|
4095
4140
|
}
|
|
4096
4141
|
await checked2(host, [
|
|
@@ -4102,15 +4147,10 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
4102
4147
|
"deploy",
|
|
4103
4148
|
...config.database.role === "master" ? ["--release-executor"] : []
|
|
4104
4149
|
], "initial Agent deployment");
|
|
4105
|
-
if (!alreadyEnrolled) {
|
|
4106
|
-
await host.installAgent(config, PLATFORM_ENROL_SOURCE);
|
|
4107
|
-
if (config.enrolment.source === "api-token")
|
|
4108
|
-
host.remove(config.enrolment.tokenFile);
|
|
4109
|
-
}
|
|
4110
4150
|
}
|
|
4111
4151
|
if (config.cloudflareHandoff) {
|
|
4112
|
-
if (!host.exists(TUNNEL_CREDENTIAL) &&
|
|
4113
|
-
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);
|
|
4114
4154
|
}
|
|
4115
4155
|
if (!host.exists(TUNNEL_CREDENTIAL))
|
|
4116
4156
|
throw new Error("sealed cloudflared connector credential is missing");
|
|
@@ -4184,7 +4224,6 @@ function strictBootstrapDocument(value) {
|
|
|
4184
4224
|
"installWarp",
|
|
4185
4225
|
"cloudflareHandoff",
|
|
4186
4226
|
"realm",
|
|
4187
|
-
"enrolTokenFile",
|
|
4188
4227
|
"software",
|
|
4189
4228
|
"deploymentCredentials",
|
|
4190
4229
|
"bootstrapRunner"
|
|
@@ -4193,8 +4232,8 @@ function strictBootstrapDocument(value) {
|
|
|
4193
4232
|
exactKeys(root.firewall, ["enabled", "sshPort", "privateCidrs"], "firewall config");
|
|
4194
4233
|
if (root.cloudflareHandoff !== undefined)
|
|
4195
4234
|
exactKeys(root.cloudflareHandoff, ["handoffFile", "nodeName"], "Cloudflare handoff");
|
|
4196
|
-
exactKeys(root.database, ["role", "agency", "serverMode", "address", "master", "coordinators"
|
|
4197
|
-
exactKeys(root.enrolment, ["source"
|
|
4235
|
+
exactKeys(root.database, ["role", "agency", "serverMode", "address", "master", "coordinators"], "database config");
|
|
4236
|
+
exactKeys(root.enrolment, ["source"], "platform enrolment config");
|
|
4198
4237
|
const runtime = exactKeys(root.runtime, [
|
|
4199
4238
|
"environment",
|
|
4200
4239
|
"serviceUser",
|
|
@@ -4202,8 +4241,7 @@ function strictBootstrapDocument(value) {
|
|
|
4202
4241
|
"bluePort",
|
|
4203
4242
|
"greenPort",
|
|
4204
4243
|
"healthPath",
|
|
4205
|
-
"keepReleases"
|
|
4206
|
-
"credentialFiles"
|
|
4244
|
+
"keepReleases"
|
|
4207
4245
|
], "runtime config");
|
|
4208
4246
|
exactKeys(runtime.environment, [
|
|
4209
4247
|
"softwareProfile",
|
|
@@ -4241,8 +4279,6 @@ function strictBootstrapDocument(value) {
|
|
|
4241
4279
|
"cloudflare",
|
|
4242
4280
|
"realtime"
|
|
4243
4281
|
], "runtime environment");
|
|
4244
|
-
if (runtime.credentialFiles !== undefined)
|
|
4245
|
-
exactKeys(runtime.credentialFiles, ["emailSecret", "backupS3Secret"], "runtime credential files");
|
|
4246
4282
|
const environment = runtime.environment;
|
|
4247
4283
|
if (environment.email !== undefined) {
|
|
4248
4284
|
const email = exactKeys(environment.email, ["provider", "host", "port", "user", "from", "eu"], "email config");
|
|
@@ -4326,10 +4362,10 @@ function localBootstrapHost() {
|
|
|
4326
4362
|
throw new Error(`Agent software requirements failed: ${result.output.trim()}`);
|
|
4327
4363
|
return result;
|
|
4328
4364
|
},
|
|
4329
|
-
async installAgent(config
|
|
4365
|
+
async installAgent(config) {
|
|
4330
4366
|
const capabilities = await readCapabilities(localRunner);
|
|
4331
4367
|
const deployRoot = config.deployRoot ?? "/opt/forgezero";
|
|
4332
|
-
const hasBinding = config.kind === "enrolled-compute" ||
|
|
4368
|
+
const hasBinding = config.kind === "enrolled-compute" || existsSync4(ENROL_CREDENTIAL) || existsSync4("/var/lib/forgezero/enrolment.json");
|
|
4333
4369
|
if (config.kind === "platform") {
|
|
4334
4370
|
const lifecycle = config.database.role === "none" ? {
|
|
4335
4371
|
apiUnits: ["forgezero@blue.service", "forgezero@green.service"],
|
|
@@ -4372,8 +4408,7 @@ function localBootstrapHost() {
|
|
|
4372
4408
|
telemetryEndpoint: config.telemetryEndpoint,
|
|
4373
4409
|
binPath: "/usr/local/lib/forgezero/agent/fz-agent",
|
|
4374
4410
|
sourceBinPath: PACKAGED_AGENT_BIN,
|
|
4375
|
-
...
|
|
4376
|
-
enrolTokenSourcePath: config.kind === "enrolled-compute" ? config.enrolTokenFile : enrolTokenSourcePath,
|
|
4411
|
+
...hasBinding ? {
|
|
4377
4412
|
enrolTokenCredentialPath: ENROL_CREDENTIAL,
|
|
4378
4413
|
enrolStatePath: "/var/lib/forgezero/enrolment.json",
|
|
4379
4414
|
apiUrl: config.apiUrl,
|
|
@@ -4392,6 +4427,8 @@ function localBootstrapHost() {
|
|
|
4392
4427
|
};
|
|
4393
4428
|
}
|
|
4394
4429
|
export {
|
|
4430
|
+
validatePlatformBootstrapSecrets,
|
|
4431
|
+
validateEnrolledComputeBootstrapSecrets,
|
|
4395
4432
|
validateBootstrapConfig,
|
|
4396
4433
|
resolveInstalledBootstrapKind,
|
|
4397
4434
|
readBootstrapConfig,
|