@forgezero/agent 0.1.34 → 0.1.36
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 +50 -0
- package/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap.d.ts +133 -0
- package/dist/bootstrap.js +3416 -0
- package/dist/cli/agent-install.d.ts +3 -0
- package/dist/cli/cloudflare-bootstrap.d.ts +11 -0
- package/dist/cloudflare-bootstrap.d.ts +218 -0
- package/dist/cloudflare-bootstrap.js +1196 -0
- package/dist/cloudflare-edge.d.ts +265 -0
- package/dist/cloudflare-edge.js +424 -0
- package/dist/definition.js +9 -2
- package/dist/deploy-file.js +9 -2
- package/dist/fz-agent.js +492 -150
- package/dist/fz.js +3833 -262
- package/dist/index.d.ts +2 -0
- package/dist/metal-bootstrap.d.ts +52 -0
- package/dist/metal-bootstrap.js +942 -0
- package/dist/platform-bootstrap-runtime.d.ts +161 -0
- package/dist/platform-bootstrap-runtime.js +462 -0
- package/dist/provision.d.ts +4 -0
- package/dist/provision.js +34 -6
- package/dist/software-helper.js +9 -2
- package/dist/software.d.ts +3 -1
- package/dist/software.js +12 -3
- package/dist/ssh-bootstrap.d.ts +72 -0
- package/dist/version.d.ts +1 -1
- package/package.json +22 -2
- package/schema/deploy-v2.json +1 -1
package/dist/fz.js
CHANGED
|
@@ -4857,9 +4857,9 @@ async function spawnWith(command, env, report = () => {}) {
|
|
|
4857
4857
|
|
|
4858
4858
|
// src/cli/index.ts
|
|
4859
4859
|
init_dist();
|
|
4860
|
-
import { existsSync as
|
|
4861
|
-
import { dirname as
|
|
4862
|
-
import { fileURLToPath } from "url";
|
|
4860
|
+
import { existsSync as existsSync6, lstatSync as lstatSync4, mkdirSync as mkdirSync7, readFileSync as readFileSync7, statSync as statSync2, unlinkSync as unlinkSync3, writeFileSync as writeFileSync7 } from "fs";
|
|
4861
|
+
import { dirname as dirname8 } from "path";
|
|
4862
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4863
4863
|
import { hostname } from "os";
|
|
4864
4864
|
|
|
4865
4865
|
// src/agent-update.ts
|
|
@@ -4875,7 +4875,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
|
|
|
4875
4875
|
var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
|
|
4876
4876
|
|
|
4877
4877
|
// src/version.ts
|
|
4878
|
-
var VERSION2 = "0.1.
|
|
4878
|
+
var VERSION2 = "0.1.36";
|
|
4879
4879
|
|
|
4880
4880
|
// src/software.ts
|
|
4881
4881
|
var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
|
|
@@ -4889,7 +4889,8 @@ var SOFTWARE_CATALOG = [
|
|
|
4889
4889
|
{ id: "nginx", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
4890
4890
|
{ id: "arangodb", version: "3.11.14", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
4891
4891
|
{ id: "cloudflared", version: "2026.7.3", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
4892
|
-
{ id: "ufw", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" }
|
|
4892
|
+
{ id: "ufw", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" },
|
|
4893
|
+
{ id: "openssh-client", version: "ubuntu-26.04", status: "active", os: "ubuntu", osVersion: "26.04", architecture: "x64", evidence: "reviewed-strategy-and-tests" }
|
|
4893
4894
|
];
|
|
4894
4895
|
var UBUNTU_2604_X64 = [
|
|
4895
4896
|
{
|
|
@@ -4916,6 +4917,11 @@ var UBUNTU_2604_X64 = [
|
|
|
4916
4917
|
requirement: { id: "ufw", version: "ubuntu-26.04" },
|
|
4917
4918
|
check: "command -v ufw >/dev/null",
|
|
4918
4919
|
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y ufw"
|
|
4920
|
+
},
|
|
4921
|
+
{
|
|
4922
|
+
requirement: { id: "openssh-client", version: "ubuntu-26.04" },
|
|
4923
|
+
check: "command -v ssh >/dev/null && command -v scp >/dev/null && command -v ssh-keyscan >/dev/null && command -v ssh-keygen >/dev/null",
|
|
4924
|
+
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y openssh-client"
|
|
4919
4925
|
}
|
|
4920
4926
|
];
|
|
4921
4927
|
function validateSoftwareRequirements(value, _options = {}) {
|
|
@@ -4929,7 +4935,7 @@ function validateSoftwareRequirements(value, _options = {}) {
|
|
|
4929
4935
|
if (Object.keys(row).some((key) => key !== "id" && key !== "version")) {
|
|
4930
4936
|
throw new Error("software requirement contains an unknown field");
|
|
4931
4937
|
}
|
|
4932
|
-
if (!["bun", "nginx", "arangodb", "cloudflared", "ufw"].includes(String(row.id)) || typeof row.version !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,31}$/.test(row.version)) {
|
|
4938
|
+
if (!["bun", "nginx", "arangodb", "cloudflared", "ufw", "openssh-client"].includes(String(row.id)) || typeof row.version !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,31}$/.test(row.version)) {
|
|
4933
4939
|
throw new Error("software requirement coordinate is invalid");
|
|
4934
4940
|
}
|
|
4935
4941
|
const requirement = { id: row.id, version: row.version };
|
|
@@ -5482,7 +5488,12 @@ function agentUnit(options) {
|
|
|
5482
5488
|
if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
|
|
5483
5489
|
throw new Error("migration pull and lifecycle profile must be supplied together");
|
|
5484
5490
|
}
|
|
5491
|
+
const bootstrapEnabled = Boolean(options.pullBootstrap && options.bootstrapSshCredentialPath && options.bootstrapTargetTelemetryEndpoint);
|
|
5492
|
+
if ([options.pullBootstrap, options.bootstrapSshCredentialPath, options.bootstrapTargetTelemetryEndpoint].some(Boolean) && !bootstrapEnabled) {
|
|
5493
|
+
throw new Error("bootstrap pull, SSH credential and target telemetry endpoint must be supplied together");
|
|
5494
|
+
}
|
|
5485
5495
|
const lifecycleHelperSocketPath = lifecycleEnabled ? systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket") : undefined;
|
|
5496
|
+
const bootstrapSshCredentialPath = bootstrapEnabled ? systemdPath(options.bootstrapSshCredentialPath, "bootstrap SSH credential") : undefined;
|
|
5486
5497
|
const warpValues = [
|
|
5487
5498
|
options.warpOrganization,
|
|
5488
5499
|
options.warpClientIdCredentialPath,
|
|
@@ -5548,6 +5559,9 @@ function agentUnit(options) {
|
|
|
5548
5559
|
options.publicApiUrl ? `FZ_PUBLIC_API_URL=${options.publicApiUrl}` : null,
|
|
5549
5560
|
options.pullDeployments ? "FZ_DEPLOY_PULL=true" : null,
|
|
5550
5561
|
options.pullMigrations ? "FZ_MIGRATION_PULL=true" : null,
|
|
5562
|
+
options.pullBootstrap ? "FZ_BOOTSTRAP_PULL=true" : null,
|
|
5563
|
+
options.pullBootstrap ? "FZ_BOOTSTRAP_SSH_KEY_CREDENTIAL=bootstrap-ssh-key" : null,
|
|
5564
|
+
options.pullBootstrap ? `FZ_BOOTSTRAP_TARGET_OTLP_ENDPOINT=${options.bootstrapTargetTelemetryEndpoint}` : null,
|
|
5551
5565
|
`FZ_AGENT_UPDATE_SOCKET=${DEFAULT_AGENT_UPDATE_SOCKET}`,
|
|
5552
5566
|
options.pullMigrations ? `FZ_LIFECYCLE_HELPER_SOCKET=${lifecycleHelperSocketPath}` : null
|
|
5553
5567
|
].filter((line) => line !== null);
|
|
@@ -5555,6 +5569,8 @@ function agentUnit(options) {
|
|
|
5555
5569
|
environment.push(`HOME=${deployRoot}/agent-home`, `XDG_CACHE_HOME=${deployRoot}/cache`);
|
|
5556
5570
|
}
|
|
5557
5571
|
const gitCredential = options.gitCredentialPath ? `LoadCredentialEncrypted=git-deploy-key:${options.gitCredentialPath}
|
|
5572
|
+
` : "";
|
|
5573
|
+
const bootstrapCredential = bootstrapEnabled ? `LoadCredentialEncrypted=bootstrap-ssh-key:${bootstrapSshCredentialPath}
|
|
5558
5574
|
` : "";
|
|
5559
5575
|
const projectCredentials = Object.entries(deploymentCredentials).map(([name, path]) => `LoadCredentialEncrypted=${name}:${path}`).join(`
|
|
5560
5576
|
`);
|
|
@@ -5609,7 +5625,7 @@ User=${user}
|
|
|
5609
5625
|
Group=${VAULT_GROUP}
|
|
5610
5626
|
${deploymentGroup}
|
|
5611
5627
|
LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
|
|
5612
|
-
${gitCredential}${projectCredentials}${projectCredentials ? `
|
|
5628
|
+
${gitCredential}${bootstrapCredential}${projectCredentials}${projectCredentials ? `
|
|
5613
5629
|
` : ""}${snpPrepare}ExecStart=${bin}
|
|
5614
5630
|
Restart=always
|
|
5615
5631
|
RestartSec=2
|
|
@@ -5664,6 +5680,10 @@ function planProvision(options) {
|
|
|
5664
5680
|
if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
|
|
5665
5681
|
throw new Error("migration pull and lifecycle profile must be supplied together");
|
|
5666
5682
|
}
|
|
5683
|
+
const bootstrapEnabled = Boolean(options.pullBootstrap && options.bootstrapSshCredentialPath && options.bootstrapTargetTelemetryEndpoint);
|
|
5684
|
+
if ([options.pullBootstrap, options.bootstrapSshCredentialPath, options.bootstrapTargetTelemetryEndpoint].some(Boolean) && !bootstrapEnabled) {
|
|
5685
|
+
throw new Error("bootstrap pull, SSH credential and target telemetry endpoint must be supplied together");
|
|
5686
|
+
}
|
|
5667
5687
|
const warpValues = [
|
|
5668
5688
|
options.warpOrganization,
|
|
5669
5689
|
options.warpClientIdCredentialPath,
|
|
@@ -5689,13 +5709,20 @@ function planProvision(options) {
|
|
|
5689
5709
|
const gitPublicKeyDir = gitPublicKeyPath?.replace(/\/[^/]+$/, "");
|
|
5690
5710
|
const lifecycleProfilePath = lifecycleEnabled ? systemdPath(options.lifecycleProfilePath, "lifecycle profile") : undefined;
|
|
5691
5711
|
const lifecycleHelperSocketPath = lifecycleEnabled ? systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket") : undefined;
|
|
5712
|
+
const bootstrapSshCredentialPath = bootstrapEnabled ? systemdPath(options.bootstrapSshCredentialPath, "bootstrap SSH credential") : undefined;
|
|
5692
5713
|
const warpClientIdCredentialPath = warpEnabled ? systemdPath(options.warpClientIdCredentialPath, "WARP client-id credential") : undefined;
|
|
5693
5714
|
const warpClientSecretCredentialPath = warpEnabled ? systemdPath(options.warpClientSecretCredentialPath, "WARP client-secret credential") : undefined;
|
|
5694
5715
|
return {
|
|
5695
5716
|
mode,
|
|
5696
5717
|
reason: reasonFor(mode),
|
|
5697
5718
|
unitPath: UNIT_PATH,
|
|
5698
|
-
unit: agentUnit({
|
|
5719
|
+
unit: agentUnit({
|
|
5720
|
+
...options,
|
|
5721
|
+
mode,
|
|
5722
|
+
lifecycleProfilePath,
|
|
5723
|
+
lifecycleHelperSocketPath,
|
|
5724
|
+
bootstrapSshCredentialPath
|
|
5725
|
+
}),
|
|
5699
5726
|
auxiliaryUnits: [
|
|
5700
5727
|
{ path: AGENT_SOCKET_UNIT_PATH, unit: agentSocketUnit(options) },
|
|
5701
5728
|
{ path: AGENT_SOCKET_PROXY_UNIT_PATH, unit: agentSocketProxyUnit(options) },
|
|
@@ -5806,7 +5833,7 @@ function planProvision(options) {
|
|
|
5806
5833
|
},
|
|
5807
5834
|
{
|
|
5808
5835
|
label: "encrypted one-time enrolment capability",
|
|
5809
|
-
command: `test -s ${enrolTokenCredentialPath} || { test -r ${enrolTokenSourcePath}; ` + `systemd-creds encrypt --name=enrol-token ${enrolTokenSourcePath} ${enrolTokenCredentialPath}; ` + `chmod 0400 ${enrolTokenCredentialPath}; rm -f ${enrolTokenSourcePath}; }`
|
|
5836
|
+
command: `test -s ${enrolStatePath} || test -s ${enrolTokenCredentialPath} || { test -r ${enrolTokenSourcePath}; ` + `systemd-creds encrypt --name=enrol-token ${enrolTokenSourcePath} ${enrolTokenCredentialPath}; ` + `chmod 0400 ${enrolTokenCredentialPath}; rm -f ${enrolTokenSourcePath}; }`
|
|
5810
5837
|
}
|
|
5811
5838
|
] : [],
|
|
5812
5839
|
...deploymentEnabled ? [{
|
|
@@ -10090,249 +10117,3583 @@ function removeSession(api, realm, path = defaultSessionPath()) {
|
|
|
10090
10117
|
persist(path, file);
|
|
10091
10118
|
}
|
|
10092
10119
|
|
|
10093
|
-
// src/
|
|
10094
|
-
|
|
10095
|
-
|
|
10096
|
-
|
|
10097
|
-
|
|
10098
|
-
|
|
10099
|
-
|
|
10100
|
-
|
|
10101
|
-
|
|
10102
|
-
|
|
10103
|
-
|
|
10104
|
-
|
|
10105
|
-
|
|
10106
|
-
|
|
10107
|
-
|
|
10108
|
-
|
|
10109
|
-
|
|
10110
|
-
|
|
10111
|
-
|
|
10112
|
-
|
|
10113
|
-
|
|
10114
|
-
|
|
10115
|
-
|
|
10116
|
-
|
|
10117
|
-
requireAttestation: false,
|
|
10118
|
-
force: false,
|
|
10119
|
-
noBrowser: false,
|
|
10120
|
-
provider: "github",
|
|
10121
|
-
branch: "main",
|
|
10122
|
-
authMode: "public",
|
|
10123
|
-
targets: [],
|
|
10124
|
-
wait: false,
|
|
10125
|
-
timeoutSeconds: 900,
|
|
10126
|
-
queries: []
|
|
10127
|
-
};
|
|
10128
|
-
const positional = [];
|
|
10129
|
-
for (let index = 0;index < argv.length; index += 1) {
|
|
10130
|
-
const token = argv[index];
|
|
10131
|
-
if (token === "--api") {
|
|
10132
|
-
options.api = argv[++index] ?? options.api;
|
|
10133
|
-
options.apiExplicit = true;
|
|
10134
|
-
} else if (token === "--app")
|
|
10135
|
-
options.app = argv[++index];
|
|
10136
|
-
else if (token === "--realm") {
|
|
10137
|
-
options.realm = argv[++index] ?? options.realm;
|
|
10138
|
-
options.realmExplicit = true;
|
|
10139
|
-
} else if (token === "--socket")
|
|
10140
|
-
options.socket = argv[++index];
|
|
10141
|
-
else if (token === "--json")
|
|
10142
|
-
options.json = true;
|
|
10143
|
-
else if (token === "--apply")
|
|
10144
|
-
options.apply = true;
|
|
10145
|
-
else if (token === "--enrol")
|
|
10146
|
-
options.enrol = true;
|
|
10147
|
-
else if (token === "--preserve-env")
|
|
10148
|
-
options.preserveEnv = true;
|
|
10149
|
-
else if (token === "--root")
|
|
10150
|
-
options.projectRoot = argv[++index] ?? options.projectRoot;
|
|
10151
|
-
else if (token === "--name")
|
|
10152
|
-
options.projectName = argv[++index];
|
|
10153
|
-
else if (token === "--purpose")
|
|
10154
|
-
options.projectPurpose = argv[++index];
|
|
10155
|
-
else if (token === "--profile")
|
|
10156
|
-
options.deployProfile = argv[++index] ?? options.deployProfile;
|
|
10157
|
-
else if (token === "--software")
|
|
10158
|
-
options.deploySoftware.push(argv[++index] ?? "");
|
|
10159
|
-
else if (token === "--channel") {
|
|
10160
|
-
const channel = argv[++index];
|
|
10161
|
-
if (channel === "development" || channel === "production")
|
|
10162
|
-
options.deployChannel = channel;
|
|
10163
|
-
else
|
|
10164
|
-
options.optionError = "--channel must be production or development.";
|
|
10165
|
-
} else if (token === "--attestation")
|
|
10166
|
-
options.requireAttestation = true;
|
|
10167
|
-
else if (token === "--force")
|
|
10168
|
-
options.force = true;
|
|
10169
|
-
else if (token === "--no-browser")
|
|
10170
|
-
options.noBrowser = true;
|
|
10171
|
-
else if (token === "--project")
|
|
10172
|
-
options.projectKey = argv[++index];
|
|
10173
|
-
else if (token === "--pipeline")
|
|
10174
|
-
options.pipelineKey = argv[++index];
|
|
10175
|
-
else if (token === "--revision")
|
|
10176
|
-
options.revision = argv[++index];
|
|
10177
|
-
else if (token === "--label")
|
|
10178
|
-
options.label = argv[++index];
|
|
10179
|
-
else if (token === "--provider") {
|
|
10180
|
-
const value = argv[++index];
|
|
10181
|
-
if (value === "github" || value === "gitlab" || value === "generic")
|
|
10182
|
-
options.provider = value;
|
|
10183
|
-
else
|
|
10184
|
-
options.optionError = "--provider must be github, gitlab, or generic.";
|
|
10185
|
-
} else if (token === "--repository")
|
|
10186
|
-
options.repository = argv[++index];
|
|
10187
|
-
else if (token === "--branch")
|
|
10188
|
-
options.branch = argv[++index] ?? options.branch;
|
|
10189
|
-
else if (token === "--clone-url")
|
|
10190
|
-
options.cloneUrl = argv[++index];
|
|
10191
|
-
else if (token === "--auth") {
|
|
10192
|
-
const value = argv[++index];
|
|
10193
|
-
if (value === "public" || value === "node-ssh" || value === "vault-token")
|
|
10194
|
-
options.authMode = value;
|
|
10195
|
-
else
|
|
10196
|
-
options.optionError = "--auth must be public, node-ssh, or vault-token.";
|
|
10197
|
-
} else if (token === "--git-secret")
|
|
10198
|
-
options.gitSecret = argv[++index];
|
|
10199
|
-
else if (token === "--git-username")
|
|
10200
|
-
options.gitUsername = argv[++index];
|
|
10201
|
-
else if (token === "--known-hosts")
|
|
10202
|
-
options.knownHostsFile = argv[++index];
|
|
10203
|
-
else if (token === "--target")
|
|
10204
|
-
options.targets.push(argv[++index] ?? "");
|
|
10205
|
-
else if (token === "--target-key")
|
|
10206
|
-
options.targetKey = argv[++index];
|
|
10207
|
-
else if (token === "--wait")
|
|
10208
|
-
options.wait = true;
|
|
10209
|
-
else if (token === "--timeout") {
|
|
10210
|
-
const value = Number(argv[++index] ?? "");
|
|
10211
|
-
if (Number.isFinite(value) && value > 0 && value <= 86400)
|
|
10212
|
-
options.timeoutSeconds = value;
|
|
10213
|
-
else
|
|
10214
|
-
options.optionError = "--timeout must be between 1 and 86400 seconds.";
|
|
10215
|
-
} else if (token === "--data")
|
|
10216
|
-
options.data = argv[++index];
|
|
10217
|
-
else if (token === "--data-file")
|
|
10218
|
-
options.dataFile = argv[++index];
|
|
10219
|
-
else if (token === "--query")
|
|
10220
|
-
options.queries.push(argv[++index] ?? "");
|
|
10221
|
-
else if (token === "--key")
|
|
10222
|
-
options.key = argv[++index];
|
|
10223
|
-
else if (token === "--mode")
|
|
10224
|
-
options.mode = argv[++index] ?? options.mode;
|
|
10225
|
-
else if (token === "--user") {
|
|
10226
|
-
options.user = argv[++index] ?? options.user;
|
|
10227
|
-
options.userExplicit = true;
|
|
10228
|
-
} else if (token === "--token-file")
|
|
10229
|
-
options.tokenFile = argv[++index];
|
|
10230
|
-
else if (token === "--phrase-file")
|
|
10231
|
-
options.phraseFile = argv[++index];
|
|
10232
|
-
else if (token === "--phrase-stdin")
|
|
10233
|
-
options.phraseStdin = true;
|
|
10234
|
-
else
|
|
10235
|
-
positional.push(token);
|
|
10120
|
+
// src/bootstrap.ts
|
|
10121
|
+
import { createHmac, randomBytes as randomBytes5 } from "crypto";
|
|
10122
|
+
import {
|
|
10123
|
+
chmodSync as chmodSync2,
|
|
10124
|
+
existsSync as existsSync4,
|
|
10125
|
+
lstatSync as lstatSync2,
|
|
10126
|
+
mkdirSync as mkdirSync4,
|
|
10127
|
+
readFileSync as readFileSync4,
|
|
10128
|
+
renameSync as renameSync3,
|
|
10129
|
+
rmSync,
|
|
10130
|
+
writeFileSync as writeFileSync4
|
|
10131
|
+
} from "fs";
|
|
10132
|
+
import { dirname as dirname4 } from "path";
|
|
10133
|
+
import { fileURLToPath } from "url";
|
|
10134
|
+
|
|
10135
|
+
// src/platform-bootstrap-runtime.ts
|
|
10136
|
+
var safeAtom = (name, value) => {
|
|
10137
|
+
if (!value || /[\0\r\n]/.test(value))
|
|
10138
|
+
throw new Error(`${name} must be non-empty and single-line.`);
|
|
10139
|
+
return value;
|
|
10140
|
+
};
|
|
10141
|
+
var boundedInteger = (name, value, minimum, maximum) => {
|
|
10142
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
10143
|
+
throw new Error(`${name} must be an integer from ${minimum} through ${maximum}.`);
|
|
10236
10144
|
}
|
|
10237
|
-
return
|
|
10238
|
-
}
|
|
10239
|
-
var out = {
|
|
10240
|
-
line: (text3 = "") => process.stdout.write(`${text3}
|
|
10241
|
-
`),
|
|
10242
|
-
step: (text3) => process.stdout.write(` ${text3}
|
|
10243
|
-
`),
|
|
10244
|
-
warn: (text3) => process.stderr.write(` ! ${text3}
|
|
10245
|
-
`),
|
|
10246
|
-
fail: (text3) => process.stderr.write(` \u2717 ${text3}
|
|
10247
|
-
`),
|
|
10248
|
-
ok: (text3) => process.stdout.write(` \u2713 ${text3}
|
|
10249
|
-
`)
|
|
10145
|
+
return value;
|
|
10250
10146
|
};
|
|
10251
|
-
var
|
|
10252
|
-
|
|
10253
|
-
|
|
10254
|
-
|
|
10255
|
-
if (!setCookie)
|
|
10256
|
-
return;
|
|
10257
|
-
const cookie = setCookie.split(";")[0];
|
|
10258
|
-
sessionCookie = cookie;
|
|
10259
|
-
if (storedSession && (cookie !== storedSession.cookie || Date.now() - storedSession.lastUsedAtTs >= 60000)) {
|
|
10260
|
-
storedSession = { ...storedSession, cookie, lastUsedAtTs: Date.now() };
|
|
10261
|
-
saveSession(storedSession);
|
|
10147
|
+
var privateCoordinator = (raw) => {
|
|
10148
|
+
const url = new URL(raw);
|
|
10149
|
+
if (url.protocol !== "http:" || url.username || url.password || url.search || url.hash || url.pathname !== "/") {
|
|
10150
|
+
throw new Error("ArangoDB coordinator URLs must be credential-free private HTTP origins.");
|
|
10262
10151
|
}
|
|
10263
|
-
|
|
10264
|
-
|
|
10265
|
-
|
|
10266
|
-
"
|
|
10267
|
-
origin: new URL(apiBase).origin,
|
|
10268
|
-
...cookie ? { cookie } : {}
|
|
10269
|
-
};
|
|
10270
|
-
}
|
|
10271
|
-
async function api(options, path, init) {
|
|
10272
|
-
const base = options.realm === "platform" ? "/api" : `/api/t/${encodeURIComponent(options.realm)}`;
|
|
10273
|
-
const response = await fetch(`${options.api}${base}${path}`, {
|
|
10274
|
-
method: init?.method ?? "GET",
|
|
10275
|
-
headers: { ...requestHeaders(options.api, sessionCookie), "user-agent": `forgezero-cli/${VERSION2}` },
|
|
10276
|
-
body: init?.body === undefined ? undefined : JSON.stringify(init.body)
|
|
10277
|
-
});
|
|
10278
|
-
captureSession(response);
|
|
10279
|
-
let body = null;
|
|
10280
|
-
try {
|
|
10281
|
-
body = await response.json();
|
|
10282
|
-
} catch {
|
|
10283
|
-
body = null;
|
|
10152
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
10153
|
+
const privateHost = host === "localhost" || host === "::1" || host.startsWith("fd") || host.startsWith("fc") || /^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
|
|
10154
|
+
if (!privateHost || url.port && url.port !== "8529") {
|
|
10155
|
+
throw new Error("ArangoDB coordinators must use private addresses and port 8529.");
|
|
10284
10156
|
}
|
|
10285
|
-
|
|
10286
|
-
|
|
10287
|
-
|
|
10288
|
-
|
|
10289
|
-
|
|
10290
|
-
|
|
10291
|
-
headers: {
|
|
10292
|
-
...requestHeaders(options.api, sessionCookie),
|
|
10293
|
-
"x-security-request-key": challenge.requestKey
|
|
10294
|
-
},
|
|
10295
|
-
body: init?.body === undefined ? undefined : JSON.stringify(init.body)
|
|
10296
|
-
});
|
|
10297
|
-
captureSession(replay);
|
|
10298
|
-
let replayed = null;
|
|
10299
|
-
try {
|
|
10300
|
-
replayed = await replay.json();
|
|
10301
|
-
} catch {
|
|
10302
|
-
replayed = null;
|
|
10303
|
-
}
|
|
10304
|
-
return { status: replay.status, body: replayed };
|
|
10305
|
-
}
|
|
10157
|
+
return url.origin;
|
|
10158
|
+
};
|
|
10159
|
+
var httpsOrigin = (name, raw) => {
|
|
10160
|
+
const value = new URL(raw);
|
|
10161
|
+
if (value.protocol !== "https:" || value.username || value.password || value.search || value.hash || value.pathname !== "/") {
|
|
10162
|
+
throw new Error(`${name} must be a credential-free HTTPS origin.`);
|
|
10306
10163
|
}
|
|
10307
|
-
|
|
10308
|
-
|
|
10309
|
-
|
|
10310
|
-
|
|
10311
|
-
|
|
10312
|
-
|
|
10313
|
-
|
|
10314
|
-
|
|
10315
|
-
|
|
10316
|
-
|
|
10317
|
-
|
|
10318
|
-
|
|
10319
|
-
|
|
10320
|
-
|
|
10321
|
-
|
|
10322
|
-
|
|
10323
|
-
|
|
10324
|
-
|
|
10164
|
+
return value.origin;
|
|
10165
|
+
};
|
|
10166
|
+
var systemdValue = (name, raw) => {
|
|
10167
|
+
if (/[\0\r\n]/.test(raw))
|
|
10168
|
+
throw new Error(`${name} must be single-line.`);
|
|
10169
|
+
const value = raw;
|
|
10170
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("$", "\\$")}"`;
|
|
10171
|
+
};
|
|
10172
|
+
function validatePlatformSharedEnvironment(input, options = {}) {
|
|
10173
|
+
if (input.softwareProfile === "platform-api" !== (input.databaseRole === "none")) {
|
|
10174
|
+
throw new Error("platform-api requires database role none; platform-db-api requires master or joiner.");
|
|
10175
|
+
}
|
|
10176
|
+
if (input.databaseCoordinators.length < 1 || input.databaseCoordinators.length > 16) {
|
|
10177
|
+
throw new Error("databaseCoordinators must contain 1 through 16 endpoints.");
|
|
10178
|
+
}
|
|
10179
|
+
const coordinators = input.databaseCoordinators.map(privateCoordinator);
|
|
10180
|
+
if (new Set(coordinators).size !== coordinators.length)
|
|
10181
|
+
throw new Error("databaseCoordinators must be unique.");
|
|
10182
|
+
if (!["private-lan", "cloudflare-warp"].includes(input.databaseNetworkMode)) {
|
|
10183
|
+
throw new Error("Database networking must be private-lan or cloudflare-warp.");
|
|
10184
|
+
}
|
|
10185
|
+
boundedInteger("databaseReplicationFactor", input.databaseReplicationFactor, 1, 16);
|
|
10186
|
+
boundedInteger("databaseWriteConcern", input.databaseWriteConcern, 1, 16);
|
|
10187
|
+
if (input.databaseWriteConcern > input.databaseReplicationFactor) {
|
|
10188
|
+
throw new Error("databaseWriteConcern cannot exceed databaseReplicationFactor.");
|
|
10189
|
+
}
|
|
10190
|
+
boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
|
|
10191
|
+
boundedInteger("seedSyncMembers", input.seedSyncMembers, 1, 64);
|
|
10192
|
+
boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
|
|
10193
|
+
boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
|
|
10194
|
+
boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
|
|
10195
|
+
if (!Number.isFinite(input.otlpTraceSampleRatio) || input.otlpTraceSampleRatio < 0 || input.otlpTraceSampleRatio > 1) {
|
|
10196
|
+
throw new Error("otlpTraceSampleRatio must be from 0 through 1.");
|
|
10197
|
+
}
|
|
10198
|
+
if (input.otlpEndpoint !== "http://127.0.0.1:4318")
|
|
10199
|
+
throw new Error("OTLP must use the exact local collector endpoint.");
|
|
10200
|
+
validateCollectorUnit(input.otlpCollectorUnit);
|
|
10201
|
+
httpsOrigin("agentOtlpEndpoint", input.agentOtlpEndpoint);
|
|
10202
|
+
for (const [name, value] of Object.entries({
|
|
10203
|
+
nodeHostname: input.nodeHostname,
|
|
10204
|
+
nodeRegion: input.nodeRegion,
|
|
10205
|
+
databaseUser: input.databaseUser,
|
|
10206
|
+
sharedDirectory: input.sharedDirectory,
|
|
10207
|
+
seedSyncEpoch: input.seedSyncEpoch,
|
|
10208
|
+
repository: input.repository,
|
|
10209
|
+
branch: input.branch,
|
|
10210
|
+
deployProfile: input.deployProfile
|
|
10211
|
+
}))
|
|
10212
|
+
safeAtom(name, value);
|
|
10213
|
+
if (!input.sharedDirectory.startsWith("/"))
|
|
10214
|
+
throw new Error("sharedDirectory must be absolute.");
|
|
10215
|
+
for (const peer of input.seedSyncPeers) {
|
|
10216
|
+
const url = new URL(peer);
|
|
10217
|
+
if (url.protocol !== "ws:" && url.protocol !== "wss:")
|
|
10218
|
+
throw new Error("Seed peers must be WebSocket URLs.");
|
|
10219
|
+
if (url.username || url.password || url.hash)
|
|
10220
|
+
throw new Error("Seed peers cannot contain credentials or fragments.");
|
|
10221
|
+
}
|
|
10222
|
+
if (input.smtp) {
|
|
10223
|
+
safeAtom("smtp.host", input.smtp.host);
|
|
10224
|
+
boundedInteger("smtp.port", input.smtp.port, 1, 65535);
|
|
10225
|
+
safeAtom("smtp.from", input.smtp.from);
|
|
10226
|
+
if (input.smtp.user)
|
|
10227
|
+
safeAtom("smtp.user", input.smtp.user);
|
|
10228
|
+
}
|
|
10229
|
+
if (input.backup) {
|
|
10230
|
+
httpsOrigin("backup.endpoint", input.backup.endpoint);
|
|
10231
|
+
for (const [name, value] of Object.entries(input.backup))
|
|
10232
|
+
safeAtom(`backup.${name}`, value);
|
|
10233
|
+
}
|
|
10234
|
+
if (input.cloudflare) {
|
|
10235
|
+
if (![input.cloudflare.accountId, input.cloudflare.zoneId, input.cloudflare.kvNamespaceId].every((item) => /^[a-f0-9]{32}$/i.test(item)) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.cloudflare.tunnelId)) {
|
|
10236
|
+
throw new Error("Cloudflare account, zone, KV and Tunnel ids are malformed.");
|
|
10237
|
+
}
|
|
10238
|
+
const service = new URL(input.cloudflare.tunnelService);
|
|
10239
|
+
if (service.protocol !== "http:" || !["127.0.0.1", "localhost", "::1"].includes(service.hostname) || service.username || service.password || service.search || service.hash)
|
|
10240
|
+
throw new Error("Cloudflare Tunnel service must be loopback HTTP.");
|
|
10241
|
+
if (input.cloudflare.warp) {
|
|
10242
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/.test(input.cloudflare.warp.organization) || !/^[0-9a-f-]{36}$/i.test(input.cloudflare.warp.virtualNetworkId) || !/^[A-Za-z0-9_-]{1,128}$/.test(input.cloudflare.warp.deviceProfileId)) {
|
|
10243
|
+
throw new Error("Cloudflare WARP organization, VNET or device profile is malformed.");
|
|
10244
|
+
}
|
|
10325
10245
|
}
|
|
10326
10246
|
}
|
|
10327
|
-
if (
|
|
10328
|
-
|
|
10329
|
-
storedSession = null;
|
|
10330
|
-
sessionCookie = null;
|
|
10247
|
+
if (!options.allowPendingCloudflareHandoff && input.databaseNetworkMode === "cloudflare-warp" !== Boolean(input.cloudflare?.warp)) {
|
|
10248
|
+
throw new Error("cloudflare-warp networking requires its exact enrolled Cloudflare coordinates.");
|
|
10331
10249
|
}
|
|
10332
|
-
return {
|
|
10250
|
+
return {
|
|
10251
|
+
...input,
|
|
10252
|
+
databaseCoordinators: coordinators,
|
|
10253
|
+
appOrigin: httpsOrigin("appOrigin", input.appOrigin),
|
|
10254
|
+
apiOrigin: httpsOrigin("apiOrigin", input.apiOrigin),
|
|
10255
|
+
agentOtlpEndpoint: httpsOrigin("agentOtlpEndpoint", input.agentOtlpEndpoint)
|
|
10256
|
+
};
|
|
10333
10257
|
}
|
|
10334
|
-
function
|
|
10335
|
-
|
|
10258
|
+
function renderPlatformSharedEnvironment(input) {
|
|
10259
|
+
const value = validatePlatformSharedEnvironment(input);
|
|
10260
|
+
const appHost = new URL(value.appOrigin).hostname.split(".").slice(-2).join(".");
|
|
10261
|
+
const apiHost = new URL(value.apiOrigin).hostname.split(".").slice(-2).join(".");
|
|
10262
|
+
const entries = {
|
|
10263
|
+
ARANGO_URL: value.databaseCoordinators[0],
|
|
10264
|
+
ARANGO_URLS: value.databaseCoordinators.join(","),
|
|
10265
|
+
ARANGO_DB: "fz",
|
|
10266
|
+
FZ_DATABASE_MODE: "platform",
|
|
10267
|
+
ARANGO_USER: value.databaseUser,
|
|
10268
|
+
ARANGO_REPLICATION_FACTOR: String(value.databaseReplicationFactor),
|
|
10269
|
+
ARANGO_WRITE_CONCERN: String(value.databaseWriteConcern),
|
|
10270
|
+
FZ_DB_ROLE: value.databaseRole,
|
|
10271
|
+
FZ_SOFTWARE_PROFILE: value.softwareProfile,
|
|
10272
|
+
FZ_ROLE: value.nodeRole,
|
|
10273
|
+
FZ_DB_ADDRESS: value.databaseAddress ?? "",
|
|
10274
|
+
FZ_DB_MASTER: value.databaseMaster ?? "",
|
|
10275
|
+
FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
|
|
10276
|
+
FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
|
|
10277
|
+
FZ_SEED_SYNC_MEMBERS: String(value.seedSyncMembers),
|
|
10278
|
+
FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
|
|
10279
|
+
FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
|
|
10280
|
+
FZ_SHARED_DIR: value.sharedDirectory,
|
|
10281
|
+
FZ_PUBLIC_API_PORT: String(value.publicApiPort),
|
|
10282
|
+
ORIGIN: value.appOrigin,
|
|
10283
|
+
API_ORIGIN: value.apiOrigin,
|
|
10284
|
+
HOST: "127.0.0.1",
|
|
10285
|
+
APP_ORIGINS: value.appOrigin,
|
|
10286
|
+
TRUST_CLOUDFLARE_IP: "1",
|
|
10287
|
+
SESSION_COOKIE_SAMESITE: appHost === apiHost ? "lax" : "none",
|
|
10288
|
+
SESSION_COOKIE_DOMAIN: "",
|
|
10289
|
+
FZ_NODE_HOSTNAME: value.nodeHostname,
|
|
10290
|
+
FZ_NODE_REGION: value.nodeRegion,
|
|
10291
|
+
FZ_CONCURRENCY_LIMIT: String(value.concurrencyLimit),
|
|
10292
|
+
FZ_DRAIN_DEADLINE_MS: String(value.drainDeadlineMs),
|
|
10293
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: value.otlpEndpoint,
|
|
10294
|
+
FZ_OTLP_COLLECTOR_UNIT: value.otlpCollectorUnit,
|
|
10295
|
+
OTEL_SERVICE_NAME: "forgezero-api",
|
|
10296
|
+
FZ_OTLP_FLUSH_INTERVAL_MS: String(value.otlpFlushIntervalMs),
|
|
10297
|
+
FZ_OTLP_TRACE_SAMPLE_RATIO: String(value.otlpTraceSampleRatio),
|
|
10298
|
+
FZ_AGENT_OTLP_ENDPOINT: value.agentOtlpEndpoint,
|
|
10299
|
+
FZ_CUSTODIAN_EMAIL: value.custodianEmail ?? "",
|
|
10300
|
+
FZ_PROFILE: value.deployProfile,
|
|
10301
|
+
FZ_REPO: value.repository,
|
|
10302
|
+
FZ_BRANCH: value.branch,
|
|
10303
|
+
FZ_SMTP_HOST: value.smtp?.host ?? "",
|
|
10304
|
+
FZ_SMTP_PORT: value.smtp ? String(value.smtp.port) : "",
|
|
10305
|
+
FZ_SMTP_USER: value.smtp?.user ?? "",
|
|
10306
|
+
FZ_SMTP_FROM: value.smtp?.from ?? "",
|
|
10307
|
+
BACKUP_S3_ENDPOINT: value.backup?.endpoint ?? "",
|
|
10308
|
+
BACKUP_S3_REGION: value.backup?.region ?? "",
|
|
10309
|
+
BACKUP_S3_BUCKET: value.backup?.bucket ?? "",
|
|
10310
|
+
BACKUP_S3_ACCESS_KEY_ID: value.backup?.accessKeyId ?? "",
|
|
10311
|
+
FZ_CF_ACCOUNT_ID: value.cloudflare?.accountId ?? "",
|
|
10312
|
+
FZ_CF_ZONE_ID: value.cloudflare?.zoneId ?? "",
|
|
10313
|
+
FZ_CF_KV_NAMESPACE_ID: value.cloudflare?.kvNamespaceId ?? "",
|
|
10314
|
+
FZ_CF_TUNNEL_ID: value.cloudflare?.tunnelId ?? "",
|
|
10315
|
+
FZ_CF_TUNNEL_SERVICE: value.cloudflare?.tunnelService ?? "",
|
|
10316
|
+
FZ_WARP_ORGANIZATION: value.cloudflare?.warp?.organization ?? "",
|
|
10317
|
+
FZ_CF_VIRTUAL_NETWORK_ID: value.cloudflare?.warp?.virtualNetworkId ?? "",
|
|
10318
|
+
FZ_CF_WARP_POLICY_ID: value.cloudflare?.warp?.deviceProfileId ?? ""
|
|
10319
|
+
};
|
|
10320
|
+
return `# Generated by fz bootstrap platform. Non-secret coordinates only.
|
|
10321
|
+
` + Object.entries(entries).map(([key, entry]) => `${key}=${systemdValue(key, entry)}`).join(`
|
|
10322
|
+
`) + `
|
|
10323
|
+
`;
|
|
10324
|
+
}
|
|
10325
|
+
function platformApiCredentialSpecs(options) {
|
|
10326
|
+
const optional = [
|
|
10327
|
+
["bootstrap-smtp-password", options.smtp],
|
|
10328
|
+
["cloudflare-kv-token", options.cloudflareKv],
|
|
10329
|
+
["cloudflare-network-token", options.cloudflareNetwork]
|
|
10330
|
+
];
|
|
10331
|
+
return [
|
|
10332
|
+
{ name: "arangodb-jwt", encryptedPath: "/etc/forgezero/creds/arangodb-jwt.cred", required: true },
|
|
10333
|
+
{ name: "seed-sync-root", encryptedPath: "/etc/forgezero/creds/seed-sync-root.cred", required: true },
|
|
10334
|
+
...optional.filter(([, present]) => present).map(([name]) => ({
|
|
10335
|
+
name,
|
|
10336
|
+
encryptedPath: `/etc/forgezero/creds/${name}.cred`,
|
|
10337
|
+
required: false
|
|
10338
|
+
}))
|
|
10339
|
+
];
|
|
10340
|
+
}
|
|
10341
|
+
function renderPlatformApiUnits(input) {
|
|
10342
|
+
for (const path of [input.sharedDirectory, input.sharedEnvironmentFile, input.slotsDirectory]) {
|
|
10343
|
+
if (!path.startsWith("/") || /[\r\n]/.test(path))
|
|
10344
|
+
throw new Error("Runtime paths must be absolute and single-line.");
|
|
10345
|
+
}
|
|
10346
|
+
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(input.serviceUser))
|
|
10347
|
+
throw new Error("Invalid service user.");
|
|
10348
|
+
validateCollectorUnit(input.collectorUnit);
|
|
10349
|
+
boundedInteger("bluePort", input.bluePort, 1024, 65535);
|
|
10350
|
+
boundedInteger("greenPort", input.greenPort, 1024, 65535);
|
|
10351
|
+
if (input.bluePort === input.greenPort)
|
|
10352
|
+
throw new Error("Blue and green ports must differ.");
|
|
10353
|
+
const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
|
|
10354
|
+
`);
|
|
10355
|
+
const template = `[Unit]
|
|
10356
|
+
Description=ForgeZero (%i slot)
|
|
10357
|
+
After=network-online.target ${input.collectorUnit}
|
|
10358
|
+
Wants=network-online.target ${input.collectorUnit}
|
|
10359
|
+
|
|
10360
|
+
[Service]
|
|
10361
|
+
Type=simple
|
|
10362
|
+
User=${input.serviceUser}
|
|
10363
|
+
WorkingDirectory=${input.slotsDirectory}/%i
|
|
10364
|
+
Environment=NODE_ENV=production
|
|
10365
|
+
Environment=FZ_SLOT=%i
|
|
10366
|
+
EnvironmentFile=${input.sharedEnvironmentFile}
|
|
10367
|
+
${credentials}
|
|
10368
|
+
ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
|
|
10369
|
+
Restart=always
|
|
10370
|
+
RestartSec=2
|
|
10371
|
+
TimeoutStopSec=35s
|
|
10372
|
+
LimitCORE=0
|
|
10373
|
+
UMask=0077
|
|
10374
|
+
NoNewPrivileges=yes
|
|
10375
|
+
PrivateTmp=yes
|
|
10376
|
+
PrivateDevices=yes
|
|
10377
|
+
ProtectSystem=strict
|
|
10378
|
+
ProtectHome=yes
|
|
10379
|
+
ReadOnlyPaths=${input.sharedDirectory}
|
|
10380
|
+
ProtectKernelTunables=yes
|
|
10381
|
+
ProtectKernelModules=yes
|
|
10382
|
+
ProtectControlGroups=yes
|
|
10383
|
+
RestrictSUIDSGID=yes
|
|
10384
|
+
RestrictRealtime=yes
|
|
10385
|
+
LockPersonality=yes
|
|
10386
|
+
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
|
10387
|
+
|
|
10388
|
+
[Install]
|
|
10389
|
+
WantedBy=multi-user.target
|
|
10390
|
+
`;
|
|
10391
|
+
return { template, dropIns: {
|
|
10392
|
+
blue: `[Service]
|
|
10393
|
+
Environment=PORT=${input.bluePort}
|
|
10394
|
+
`,
|
|
10395
|
+
green: `[Service]
|
|
10396
|
+
Environment=PORT=${input.greenPort}
|
|
10397
|
+
`
|
|
10398
|
+
} };
|
|
10399
|
+
}
|
|
10400
|
+
function renderPlatformNginx(input) {
|
|
10401
|
+
boundedInteger("publicPort", input.publicPort, 1024, 65535);
|
|
10402
|
+
boundedInteger("initialSlotPort", input.initialSlotPort, 1024, 65535);
|
|
10403
|
+
if (input.publicPort === input.initialSlotPort)
|
|
10404
|
+
throw new Error("Edge and slot ports must differ.");
|
|
10405
|
+
return {
|
|
10406
|
+
upstream: `upstream forgezero { server 127.0.0.1:${input.initialSlotPort}; }
|
|
10407
|
+
`,
|
|
10408
|
+
site: `server {
|
|
10409
|
+
listen 127.0.0.1:${input.publicPort};
|
|
10410
|
+
server_name _;
|
|
10411
|
+
location ^~ /api/ { proxy_pass http://forgezero; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }
|
|
10412
|
+
location ^~ /v1/ { proxy_pass http://forgezero; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }
|
|
10413
|
+
location / { return 404; }
|
|
10414
|
+
}
|
|
10415
|
+
`
|
|
10416
|
+
};
|
|
10417
|
+
}
|
|
10418
|
+
function renderPlatformActivationFiles(input) {
|
|
10419
|
+
if (!input.root.startsWith("/") || /[\0\r\n]/.test(input.root))
|
|
10420
|
+
throw new Error("Activation root must be absolute and single-line.");
|
|
10421
|
+
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(input.serviceUser))
|
|
10422
|
+
throw new Error("Invalid activation service user.");
|
|
10423
|
+
boundedInteger("bluePort", input.bluePort, 1024, 65535);
|
|
10424
|
+
boundedInteger("greenPort", input.greenPort, 1024, 65535);
|
|
10425
|
+
if (input.bluePort === input.greenPort)
|
|
10426
|
+
throw new Error("Activation slot ports must differ.");
|
|
10427
|
+
boundedInteger("keepReleases", input.keepReleases, 2, 100);
|
|
10428
|
+
if (!/^\/[A-Za-z0-9/_-]{1,128}$/.test(input.healthPath) || input.healthPath.includes("..")) {
|
|
10429
|
+
throw new Error("Activation health path is malformed.");
|
|
10430
|
+
}
|
|
10431
|
+
const environment = [
|
|
10432
|
+
`FZ_DIR=${input.root}`,
|
|
10433
|
+
`FZ_USER=${input.serviceUser}`,
|
|
10434
|
+
`FZ_BLUE_PORT=${input.bluePort}`,
|
|
10435
|
+
`FZ_GREEN_PORT=${input.greenPort}`,
|
|
10436
|
+
`FZ_HEALTH_PATH=${input.healthPath}`,
|
|
10437
|
+
`FZ_KEEP_RELEASES=${input.keepReleases}`
|
|
10438
|
+
].join(`
|
|
10439
|
+
`) + `
|
|
10440
|
+
`;
|
|
10441
|
+
const helper = `#!/usr/bin/env bash
|
|
10442
|
+
set -Eeuo pipefail
|
|
10443
|
+
source /etc/forgezero/deploy.env
|
|
10444
|
+
[[ $# == 1 ]] || { echo "usage: forgezero-activate <release>" >&2; exit 2; }
|
|
10445
|
+
release="$(realpath -e "$1")"; releases="$(realpath -e "$FZ_DIR/releases")"; slots="$FZ_DIR/slots"
|
|
10446
|
+
install -d -o root -g root -m 0755 "$slots"
|
|
10447
|
+
case "$release/" in "$releases"/*/) ;; *) echo "release is outside $releases" >&2; exit 2 ;; esac
|
|
10448
|
+
[[ -f "$release/.fz/deploy.json" && -s "$release/src/index.ts" && -s "$release/bun.lock" ]] || { echo "release is incomplete" >&2; exit 2; }
|
|
10449
|
+
slot_file="$FZ_DIR/.forge-slot"; previous_slot="$(cat "$slot_file" 2>/dev/null || true)"
|
|
10450
|
+
if [[ "$previous_slot" == blue ]]; then target=green; port="$FZ_GREEN_PORT"; else target=blue; port="$FZ_BLUE_PORT"; fi
|
|
10451
|
+
target_link="$slots/$target"; previous_target_link="$(readlink -f "$target_link" 2>/dev/null || true)"
|
|
10452
|
+
chown -R root:"$FZ_USER" "$release"; chmod -R a-w "$release"; find "$release" -type d -exec chmod a+rx {} +; find "$release" -type f -exec chmod a+r {} +
|
|
10453
|
+
ln -sfn "$release" "$target_link"; systemctl restart "forgezero@\${target}.service"
|
|
10454
|
+
healthy=0; for _ in $(seq 1 30); do curl -fsS --max-time 2 "http://127.0.0.1:\${port}\${FZ_HEALTH_PATH}" >/dev/null 2>&1 && { healthy=1; break; }; sleep 1; done
|
|
10455
|
+
if (( ! healthy )); then systemctl stop "forgezero@\${target}.service" || true; [[ -n "$previous_target_link" && -d "$previous_target_link" ]] && ln -sfn "$previous_target_link" "$target_link" || rm -f "$target_link"; exit 1; fi
|
|
10456
|
+
upstream=/etc/nginx/conf.d/forgezero-upstream.conf; backup="$(mktemp -p /run forgezero-upstream.XXXXXX)"; [[ -f "$upstream" ]] && cp "$upstream" "$backup" || : >"$backup"
|
|
10457
|
+
printf 'upstream forgezero { server 127.0.0.1:%s; }\\n' "$port" >"$upstream"
|
|
10458
|
+
if ! nginx -t || ! nginx -s reload; then [[ -s "$backup" ]] && cp "$backup" "$upstream" || rm -f "$upstream"; rm -f "$backup"; systemctl stop "forgezero@\${target}.service" || true; [[ -n "$previous_target_link" && -d "$previous_target_link" ]] && ln -sfn "$previous_target_link" "$target_link" || rm -f "$target_link"; nginx -t >/dev/null 2>&1 && nginx -s reload || true; exit 1; fi
|
|
10459
|
+
rm -f "$backup"; printf '%s\\n' "$target" >"$slot_file"; [[ -n "$previous_slot" && "$previous_slot" != "$target" ]] && systemctl stop "forgezero@\${previous_slot}.service" || true
|
|
10460
|
+
mapfile -t old < <(find "$releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -rn | tail -n "+$((FZ_KEEP_RELEASES + 1))" | cut -d' ' -f2-)
|
|
10461
|
+
for path in "\${old[@]}"; do [[ "$path" == "$release" ]] || rm -rf -- "$path"; done
|
|
10462
|
+
printf 'promoted %s on %s\\n' "$release" "$target"
|
|
10463
|
+
`;
|
|
10464
|
+
return {
|
|
10465
|
+
environment,
|
|
10466
|
+
helper,
|
|
10467
|
+
sudoers: `forgezero-runner ALL=(root) NOPASSWD: /usr/local/libexec/forgezero-activate *
|
|
10468
|
+
`
|
|
10469
|
+
};
|
|
10470
|
+
}
|
|
10471
|
+
function validateCollectorUnit(unit) {
|
|
10472
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}\.service$/.test(unit))
|
|
10473
|
+
throw new Error("Invalid OTLP collector service unit.");
|
|
10474
|
+
if (/^(forgezero@.*|forgezero-agent|forgezero-metal-agent|forgezero-db)\.service$/.test(unit)) {
|
|
10475
|
+
throw new Error("OTLP collector must be independently supervised.");
|
|
10476
|
+
}
|
|
10477
|
+
}
|
|
10478
|
+
function planLocalOtlpProof(endpoint, collectorUnit) {
|
|
10479
|
+
if (endpoint !== "http://127.0.0.1:4318")
|
|
10480
|
+
throw new Error("OTLP proof requires exact loopback endpoint http://127.0.0.1:4318.");
|
|
10481
|
+
validateCollectorUnit(collectorUnit);
|
|
10482
|
+
return {
|
|
10483
|
+
unitCheck: { command: "systemctl", argv: ["is-active", "--quiet", collectorUnit] },
|
|
10484
|
+
receiverCheck: {
|
|
10485
|
+
command: "curl",
|
|
10486
|
+
acceptedStatus: "2xx",
|
|
10487
|
+
argv: [
|
|
10488
|
+
"--silent",
|
|
10489
|
+
"--show-error",
|
|
10490
|
+
"--max-time",
|
|
10491
|
+
"5",
|
|
10492
|
+
"--output",
|
|
10493
|
+
"/dev/null",
|
|
10494
|
+
"--write-out",
|
|
10495
|
+
"%{http_code}",
|
|
10496
|
+
"--request",
|
|
10497
|
+
"POST",
|
|
10498
|
+
"--header",
|
|
10499
|
+
"Content-Type: application/json",
|
|
10500
|
+
"--data-binary",
|
|
10501
|
+
"{}",
|
|
10502
|
+
`${endpoint}/v1/metrics`
|
|
10503
|
+
]
|
|
10504
|
+
}
|
|
10505
|
+
};
|
|
10506
|
+
}
|
|
10507
|
+
|
|
10508
|
+
// src/cloudflare-bootstrap.ts
|
|
10509
|
+
import { constants } from "fs";
|
|
10510
|
+
import { chmod, lstat, mkdir, mkdtemp, open, rename, rm, stat, unlink } from "fs/promises";
|
|
10511
|
+
import { dirname as dirname3, join as join5, resolve as resolve2 } from "path";
|
|
10512
|
+
import { tmpdir } from "os";
|
|
10513
|
+
import { randomUUID } from "crypto";
|
|
10514
|
+
import { isIP as isIP3 } from "net";
|
|
10515
|
+
|
|
10516
|
+
// src/cloudflare-edge.ts
|
|
10517
|
+
import { isIP as isIP2 } from "net";
|
|
10518
|
+
function isPrivateDatabaseAddress(value) {
|
|
10519
|
+
const address = value.trim().toLowerCase();
|
|
10520
|
+
const family = isIP2(address);
|
|
10521
|
+
if (family === 4) {
|
|
10522
|
+
const [a, b] = address.split(".").map(Number);
|
|
10523
|
+
return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
|
|
10524
|
+
}
|
|
10525
|
+
if (family === 6) {
|
|
10526
|
+
const first = Number.parseInt(address.split(":", 1)[0], 16);
|
|
10527
|
+
return Number.isFinite(first) && (first & 65024) === 64512;
|
|
10528
|
+
}
|
|
10529
|
+
return false;
|
|
10530
|
+
}
|
|
10531
|
+
function privateDatabaseHostRoute(value) {
|
|
10532
|
+
const address = value.trim().toLowerCase();
|
|
10533
|
+
if (!isPrivateDatabaseAddress(address)) {
|
|
10534
|
+
throw new Error("database address must be an RFC 1918 IPv4 or unique-local IPv6 address");
|
|
10535
|
+
}
|
|
10536
|
+
return `${address}/${isIP2(address) === 4 ? 32 : 128}`;
|
|
10537
|
+
}
|
|
10538
|
+
var endpoint = "https://api.cloudflare.com/client/v4";
|
|
10539
|
+
async function cf(config, path, init = {}, fetcher = fetch) {
|
|
10540
|
+
const response = await fetcher(`${endpoint}${path}`, {
|
|
10541
|
+
...init,
|
|
10542
|
+
headers: {
|
|
10543
|
+
authorization: `Bearer ${config.apiToken}`,
|
|
10544
|
+
"content-type": "application/json",
|
|
10545
|
+
...init.headers
|
|
10546
|
+
}
|
|
10547
|
+
});
|
|
10548
|
+
const body = await response.json();
|
|
10549
|
+
if (!response.ok || body.success !== true) {
|
|
10550
|
+
throw new Error(body.errors?.map(({ message }) => message).filter(Boolean).join("; ") || `Cloudflare returned HTTP ${response.status}`);
|
|
10551
|
+
}
|
|
10552
|
+
return body.result;
|
|
10553
|
+
}
|
|
10554
|
+
async function ensureCloudflarePrivateRoute(config, fetcher = fetch) {
|
|
10555
|
+
const [address, prefixText, ...extra] = config.network.split("/");
|
|
10556
|
+
const family = isIP2(address ?? "");
|
|
10557
|
+
const prefix = Number(prefixText);
|
|
10558
|
+
if (extra.length > 0 || !family || !Number.isInteger(prefix) || prefix < 0 || prefix > (family === 4 ? 32 : 128)) {
|
|
10559
|
+
throw new Error("Cloudflare private route must be an explicit IPv4 or IPv6 CIDR");
|
|
10560
|
+
}
|
|
10561
|
+
const path = `/accounts/${config.accountId}/teamnet/routes`;
|
|
10562
|
+
const routes = await cf(config, path, {}, fetcher);
|
|
10563
|
+
const current = routes.find((route2) => !route2.deleted_at && route2.network === config.network && (route2.virtual_network_id ?? "") === (config.virtualNetworkId ?? ""));
|
|
10564
|
+
if (current) {
|
|
10565
|
+
if (current.tunnel_id !== config.tunnelId) {
|
|
10566
|
+
throw new Error(`private route ${config.network} already belongs to another Tunnel`);
|
|
10567
|
+
}
|
|
10568
|
+
return { route: current, created: false };
|
|
10569
|
+
}
|
|
10570
|
+
const route = await cf(config, path, {
|
|
10571
|
+
method: "POST",
|
|
10572
|
+
body: JSON.stringify({
|
|
10573
|
+
network: config.network,
|
|
10574
|
+
tunnel_id: config.tunnelId,
|
|
10575
|
+
comment: config.comment.slice(0, 100),
|
|
10576
|
+
...config.virtualNetworkId ? { virtual_network_id: config.virtualNetworkId } : {}
|
|
10577
|
+
})
|
|
10578
|
+
}, fetcher);
|
|
10579
|
+
return { route, created: true };
|
|
10580
|
+
}
|
|
10581
|
+
async function ensureCloudflarePrivateDatabaseRoute(config, fetcher = fetch) {
|
|
10582
|
+
return ensureCloudflarePrivateRoute({
|
|
10583
|
+
...config,
|
|
10584
|
+
network: privateDatabaseHostRoute(config.privateAddress)
|
|
10585
|
+
}, fetcher);
|
|
10586
|
+
}
|
|
10587
|
+
async function ensureCloudflareWarpDatabaseInclude(config, fetcher = fetch) {
|
|
10588
|
+
if (config.policyId && !/^[A-Za-z0-9-]{1,64}$/.test(config.policyId))
|
|
10589
|
+
throw new Error("Cloudflare WARP policy id is invalid");
|
|
10590
|
+
const route = privateDatabaseHostRoute(config.privateAddress);
|
|
10591
|
+
const policy = config.policyId ? `/${config.policyId}` : "";
|
|
10592
|
+
const path = `/accounts/${config.accountId}/devices/policy${policy}/include`;
|
|
10593
|
+
const entries = await cf(config, path, {}, fetcher);
|
|
10594
|
+
if (entries.some((entry) => entry.address === route))
|
|
10595
|
+
return { entries, created: false };
|
|
10596
|
+
const next = [...entries, { address: route, description: config.description.slice(0, 100) }];
|
|
10597
|
+
const updated = await cf(config, path, {
|
|
10598
|
+
method: "PUT",
|
|
10599
|
+
body: JSON.stringify(next)
|
|
10600
|
+
}, fetcher);
|
|
10601
|
+
return { entries: updated, created: true };
|
|
10602
|
+
}
|
|
10603
|
+
async function configureCloudflareEdge(config, fetcher = fetch) {
|
|
10604
|
+
const tunnelAuth = { apiToken: config.tunnelApiToken?.trim() || config.apiToken };
|
|
10605
|
+
const dnsAuth = { apiToken: config.dnsApiToken?.trim() || config.apiToken };
|
|
10606
|
+
const tunnelPath = `/accounts/${config.accountId}/cfd_tunnel/${config.tunnelId}/configurations`;
|
|
10607
|
+
const current = await cf(tunnelAuth, tunnelPath, {}, fetcher);
|
|
10608
|
+
const existing = current.config?.ingress ?? [];
|
|
10609
|
+
const catchAll = existing.filter((rule) => !("hostname" in rule));
|
|
10610
|
+
const otherHosts = existing.filter((rule) => ("hostname" in rule) && rule.hostname !== config.hostname);
|
|
10611
|
+
await cf(tunnelAuth, tunnelPath, {
|
|
10612
|
+
method: "PUT",
|
|
10613
|
+
body: JSON.stringify({ config: { ingress: [
|
|
10614
|
+
{ hostname: config.hostname, service: config.service },
|
|
10615
|
+
...otherHosts,
|
|
10616
|
+
...catchAll.length > 0 ? catchAll : [{ service: "http_status:404" }]
|
|
10617
|
+
] } })
|
|
10618
|
+
}, fetcher);
|
|
10619
|
+
const dnsPath = `/zones/${config.zoneId}/dns_records`;
|
|
10620
|
+
const records = await cf(dnsAuth, `${dnsPath}?type=CNAME&name=${encodeURIComponent(config.hostname)}&per_page=1000`, {}, fetcher);
|
|
10621
|
+
if (records.length > 1) {
|
|
10622
|
+
throw new Error(`Cloudflare DNS record for ${config.hostname} is ambiguous`);
|
|
10623
|
+
}
|
|
10624
|
+
const record2 = {
|
|
10625
|
+
type: "CNAME",
|
|
10626
|
+
name: config.hostname,
|
|
10627
|
+
content: `${config.tunnelId}.cfargotunnel.com`,
|
|
10628
|
+
proxied: true,
|
|
10629
|
+
ttl: 1
|
|
10630
|
+
};
|
|
10631
|
+
await cf(dnsAuth, records[0] ? `${dnsPath}/${records[0].id}` : dnsPath, {
|
|
10632
|
+
method: records[0] ? "PUT" : "POST",
|
|
10633
|
+
body: JSON.stringify(record2)
|
|
10634
|
+
}, fetcher);
|
|
10635
|
+
}
|
|
10636
|
+
var exactAccountPermissionGroup = async (config, name, fetcher) => {
|
|
10637
|
+
const groups = await cf(config, `/accounts/${config.accountId}/tokens/permission_groups?name=${encodeURIComponent(name)}` + "&scope=com.cloudflare.api.account", {}, fetcher);
|
|
10638
|
+
const matches = groups.filter((group) => group.name === name && group.scopes?.includes("com.cloudflare.api.account") && Boolean(group.id && /^[a-f0-9]{32}$/i.test(group.id)));
|
|
10639
|
+
if (matches.length !== 1) {
|
|
10640
|
+
throw new Error(`Cloudflare account token permission group ${name} is ${matches.length === 0 ? "missing" : "ambiguous"}`);
|
|
10641
|
+
}
|
|
10642
|
+
return { id: matches[0].id, name };
|
|
10643
|
+
};
|
|
10644
|
+
async function createCloudflareAccountRuntimeToken(config, fetcher = fetch) {
|
|
10645
|
+
if (!/^[a-f0-9]{32}$/i.test(config.accountId))
|
|
10646
|
+
throw new Error("Cloudflare account id is invalid");
|
|
10647
|
+
const name = config.name.trim();
|
|
10648
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,119}$/.test(name)) {
|
|
10649
|
+
throw new Error("Cloudflare account runtime-token name is invalid");
|
|
10650
|
+
}
|
|
10651
|
+
const permissionNames = [...new Set(config.permissionNames)];
|
|
10652
|
+
if (permissionNames.length === 0)
|
|
10653
|
+
throw new Error("Cloudflare account runtime token needs a permission group");
|
|
10654
|
+
const permissionGroups = await Promise.all(permissionNames.map((permissionName) => exactAccountPermissionGroup(config, permissionName, fetcher)));
|
|
10655
|
+
const body = {
|
|
10656
|
+
name,
|
|
10657
|
+
policies: [{
|
|
10658
|
+
effect: "allow",
|
|
10659
|
+
permission_groups: permissionGroups.map(({ id: id2 }) => ({ id: id2 })),
|
|
10660
|
+
resources: { [`com.cloudflare.api.account.${config.accountId}`]: "*" }
|
|
10661
|
+
}]
|
|
10662
|
+
};
|
|
10663
|
+
const created = await cf(config, `/accounts/${config.accountId}/tokens`, { method: "POST", body: JSON.stringify(body) }, fetcher);
|
|
10664
|
+
if (!created.id || !/^[a-f0-9]{32}$/i.test(created.id) || !created.value || !/^[A-Za-z0-9._-]{40,80}$/.test(created.value)) {
|
|
10665
|
+
throw new Error("Cloudflare did not return the one-time account runtime-token id and value");
|
|
10666
|
+
}
|
|
10667
|
+
return { id: created.id, value: created.value, name, permissionNames };
|
|
10668
|
+
}
|
|
10669
|
+
async function ensureCloudflareAccessServiceToken(config, fetcher = fetch) {
|
|
10670
|
+
const name = config.name.trim();
|
|
10671
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name)) {
|
|
10672
|
+
throw new Error("Cloudflare Access service-token name is invalid");
|
|
10673
|
+
}
|
|
10674
|
+
const path = `/accounts/${config.accountId}/access/service_tokens`;
|
|
10675
|
+
const tokens = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
10676
|
+
const matches = tokens.filter((token) => token.name === name);
|
|
10677
|
+
if (matches.length > 1)
|
|
10678
|
+
throw new Error(`Cloudflare Access service token ${name} is ambiguous`);
|
|
10679
|
+
if (matches[0]) {
|
|
10680
|
+
if (!config.existing || config.existing.tokenId !== matches[0].id || config.existing.clientId !== matches[0].client_id || !config.existing.clientSecret) {
|
|
10681
|
+
throw new Error(`Cloudflare Access service token ${name} exists but its one-time client secret was not supplied`);
|
|
10682
|
+
}
|
|
10683
|
+
return { credentials: config.existing, created: false };
|
|
10684
|
+
}
|
|
10685
|
+
const created = await cf(config, path, {
|
|
10686
|
+
method: "POST",
|
|
10687
|
+
body: JSON.stringify({ name, duration: config.duration ?? "8760h" })
|
|
10688
|
+
}, fetcher);
|
|
10689
|
+
if (!created.id || !created.client_id || !created.client_secret) {
|
|
10690
|
+
throw new Error("Cloudflare did not return the new Access service-token secret");
|
|
10691
|
+
}
|
|
10692
|
+
return {
|
|
10693
|
+
credentials: {
|
|
10694
|
+
tokenId: created.id,
|
|
10695
|
+
clientId: created.client_id,
|
|
10696
|
+
clientSecret: created.client_secret
|
|
10697
|
+
},
|
|
10698
|
+
created: true
|
|
10699
|
+
};
|
|
10700
|
+
}
|
|
10701
|
+
async function ensureCloudflareAccessPolicy(config, fetcher = fetch) {
|
|
10702
|
+
const path = `/accounts/${config.accountId}/access/policies`;
|
|
10703
|
+
const policies = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
10704
|
+
const matches = policies.filter((policy2) => policy2.name === config.name);
|
|
10705
|
+
if (matches.length > 1)
|
|
10706
|
+
throw new Error(`Cloudflare Access policy ${config.name} is ambiguous`);
|
|
10707
|
+
const desired = {
|
|
10708
|
+
name: config.name,
|
|
10709
|
+
decision: "non_identity",
|
|
10710
|
+
include: [{ service_token: { token_id: config.serviceTokenId } }]
|
|
10711
|
+
};
|
|
10712
|
+
const policy = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, {
|
|
10713
|
+
method: matches[0] ? "PUT" : "POST",
|
|
10714
|
+
body: JSON.stringify(desired)
|
|
10715
|
+
}, fetcher);
|
|
10716
|
+
return { policy, created: !matches[0] };
|
|
10717
|
+
}
|
|
10718
|
+
async function ensureCloudflareAccessApplication(config, fetcher = fetch) {
|
|
10719
|
+
const path = `/accounts/${config.accountId}/access/apps`;
|
|
10720
|
+
const applications = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
10721
|
+
const matches = applications.filter((application2) => application2.domain === config.hostname || application2.self_hosted_domains?.includes(config.hostname));
|
|
10722
|
+
if (matches.length > 1)
|
|
10723
|
+
throw new Error(`Cloudflare Access application for ${config.hostname} is ambiguous`);
|
|
10724
|
+
const desired = {
|
|
10725
|
+
name: config.name,
|
|
10726
|
+
type: "self_hosted",
|
|
10727
|
+
domain: config.hostname,
|
|
10728
|
+
session_duration: "24h",
|
|
10729
|
+
service_auth_401_redirect: true,
|
|
10730
|
+
policies: [{ id: config.policyId, precedence: 1 }]
|
|
10731
|
+
};
|
|
10732
|
+
const application = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, { method: matches[0] ? "PUT" : "POST", body: JSON.stringify(desired) }, fetcher);
|
|
10733
|
+
return { application, created: !matches[0] };
|
|
10734
|
+
}
|
|
10735
|
+
async function ensureCloudflareWarpEnrollmentApplication(config, fetcher = fetch) {
|
|
10736
|
+
const name = config.name.trim();
|
|
10737
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name)) {
|
|
10738
|
+
throw new Error("Cloudflare WARP enrollment application name is invalid");
|
|
10739
|
+
}
|
|
10740
|
+
const path = `/accounts/${config.accountId}/access/apps`;
|
|
10741
|
+
const applications = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
10742
|
+
const matches = applications.filter((application2) => application2.type === "warp" || application2.name === name);
|
|
10743
|
+
if (matches.length > 1)
|
|
10744
|
+
throw new Error(`Cloudflare WARP enrollment application ${name} is ambiguous`);
|
|
10745
|
+
if (matches[0] && (matches[0].type !== "warp" || matches[0].name !== name)) {
|
|
10746
|
+
throw new Error(`Cloudflare Access application ${name} is not the owned WARP enrollment application`);
|
|
10747
|
+
}
|
|
10748
|
+
const desired = {
|
|
10749
|
+
name,
|
|
10750
|
+
type: "warp",
|
|
10751
|
+
policies: [{ id: config.policyId, precedence: 1 }]
|
|
10752
|
+
};
|
|
10753
|
+
const application = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, { method: matches[0] ? "PUT" : "POST", body: JSON.stringify(desired) }, fetcher);
|
|
10754
|
+
if (!application.id || application.type && application.type !== "warp") {
|
|
10755
|
+
throw new Error("Cloudflare did not return the WARP enrollment application");
|
|
10756
|
+
}
|
|
10757
|
+
return { application, created: !matches[0] };
|
|
10758
|
+
}
|
|
10759
|
+
async function ensureCloudflareVirtualNetwork(config, fetcher = fetch) {
|
|
10760
|
+
const name = config.name.trim();
|
|
10761
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name))
|
|
10762
|
+
throw new Error("Cloudflare VNET name is invalid");
|
|
10763
|
+
const path = `/accounts/${config.accountId}/teamnet/virtual_networks`;
|
|
10764
|
+
const networks = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
10765
|
+
const matches = networks.filter((network) => !network.deleted_at && network.name === name);
|
|
10766
|
+
if (matches.length > 1)
|
|
10767
|
+
throw new Error(`Cloudflare VNET ${name} is ambiguous`);
|
|
10768
|
+
if (matches[0])
|
|
10769
|
+
return { virtualNetwork: matches[0], created: false };
|
|
10770
|
+
const virtualNetwork = await cf(config, path, {
|
|
10771
|
+
method: "POST",
|
|
10772
|
+
body: JSON.stringify({ name, comment: config.comment.slice(0, 256), is_default_network: false })
|
|
10773
|
+
}, fetcher);
|
|
10774
|
+
if (!virtualNetwork.id || !/^[0-9a-f-]{36}$/i.test(virtualNetwork.id)) {
|
|
10775
|
+
throw new Error("Cloudflare did not return the VNET id");
|
|
10776
|
+
}
|
|
10777
|
+
return { virtualNetwork, created: true };
|
|
10778
|
+
}
|
|
10779
|
+
async function ensureCloudflareWarpDevicePolicy(config, fetcher = fetch) {
|
|
10780
|
+
const name = config.name.trim();
|
|
10781
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name))
|
|
10782
|
+
throw new Error("Cloudflare WARP device profile name is invalid");
|
|
10783
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(config.serviceTokenId))
|
|
10784
|
+
throw new Error("Cloudflare service-token id is invalid");
|
|
10785
|
+
if (!/^[0-9a-f-]{36}$/i.test(config.virtualNetworkId))
|
|
10786
|
+
throw new Error("Cloudflare VNET id is invalid");
|
|
10787
|
+
const precedence = config.precedence ?? 100;
|
|
10788
|
+
if (!Number.isInteger(precedence) || precedence < 1 || precedence > 999999) {
|
|
10789
|
+
throw new Error("Cloudflare WARP device profile precedence is invalid");
|
|
10790
|
+
}
|
|
10791
|
+
const match = `identity.service_token_uuid == "${config.serviceTokenId}"`;
|
|
10792
|
+
const listPath = `/accounts/${config.accountId}/devices/policies`;
|
|
10793
|
+
const path = `/accounts/${config.accountId}/devices/policy`;
|
|
10794
|
+
const policies = await cf(config, `${listPath}?per_page=1000`, {}, fetcher);
|
|
10795
|
+
const matches = policies.filter((policy2) => policy2.name === name);
|
|
10796
|
+
if (matches.length > 1)
|
|
10797
|
+
throw new Error(`Cloudflare WARP device profile ${name} is ambiguous`);
|
|
10798
|
+
if (matches[0]?.match && matches[0].match !== match) {
|
|
10799
|
+
throw new Error(`Cloudflare WARP device profile ${name} belongs to another enrollment identity`);
|
|
10800
|
+
}
|
|
10801
|
+
const desired = {
|
|
10802
|
+
name,
|
|
10803
|
+
match,
|
|
10804
|
+
precedence,
|
|
10805
|
+
description: "ForgeZero non-interactive compute enrollment",
|
|
10806
|
+
enabled: true,
|
|
10807
|
+
allow_mode_switch: false,
|
|
10808
|
+
allowed_to_leave: false,
|
|
10809
|
+
auto_connect: 0,
|
|
10810
|
+
switch_locked: true,
|
|
10811
|
+
service_mode_v2: { mode: "warp" },
|
|
10812
|
+
virtual_networks: { allowed: [config.virtualNetworkId], default: config.virtualNetworkId }
|
|
10813
|
+
};
|
|
10814
|
+
const policy = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, { method: matches[0] ? "PATCH" : "POST", body: JSON.stringify(desired) }, fetcher);
|
|
10815
|
+
if (!policy.id)
|
|
10816
|
+
throw new Error("Cloudflare did not return the WARP device profile id");
|
|
10817
|
+
return { policy, created: !matches[0] };
|
|
10818
|
+
}
|
|
10819
|
+
async function configureCloudflareWorkerAccessSecrets(config, fetcher = fetch) {
|
|
10820
|
+
if (!/^[a-z][a-z0-9-]{0,62}$/.test(config.scriptName)) {
|
|
10821
|
+
throw new Error("Cloudflare Worker script name is invalid");
|
|
10822
|
+
}
|
|
10823
|
+
await cf(config, `/accounts/${config.accountId}/workers/scripts/${config.scriptName}/secrets-bulk`, {
|
|
10824
|
+
method: "PATCH",
|
|
10825
|
+
body: JSON.stringify({
|
|
10826
|
+
secrets: {
|
|
10827
|
+
CF_ACCESS_CLIENT_ID: {
|
|
10828
|
+
name: "CF_ACCESS_CLIENT_ID",
|
|
10829
|
+
type: "secret_text",
|
|
10830
|
+
text: config.credentials.clientId
|
|
10831
|
+
},
|
|
10832
|
+
CF_ACCESS_CLIENT_SECRET: {
|
|
10833
|
+
name: "CF_ACCESS_CLIENT_SECRET",
|
|
10834
|
+
type: "secret_text",
|
|
10835
|
+
text: config.credentials.clientSecret
|
|
10836
|
+
}
|
|
10837
|
+
}
|
|
10838
|
+
})
|
|
10839
|
+
}, fetcher);
|
|
10840
|
+
}
|
|
10841
|
+
async function ensureCloudflareKvNamespace(config, fetcher = fetch) {
|
|
10842
|
+
const title = config.title.trim();
|
|
10843
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(title)) {
|
|
10844
|
+
throw new Error("Cloudflare KV namespace title is invalid");
|
|
10845
|
+
}
|
|
10846
|
+
const path = `/accounts/${config.accountId}/storage/kv/namespaces`;
|
|
10847
|
+
const namespaces = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
10848
|
+
const matches = namespaces.filter((namespace2) => namespace2.title === title);
|
|
10849
|
+
if (matches.length > 1)
|
|
10850
|
+
throw new Error(`Cloudflare KV namespace ${title} is ambiguous`);
|
|
10851
|
+
if (matches[0])
|
|
10852
|
+
return { namespace: matches[0], created: false };
|
|
10853
|
+
const namespace = await cf(config, path, {
|
|
10854
|
+
method: "POST",
|
|
10855
|
+
body: JSON.stringify({ title })
|
|
10856
|
+
}, fetcher);
|
|
10857
|
+
return { namespace, created: true };
|
|
10858
|
+
}
|
|
10859
|
+
async function ensureCloudflareTunnel(config, fetcher = fetch) {
|
|
10860
|
+
const name = config.name.trim();
|
|
10861
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(name)) {
|
|
10862
|
+
throw new Error("Cloudflare Tunnel name is invalid");
|
|
10863
|
+
}
|
|
10864
|
+
const path = `/accounts/${config.accountId}/cfd_tunnel`;
|
|
10865
|
+
const tunnels = await cf(config, `${path}?is_deleted=false&name=${encodeURIComponent(name)}&per_page=1000`, {}, fetcher);
|
|
10866
|
+
const matches = tunnels.filter((tunnel2) => tunnel2.name === name && !tunnel2.deleted_at);
|
|
10867
|
+
if (matches.length > 1)
|
|
10868
|
+
throw new Error(`Cloudflare Tunnel ${name} is ambiguous`);
|
|
10869
|
+
const created = !matches[0];
|
|
10870
|
+
const tunnel = matches[0] ?? await cf(config, path, {
|
|
10871
|
+
method: "POST",
|
|
10872
|
+
body: JSON.stringify({ name, config_src: "cloudflare" })
|
|
10873
|
+
}, fetcher);
|
|
10874
|
+
const connectorToken = await cf(config, `${path}/${encodeURIComponent(tunnel.id)}/token`, {}, fetcher);
|
|
10875
|
+
if (!connectorToken || connectorToken.length > 16384) {
|
|
10876
|
+
throw new Error("Cloudflare returned an invalid Tunnel connector token");
|
|
10877
|
+
}
|
|
10878
|
+
return { tunnel, connectorToken, created };
|
|
10879
|
+
}
|
|
10880
|
+
|
|
10881
|
+
// src/cloudflare-bootstrap.ts
|
|
10882
|
+
var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
10883
|
+
async function assertOwnerOnlyHandle(path, handle, maximumBytes) {
|
|
10884
|
+
const metadata = await handle.stat();
|
|
10885
|
+
if (!metadata.isFile())
|
|
10886
|
+
throw new Error(`${path} must be a regular file`);
|
|
10887
|
+
if (metadata.nlink !== 1)
|
|
10888
|
+
throw new Error(`${path} must not have multiple hard links`);
|
|
10889
|
+
const uid = ownerUid();
|
|
10890
|
+
if (uid !== undefined && uid !== 0 && metadata.uid !== uid)
|
|
10891
|
+
throw new Error(`${path} must be owned by the current operator`);
|
|
10892
|
+
if ((metadata.mode & 63) !== 0)
|
|
10893
|
+
throw new Error(`${path} must not be accessible by group or other users`);
|
|
10894
|
+
if ((metadata.mode & 256) === 0)
|
|
10895
|
+
throw new Error(`${path} must be readable by its owner`);
|
|
10896
|
+
if (metadata.size < 1 || metadata.size > maximumBytes)
|
|
10897
|
+
throw new Error(`${path} has an invalid size`);
|
|
10898
|
+
}
|
|
10899
|
+
async function readOwnerOnlyFile(path, maximumBytes) {
|
|
10900
|
+
const absolute = resolve2(path);
|
|
10901
|
+
let handle;
|
|
10902
|
+
try {
|
|
10903
|
+
handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
10904
|
+
await assertOwnerOnlyHandle(absolute, handle, maximumBytes);
|
|
10905
|
+
return await handle.readFile({ encoding: "utf8" });
|
|
10906
|
+
} catch (cause) {
|
|
10907
|
+
if (cause instanceof Error && cause.message.startsWith(absolute))
|
|
10908
|
+
throw cause;
|
|
10909
|
+
throw new Error(`cannot securely read owner-only file ${absolute}`);
|
|
10910
|
+
} finally {
|
|
10911
|
+
await handle?.close();
|
|
10912
|
+
}
|
|
10913
|
+
}
|
|
10914
|
+
async function readOwnerApiToken(path) {
|
|
10915
|
+
const token = (await readOwnerOnlyFile(path, 4096)).trim();
|
|
10916
|
+
if (!/^[A-Za-z0-9._-]{40,80}$/.test(token)) {
|
|
10917
|
+
throw new Error(`${resolve2(path)} must contain exactly one Cloudflare API token`);
|
|
10918
|
+
}
|
|
10919
|
+
return token;
|
|
10920
|
+
}
|
|
10921
|
+
async function readCloudflareBootstrapTokens(files) {
|
|
10922
|
+
const entries = await Promise.all([
|
|
10923
|
+
["apiToken", files.apiTokenFile],
|
|
10924
|
+
["tunnelApiToken", files.tunnelApiTokenFile],
|
|
10925
|
+
["dnsApiToken", files.dnsApiTokenFile],
|
|
10926
|
+
["kvApiToken", files.kvApiTokenFile],
|
|
10927
|
+
["accessApiToken", files.accessApiTokenFile],
|
|
10928
|
+
["workerApiToken", files.workerApiTokenFile]
|
|
10929
|
+
].map(async ([key, path]) => [key, path ? await readOwnerApiToken(path) : undefined]));
|
|
10930
|
+
const tokens = Object.fromEntries(entries.filter(([, value]) => value !== undefined));
|
|
10931
|
+
const unified = tokens.apiToken;
|
|
10932
|
+
for (const key of ["tunnelApiToken", "dnsApiToken", "kvApiToken", "accessApiToken", "workerApiToken"]) {
|
|
10933
|
+
if (!tokens[key] && !unified)
|
|
10934
|
+
throw new Error(`Cloudflare ${key} file is required when --token-file is omitted`);
|
|
10935
|
+
}
|
|
10936
|
+
return tokens;
|
|
10937
|
+
}
|
|
10938
|
+
var validateId = (value, label) => {
|
|
10939
|
+
const normalized = value.trim().toLowerCase();
|
|
10940
|
+
if (!/^[a-f0-9]{32}$/.test(normalized))
|
|
10941
|
+
throw new Error(`${label} must be a 32-character hexadecimal id`);
|
|
10942
|
+
return normalized;
|
|
10943
|
+
};
|
|
10944
|
+
var validateName = (value, label, maximum, allowSpaces = true) => {
|
|
10945
|
+
const normalized = value.trim();
|
|
10946
|
+
const pattern = allowSpaces ? /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/ : /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
10947
|
+
if (!normalized || normalized.length > maximum || !pattern.test(normalized)) {
|
|
10948
|
+
throw new Error(`${label} is invalid`);
|
|
10949
|
+
}
|
|
10950
|
+
return normalized;
|
|
10951
|
+
};
|
|
10952
|
+
var privateAddress = (value) => {
|
|
10953
|
+
const address = value.trim().toLowerCase();
|
|
10954
|
+
const family = isIP3(address);
|
|
10955
|
+
if (family === 4) {
|
|
10956
|
+
const [a, b] = address.split(".").map(Number);
|
|
10957
|
+
if (a === 10 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31)
|
|
10958
|
+
return address;
|
|
10959
|
+
}
|
|
10960
|
+
if (family === 6) {
|
|
10961
|
+
const first = Number.parseInt(address.split(":", 1)[0], 16);
|
|
10962
|
+
if (Number.isFinite(first) && (first & 65024) === 64512)
|
|
10963
|
+
return address;
|
|
10964
|
+
}
|
|
10965
|
+
throw new Error("Cloudflare private database address must be RFC 1918 IPv4 or unique-local IPv6");
|
|
10966
|
+
};
|
|
10967
|
+
function validateCloudflareBootstrapCoordinates(input) {
|
|
10968
|
+
const nodeInputs = input.nodes?.length ? input.nodes : [{
|
|
10969
|
+
nodeName: input.tunnelName,
|
|
10970
|
+
hostname: input.hostname,
|
|
10971
|
+
service: input.service,
|
|
10972
|
+
tunnelName: input.tunnelName,
|
|
10973
|
+
applicationName: input.applicationName
|
|
10974
|
+
}];
|
|
10975
|
+
if (nodeInputs.length < 1 || nodeInputs.length > 32) {
|
|
10976
|
+
throw new Error("Cloudflare bootstrap requires between 1 and 32 explicit nodes");
|
|
10977
|
+
}
|
|
10978
|
+
const nodes = nodeInputs.map((node) => {
|
|
10979
|
+
const nodeName = node.nodeName.trim().toLowerCase();
|
|
10980
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(nodeName))
|
|
10981
|
+
throw new Error("Cloudflare node name is invalid");
|
|
10982
|
+
const hostname = node.hostname.trim().toLowerCase();
|
|
10983
|
+
if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname)) {
|
|
10984
|
+
throw new Error("Cloudflare public node hostname is invalid");
|
|
10985
|
+
}
|
|
10986
|
+
const serviceUrl = new URL(node.service);
|
|
10987
|
+
if (serviceUrl.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(serviceUrl.hostname) || !serviceUrl.port || serviceUrl.pathname !== "/" || serviceUrl.search || serviceUrl.hash) {
|
|
10988
|
+
throw new Error("Cloudflare Tunnel service must be an explicit loopback HTTP port");
|
|
10989
|
+
}
|
|
10990
|
+
return {
|
|
10991
|
+
nodeName,
|
|
10992
|
+
hostname,
|
|
10993
|
+
service: serviceUrl.toString().replace(/\/$/, ""),
|
|
10994
|
+
tunnelName: validateName(node.tunnelName, "Cloudflare Tunnel name", 100, false),
|
|
10995
|
+
applicationName: validateName(node.applicationName, "Cloudflare Access application name", 100),
|
|
10996
|
+
...node.privateAddress ? { privateAddress: privateAddress(node.privateAddress) } : {}
|
|
10997
|
+
};
|
|
10998
|
+
});
|
|
10999
|
+
for (const [label, values] of [
|
|
11000
|
+
["node name", nodes.map(({ nodeName }) => nodeName)],
|
|
11001
|
+
["hostname", nodes.map(({ hostname }) => hostname)],
|
|
11002
|
+
["Tunnel name", nodes.map(({ tunnelName }) => tunnelName)],
|
|
11003
|
+
["Access application name", nodes.map(({ applicationName }) => applicationName)]
|
|
11004
|
+
]) {
|
|
11005
|
+
if (new Set(values).size !== values.length)
|
|
11006
|
+
throw new Error(`Cloudflare fleet ${label} must be unique`);
|
|
11007
|
+
}
|
|
11008
|
+
const first = nodes[0];
|
|
11009
|
+
const workerScriptName = input.workerScriptName.trim();
|
|
11010
|
+
if (!/^[a-z][a-z0-9-]{0,62}$/.test(workerScriptName))
|
|
11011
|
+
throw new Error("Cloudflare Worker script name is invalid");
|
|
11012
|
+
const workerCompatibilityDate = input.workerCompatibilityDate.trim();
|
|
11013
|
+
if (!/^20\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/.test(workerCompatibilityDate)) {
|
|
11014
|
+
throw new Error("Cloudflare Worker compatibility date is invalid");
|
|
11015
|
+
}
|
|
11016
|
+
const publicDomains = [...new Set(input.publicDomains.map((domain) => domain.trim().toLowerCase()))];
|
|
11017
|
+
if (publicDomains.length < 1 || publicDomains.length > 10 || publicDomains.some((domain) => !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain) || nodes.some(({ hostname }) => hostname === domain))) {
|
|
11018
|
+
throw new Error("Cloudflare Worker public domains are invalid or include the private origin hostname");
|
|
11019
|
+
}
|
|
11020
|
+
const workerDirectory = resolve2(input.workerDirectory);
|
|
11021
|
+
const workerMain = input.workerMain.trim();
|
|
11022
|
+
if (!workerMain || workerMain.startsWith("/") || workerMain.split(/[\\/]/).includes("..")) {
|
|
11023
|
+
throw new Error("Cloudflare Worker main must be a project-relative path");
|
|
11024
|
+
}
|
|
11025
|
+
const runtimeTokenNamePrefix = input.runtimeTokenNamePrefix.trim();
|
|
11026
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(runtimeTokenNamePrefix)) {
|
|
11027
|
+
throw new Error("Cloudflare runtime-token name prefix is invalid");
|
|
11028
|
+
}
|
|
11029
|
+
if (input.createPrivateNetworkRuntimeToken && !input.createRuntimeTokens) {
|
|
11030
|
+
throw new Error("private-network runtime token requires runtime-token creation");
|
|
11031
|
+
}
|
|
11032
|
+
const privateNetwork = input.privateNetwork ? {
|
|
11033
|
+
warpOrganization: validateName(input.privateNetwork.warpOrganization, "Cloudflare WARP organization", 63, false).toLowerCase(),
|
|
11034
|
+
virtualNetworkName: validateName(input.privateNetwork.virtualNetworkName, "Cloudflare VNET name", 100),
|
|
11035
|
+
deviceProfileName: validateName(input.privateNetwork.deviceProfileName, "Cloudflare WARP device profile name", 100),
|
|
11036
|
+
enrollmentApplicationName: validateName(input.privateNetwork.enrollmentApplicationName, "Cloudflare WARP enrollment application name", 100),
|
|
11037
|
+
...input.privateNetwork.deviceProfilePrecedence !== undefined ? { deviceProfilePrecedence: input.privateNetwork.deviceProfilePrecedence } : {}
|
|
11038
|
+
} : undefined;
|
|
11039
|
+
if (privateNetwork && (!input.createPrivateNetworkRuntimeToken || nodes.every((node) => !node.privateAddress))) {
|
|
11040
|
+
throw new Error("Cloudflare private network requires its runtime token and at least one DB node private address");
|
|
11041
|
+
}
|
|
11042
|
+
if (!privateNetwork && nodes.some((node) => node.privateAddress)) {
|
|
11043
|
+
throw new Error("Cloudflare node private addresses require privateNetwork coordinates");
|
|
11044
|
+
}
|
|
11045
|
+
return {
|
|
11046
|
+
accountId: validateId(input.accountId, "Cloudflare account id"),
|
|
11047
|
+
zoneId: validateId(input.zoneId, "Cloudflare zone id"),
|
|
11048
|
+
hostname: first.hostname,
|
|
11049
|
+
service: first.service,
|
|
11050
|
+
tunnelName: first.tunnelName,
|
|
11051
|
+
kvNamespaceTitle: validateName(input.kvNamespaceTitle, "Cloudflare KV namespace title", 128, false),
|
|
11052
|
+
workerScriptName,
|
|
11053
|
+
serviceTokenName: validateName(input.serviceTokenName, "Cloudflare Access service-token name", 100),
|
|
11054
|
+
policyName: validateName(input.policyName, "Cloudflare Access policy name", 100),
|
|
11055
|
+
applicationName: first.applicationName,
|
|
11056
|
+
workerDirectory,
|
|
11057
|
+
workerMain,
|
|
11058
|
+
workerCompatibilityDate,
|
|
11059
|
+
publicDomains,
|
|
11060
|
+
createRuntimeTokens: input.createRuntimeTokens,
|
|
11061
|
+
createPrivateNetworkRuntimeToken: input.createPrivateNetworkRuntimeToken,
|
|
11062
|
+
runtimeTokenNamePrefix,
|
|
11063
|
+
nodes,
|
|
11064
|
+
...privateNetwork ? { privateNetwork } : {}
|
|
11065
|
+
};
|
|
11066
|
+
}
|
|
11067
|
+
function planCloudflareBootstrap(input, outputPath) {
|
|
11068
|
+
const coordinates = validateCloudflareBootstrapCoordinates(input);
|
|
11069
|
+
return {
|
|
11070
|
+
format: 1,
|
|
11071
|
+
kind: "forgezero-cloudflare-bootstrap-plan",
|
|
11072
|
+
mode: "attended-token-file",
|
|
11073
|
+
outputFile: resolve2(outputPath),
|
|
11074
|
+
coordinates,
|
|
11075
|
+
operations: [
|
|
11076
|
+
"create or reuse one Workers KV namespace",
|
|
11077
|
+
"create or reuse one remotely-managed Tunnel per node and checkpoint every connector token",
|
|
11078
|
+
...coordinates.createRuntimeTokens ? [
|
|
11079
|
+
"create exact-account least-privilege runtime tokens and checkpoint their one-time values"
|
|
11080
|
+
] : [],
|
|
11081
|
+
"deploy the shared Worker once with the created NODES binding and stable custom domains",
|
|
11082
|
+
"create or reuse one shared Access service token/policy and one self-hosted application per node",
|
|
11083
|
+
...coordinates.privateNetwork ? [
|
|
11084
|
+
"create or reuse the VNET, WARP enrollment application, locked service-token device profile and exact DB host routes"
|
|
11085
|
+
] : [],
|
|
11086
|
+
"write the Access client id and secret to the existing Worker as encrypted secrets",
|
|
11087
|
+
"reconcile each node ingress rule and proxied CNAME only after all Access applications are ready"
|
|
11088
|
+
],
|
|
11089
|
+
secrets: [
|
|
11090
|
+
"API tokens are read only from owner-only files and are never written to output",
|
|
11091
|
+
"the output contains connector, Access and requested runtime credentials and is atomically written with mode 0600",
|
|
11092
|
+
"the normal API process does not receive or import the management token files"
|
|
11093
|
+
]
|
|
11094
|
+
};
|
|
11095
|
+
}
|
|
11096
|
+
var defaultWorkerCommandRunner = async ({ command, cwd, env }) => {
|
|
11097
|
+
const child = Bun.spawn([...command], {
|
|
11098
|
+
cwd,
|
|
11099
|
+
env: { ...env },
|
|
11100
|
+
stdin: "ignore",
|
|
11101
|
+
stdout: "pipe",
|
|
11102
|
+
stderr: "pipe"
|
|
11103
|
+
});
|
|
11104
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
11105
|
+
child.exited,
|
|
11106
|
+
new Response(child.stdout).text(),
|
|
11107
|
+
new Response(child.stderr).text()
|
|
11108
|
+
]);
|
|
11109
|
+
return { exitCode, stdout, stderr };
|
|
11110
|
+
};
|
|
11111
|
+
var inheritedWorkerEnvironment = () => {
|
|
11112
|
+
const allowed = [
|
|
11113
|
+
"PATH",
|
|
11114
|
+
"HOME",
|
|
11115
|
+
"TMPDIR",
|
|
11116
|
+
"XDG_CONFIG_HOME",
|
|
11117
|
+
"XDG_CACHE_HOME",
|
|
11118
|
+
"SSL_CERT_FILE",
|
|
11119
|
+
"SSL_CERT_DIR",
|
|
11120
|
+
"NODE_EXTRA_CA_CERTS",
|
|
11121
|
+
"HTTPS_PROXY",
|
|
11122
|
+
"HTTP_PROXY",
|
|
11123
|
+
"NO_PROXY"
|
|
11124
|
+
];
|
|
11125
|
+
return Object.fromEntries(allowed.flatMap((key) => process.env[key] ? [[key, process.env[key]]] : []));
|
|
11126
|
+
};
|
|
11127
|
+
var redact = (text3, secrets) => {
|
|
11128
|
+
let safe = text3.slice(0, 4096);
|
|
11129
|
+
for (const secret of secrets)
|
|
11130
|
+
if (secret)
|
|
11131
|
+
safe = safe.split(secret).join("[REDACTED]");
|
|
11132
|
+
return safe.trim();
|
|
11133
|
+
};
|
|
11134
|
+
async function deployCloudflareWorker(coordinates, kvNamespaceId, apiToken, runner = defaultWorkerCommandRunner) {
|
|
11135
|
+
const validated = validateCloudflareBootstrapCoordinates(coordinates);
|
|
11136
|
+
const workerDirectoryMetadata = await stat(validated.workerDirectory);
|
|
11137
|
+
if (!workerDirectoryMetadata.isDirectory())
|
|
11138
|
+
throw new Error("Cloudflare Worker directory is not a directory");
|
|
11139
|
+
const workerMain = resolve2(validated.workerDirectory, validated.workerMain);
|
|
11140
|
+
const workerMainMetadata = await stat(workerMain);
|
|
11141
|
+
if (!workerMainMetadata.isFile())
|
|
11142
|
+
throw new Error("Cloudflare Worker main is not a regular file");
|
|
11143
|
+
const wrangler = resolve2(validated.workerDirectory, "node_modules/.bin/wrangler");
|
|
11144
|
+
const wranglerMetadata = await stat(wrangler);
|
|
11145
|
+
if (!wranglerMetadata.isFile())
|
|
11146
|
+
throw new Error("Cloudflare Wrangler is not installed in the Worker project");
|
|
11147
|
+
if (!/^[a-f0-9]{32}$/.test(kvNamespaceId))
|
|
11148
|
+
throw new Error("Cloudflare KV namespace id is invalid");
|
|
11149
|
+
const temporaryDirectory = await mkdtemp(join5(tmpdir(), "fz-wrangler-"));
|
|
11150
|
+
await chmod(temporaryDirectory, 448);
|
|
11151
|
+
const configurationPath = join5(temporaryDirectory, "wrangler.json");
|
|
11152
|
+
try {
|
|
11153
|
+
const handle = await open(configurationPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 384);
|
|
11154
|
+
try {
|
|
11155
|
+
await handle.writeFile(`${JSON.stringify({
|
|
11156
|
+
name: validated.workerScriptName,
|
|
11157
|
+
main: workerMain,
|
|
11158
|
+
compatibility_date: validated.workerCompatibilityDate,
|
|
11159
|
+
workers_dev: false,
|
|
11160
|
+
routes: validated.publicDomains.map((pattern) => ({ pattern, custom_domain: true })),
|
|
11161
|
+
observability: { enabled: true },
|
|
11162
|
+
kv_namespaces: [{ binding: "NODES", id: kvNamespaceId }]
|
|
11163
|
+
}, null, 2)}
|
|
11164
|
+
`);
|
|
11165
|
+
await handle.sync();
|
|
11166
|
+
} finally {
|
|
11167
|
+
await handle.close();
|
|
11168
|
+
}
|
|
11169
|
+
const result = await runner({
|
|
11170
|
+
command: [wrangler, "deploy", "--config", configurationPath],
|
|
11171
|
+
cwd: validated.workerDirectory,
|
|
11172
|
+
env: {
|
|
11173
|
+
...inheritedWorkerEnvironment(),
|
|
11174
|
+
XDG_CONFIG_HOME: temporaryDirectory,
|
|
11175
|
+
XDG_CACHE_HOME: temporaryDirectory,
|
|
11176
|
+
WRANGLER_LOG_PATH: join5(temporaryDirectory, "wrangler.log"),
|
|
11177
|
+
CLOUDFLARE_ACCOUNT_ID: validated.accountId,
|
|
11178
|
+
CLOUDFLARE_API_TOKEN: apiToken,
|
|
11179
|
+
WRANGLER_SEND_METRICS: "false"
|
|
11180
|
+
}
|
|
11181
|
+
});
|
|
11182
|
+
if (result.exitCode !== 0) {
|
|
11183
|
+
const detail = redact(result.stderr || result.stdout || "no Wrangler diagnostic", [apiToken]);
|
|
11184
|
+
throw new Error(`Cloudflare Worker deployment failed with exit ${result.exitCode}: ${detail}`);
|
|
11185
|
+
}
|
|
11186
|
+
} finally {
|
|
11187
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
11188
|
+
}
|
|
11189
|
+
}
|
|
11190
|
+
async function readExistingOutput(path) {
|
|
11191
|
+
try {
|
|
11192
|
+
await lstat(path);
|
|
11193
|
+
} catch (cause) {
|
|
11194
|
+
if (cause.code === "ENOENT")
|
|
11195
|
+
return;
|
|
11196
|
+
throw cause;
|
|
11197
|
+
}
|
|
11198
|
+
const text3 = await readOwnerOnlyFile(path, 1048576);
|
|
11199
|
+
let output;
|
|
11200
|
+
try {
|
|
11201
|
+
output = JSON.parse(text3);
|
|
11202
|
+
} catch {
|
|
11203
|
+
throw new Error(`${resolve2(path)} is not valid bootstrap JSON`);
|
|
11204
|
+
}
|
|
11205
|
+
if (output.format !== 1 || output.kind !== "forgezero-cloudflare-bootstrap" || !output.resources) {
|
|
11206
|
+
throw new Error(`${resolve2(path)} is not a ForgeZero Cloudflare bootstrap output`);
|
|
11207
|
+
}
|
|
11208
|
+
return output;
|
|
11209
|
+
}
|
|
11210
|
+
async function readCloudflareConnectorHandoff(checkpointPath, nodeName) {
|
|
11211
|
+
const output = await readExistingOutput(resolve2(checkpointPath));
|
|
11212
|
+
if (!output || output.phase !== "complete") {
|
|
11213
|
+
throw new Error("Cloudflare connector handoff requires a completed bootstrap checkpoint");
|
|
11214
|
+
}
|
|
11215
|
+
const normalizedNodeName = nodeName.trim().toLowerCase();
|
|
11216
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(normalizedNodeName)) {
|
|
11217
|
+
throw new Error("Cloudflare connector handoff node name is invalid");
|
|
11218
|
+
}
|
|
11219
|
+
const coordinates = validateCloudflareBootstrapCoordinates(output.coordinates);
|
|
11220
|
+
const expected = coordinates.nodes.find((node) => node.nodeName === normalizedNodeName);
|
|
11221
|
+
const matches = output.resources.nodes?.filter((node) => node.nodeName === normalizedNodeName) ?? [];
|
|
11222
|
+
if (!expected || matches.length !== 1) {
|
|
11223
|
+
throw new Error(`Cloudflare connector handoff has no unique completed node ${normalizedNodeName}`);
|
|
11224
|
+
}
|
|
11225
|
+
const resource = matches[0];
|
|
11226
|
+
if (resource.hostname !== expected.hostname || resource.service !== expected.service || resource.tunnelName !== expected.tunnelName || !resource.applicationId || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(resource.tunnelId) || !/^[A-Za-z0-9._-]{40,16384}$/.test(resource.connectorToken)) {
|
|
11227
|
+
throw new Error(`Cloudflare connector handoff for ${normalizedNodeName} is malformed or incomplete`);
|
|
11228
|
+
}
|
|
11229
|
+
return {
|
|
11230
|
+
nodeName: resource.nodeName,
|
|
11231
|
+
hostname: resource.hostname,
|
|
11232
|
+
service: resource.service,
|
|
11233
|
+
tunnelId: resource.tunnelId,
|
|
11234
|
+
connectorToken: resource.connectorToken
|
|
11235
|
+
};
|
|
11236
|
+
}
|
|
11237
|
+
async function readCloudflareHostHandoff(checkpointPath, nodeName) {
|
|
11238
|
+
const connector = await readCloudflareConnectorHandoff(checkpointPath, nodeName);
|
|
11239
|
+
const output = await readExistingOutput(resolve2(checkpointPath));
|
|
11240
|
+
const kv = output?.resources.runtimeTokens?.kv;
|
|
11241
|
+
if (!output || output.phase !== "complete" || !/^[a-f0-9]{32}$/i.test(output.coordinates.accountId) || !/^[a-f0-9]{32}$/i.test(output.coordinates.zoneId) || !/^[a-f0-9]{32}$/i.test(output.resources.kvNamespaceId) || !kv || !/^[A-Za-z0-9._-]{40,80}$/.test(kv.value)) {
|
|
11242
|
+
throw new Error("Cloudflare host handoff is missing the exact-account KV runtime capability");
|
|
11243
|
+
}
|
|
11244
|
+
const network = output.resources.runtimeTokens?.privateNetwork?.value;
|
|
11245
|
+
if (network !== undefined && !/^[A-Za-z0-9._-]{40,80}$/.test(network)) {
|
|
11246
|
+
throw new Error("Cloudflare host handoff private-network capability is malformed");
|
|
11247
|
+
}
|
|
11248
|
+
const privateNetwork = output.resources.privateNetwork;
|
|
11249
|
+
const access = output.resources.access;
|
|
11250
|
+
if (Boolean(privateNetwork) !== Boolean(network)) {
|
|
11251
|
+
throw new Error("Cloudflare host handoff private-network resources and capability disagree");
|
|
11252
|
+
}
|
|
11253
|
+
if (privateNetwork && (!access?.clientId || !access.clientSecret || !/^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/.test(privateNetwork.warpOrganization) || !/^[0-9a-f-]{36}$/i.test(privateNetwork.virtualNetworkId) || !privateNetwork.deviceProfileId)) {
|
|
11254
|
+
throw new Error("Cloudflare host handoff WARP enrollment is malformed");
|
|
11255
|
+
}
|
|
11256
|
+
return {
|
|
11257
|
+
...connector,
|
|
11258
|
+
accountId: output.coordinates.accountId,
|
|
11259
|
+
zoneId: output.coordinates.zoneId,
|
|
11260
|
+
kvNamespaceId: output.resources.kvNamespaceId,
|
|
11261
|
+
kvRuntimeToken: kv.value,
|
|
11262
|
+
...network ? { privateNetworkRuntimeToken: network } : {},
|
|
11263
|
+
...privateNetwork && access ? { warp: {
|
|
11264
|
+
organization: privateNetwork.warpOrganization,
|
|
11265
|
+
clientId: access.clientId,
|
|
11266
|
+
clientSecret: access.clientSecret,
|
|
11267
|
+
virtualNetworkId: privateNetwork.virtualNetworkId,
|
|
11268
|
+
deviceProfileId: privateNetwork.deviceProfileId
|
|
11269
|
+
} } : {}
|
|
11270
|
+
};
|
|
11271
|
+
}
|
|
11272
|
+
async function prepareOwnerOutputDirectory(absolutePath) {
|
|
11273
|
+
const directory = dirname3(absolutePath);
|
|
11274
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
11275
|
+
const directoryMetadata = await stat(directory);
|
|
11276
|
+
const uid = ownerUid();
|
|
11277
|
+
if (!directoryMetadata.isDirectory() || uid !== undefined && directoryMetadata.uid !== uid || (directoryMetadata.mode & 18) !== 0) {
|
|
11278
|
+
throw new Error(`bootstrap output directory ${directory} must be operator-owned and not group/other writable`);
|
|
11279
|
+
}
|
|
11280
|
+
return directory;
|
|
11281
|
+
}
|
|
11282
|
+
async function writeOwnerBootstrapOutput(path, output) {
|
|
11283
|
+
const absolute = resolve2(path);
|
|
11284
|
+
const directory = await prepareOwnerOutputDirectory(absolute);
|
|
11285
|
+
const temporary = `${absolute}.${randomUUID()}.tmp`;
|
|
11286
|
+
let handle;
|
|
11287
|
+
try {
|
|
11288
|
+
handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
|
|
11289
|
+
await handle.writeFile(`${JSON.stringify(output, null, 2)}
|
|
11290
|
+
`, { encoding: "utf8" });
|
|
11291
|
+
await handle.sync();
|
|
11292
|
+
await handle.close();
|
|
11293
|
+
handle = undefined;
|
|
11294
|
+
await rename(temporary, absolute);
|
|
11295
|
+
await chmod(absolute, 384);
|
|
11296
|
+
const directoryHandle = await open(directory, constants.O_RDONLY);
|
|
11297
|
+
try {
|
|
11298
|
+
await directoryHandle.sync();
|
|
11299
|
+
} finally {
|
|
11300
|
+
await directoryHandle.close();
|
|
11301
|
+
}
|
|
11302
|
+
} finally {
|
|
11303
|
+
await handle?.close();
|
|
11304
|
+
await unlink(temporary).catch((cause) => {
|
|
11305
|
+
if (cause.code !== "ENOENT")
|
|
11306
|
+
throw cause;
|
|
11307
|
+
});
|
|
11308
|
+
}
|
|
11309
|
+
}
|
|
11310
|
+
var tokenFor = (tokens, key) => {
|
|
11311
|
+
const token = tokens[key]?.trim() || tokens.apiToken?.trim();
|
|
11312
|
+
if (!token)
|
|
11313
|
+
throw new Error(`Cloudflare ${key} is not configured`);
|
|
11314
|
+
return token;
|
|
11315
|
+
};
|
|
11316
|
+
var initialManagementToken = (tokens) => {
|
|
11317
|
+
const token = tokens.apiToken?.trim();
|
|
11318
|
+
if (!token) {
|
|
11319
|
+
throw new Error("initial Cloudflare --token-file is required to create account-owned runtime tokens");
|
|
11320
|
+
}
|
|
11321
|
+
return token;
|
|
11322
|
+
};
|
|
11323
|
+
var sameCoordinates = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
11324
|
+
async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fetch, workerRunner = defaultWorkerCommandRunner) {
|
|
11325
|
+
const coordinates = validateCloudflareBootstrapCoordinates(input);
|
|
11326
|
+
const absoluteOutput = resolve2(outputPath);
|
|
11327
|
+
const existing = await readExistingOutput(absoluteOutput);
|
|
11328
|
+
if (existing && !sameCoordinates(existing.coordinates, coordinates)) {
|
|
11329
|
+
throw new Error("bootstrap output belongs to different Cloudflare coordinates; choose a different output file");
|
|
11330
|
+
}
|
|
11331
|
+
await prepareOwnerOutputDirectory(absoluteOutput);
|
|
11332
|
+
const namespace = await ensureCloudflareKvNamespace({
|
|
11333
|
+
accountId: coordinates.accountId,
|
|
11334
|
+
title: coordinates.kvNamespaceTitle,
|
|
11335
|
+
apiToken: tokenFor(tokens, "kvApiToken")
|
|
11336
|
+
}, fetcher);
|
|
11337
|
+
const nodeResources = [];
|
|
11338
|
+
const createdNodes = [];
|
|
11339
|
+
let resources;
|
|
11340
|
+
for (const node of coordinates.nodes) {
|
|
11341
|
+
const checkpointed = existing?.resources.nodes?.find(({ nodeName }) => nodeName === node.nodeName);
|
|
11342
|
+
let created = false;
|
|
11343
|
+
if (checkpointed) {
|
|
11344
|
+
nodeResources.push(checkpointed);
|
|
11345
|
+
} else {
|
|
11346
|
+
const tunnel = await ensureCloudflareTunnel({
|
|
11347
|
+
accountId: coordinates.accountId,
|
|
11348
|
+
name: node.tunnelName,
|
|
11349
|
+
apiToken: tokenFor(tokens, "tunnelApiToken")
|
|
11350
|
+
}, fetcher);
|
|
11351
|
+
created = tunnel.created;
|
|
11352
|
+
nodeResources.push({
|
|
11353
|
+
nodeName: node.nodeName,
|
|
11354
|
+
hostname: node.hostname,
|
|
11355
|
+
service: node.service,
|
|
11356
|
+
tunnelName: node.tunnelName,
|
|
11357
|
+
tunnelId: tunnel.tunnel.id,
|
|
11358
|
+
connectorToken: tunnel.connectorToken
|
|
11359
|
+
});
|
|
11360
|
+
}
|
|
11361
|
+
createdNodes.push({ nodeName: node.nodeName, tunnel: created, application: false });
|
|
11362
|
+
const firstNode = nodeResources[0];
|
|
11363
|
+
resources = {
|
|
11364
|
+
tunnelId: firstNode.tunnelId,
|
|
11365
|
+
kvNamespaceId: namespace.namespace.id,
|
|
11366
|
+
hostname: firstNode.hostname,
|
|
11367
|
+
service: firstNode.service,
|
|
11368
|
+
connectorToken: firstNode.connectorToken,
|
|
11369
|
+
nodes: [...nodeResources],
|
|
11370
|
+
...existing?.resources.access ? { access: existing.resources.access } : {},
|
|
11371
|
+
...existing?.resources.runtimeTokens ? { runtimeTokens: existing.resources.runtimeTokens } : {},
|
|
11372
|
+
...existing?.resources.worker ? { worker: existing.resources.worker } : {},
|
|
11373
|
+
...existing?.resources.privateNetwork ? { privateNetwork: existing.resources.privateNetwork } : {}
|
|
11374
|
+
};
|
|
11375
|
+
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
11376
|
+
format: 1,
|
|
11377
|
+
kind: "forgezero-cloudflare-bootstrap",
|
|
11378
|
+
phase: resources.access ? "access-token-provisioned" : "edge-resources-provisioned",
|
|
11379
|
+
updatedAt: new Date().toISOString(),
|
|
11380
|
+
coordinates,
|
|
11381
|
+
resources
|
|
11382
|
+
});
|
|
11383
|
+
}
|
|
11384
|
+
if (!resources)
|
|
11385
|
+
throw new Error("Cloudflare fleet has no nodes");
|
|
11386
|
+
if (coordinates.createRuntimeTokens && !resources.runtimeTokens) {
|
|
11387
|
+
resources = {
|
|
11388
|
+
...resources,
|
|
11389
|
+
runtimeTokens: { kv: await createCloudflareAccountRuntimeToken({
|
|
11390
|
+
accountId: coordinates.accountId,
|
|
11391
|
+
name: `${coordinates.runtimeTokenNamePrefix}-kv-runtime`,
|
|
11392
|
+
permissionNames: ["Workers KV Storage Write"],
|
|
11393
|
+
apiToken: initialManagementToken(tokens)
|
|
11394
|
+
}, fetcher) }
|
|
11395
|
+
};
|
|
11396
|
+
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
11397
|
+
format: 1,
|
|
11398
|
+
kind: "forgezero-cloudflare-bootstrap",
|
|
11399
|
+
phase: "runtime-tokens-created",
|
|
11400
|
+
updatedAt: new Date().toISOString(),
|
|
11401
|
+
coordinates,
|
|
11402
|
+
resources
|
|
11403
|
+
});
|
|
11404
|
+
}
|
|
11405
|
+
if (coordinates.createPrivateNetworkRuntimeToken && resources.runtimeTokens && !resources.runtimeTokens.privateNetwork) {
|
|
11406
|
+
resources = {
|
|
11407
|
+
...resources,
|
|
11408
|
+
runtimeTokens: {
|
|
11409
|
+
...resources.runtimeTokens,
|
|
11410
|
+
privateNetwork: await createCloudflareAccountRuntimeToken({
|
|
11411
|
+
accountId: coordinates.accountId,
|
|
11412
|
+
name: `${coordinates.runtimeTokenNamePrefix}-private-network-runtime`,
|
|
11413
|
+
permissionNames: ["Cloudflare One Networks Write", "Zero Trust Write"],
|
|
11414
|
+
apiToken: initialManagementToken(tokens)
|
|
11415
|
+
}, fetcher)
|
|
11416
|
+
}
|
|
11417
|
+
};
|
|
11418
|
+
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
11419
|
+
format: 1,
|
|
11420
|
+
kind: "forgezero-cloudflare-bootstrap",
|
|
11421
|
+
phase: "runtime-tokens-created",
|
|
11422
|
+
updatedAt: new Date().toISOString(),
|
|
11423
|
+
coordinates,
|
|
11424
|
+
resources
|
|
11425
|
+
});
|
|
11426
|
+
}
|
|
11427
|
+
if (!resources.worker) {
|
|
11428
|
+
await deployCloudflareWorker(coordinates, namespace.namespace.id, tokenFor(tokens, "workerApiToken"), workerRunner);
|
|
11429
|
+
resources = {
|
|
11430
|
+
...resources,
|
|
11431
|
+
worker: {
|
|
11432
|
+
scriptName: coordinates.workerScriptName,
|
|
11433
|
+
publicDomains: coordinates.publicDomains,
|
|
11434
|
+
deployed: true
|
|
11435
|
+
}
|
|
11436
|
+
};
|
|
11437
|
+
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
11438
|
+
format: 1,
|
|
11439
|
+
kind: "forgezero-cloudflare-bootstrap",
|
|
11440
|
+
phase: "worker-deployed",
|
|
11441
|
+
updatedAt: new Date().toISOString(),
|
|
11442
|
+
coordinates,
|
|
11443
|
+
resources
|
|
11444
|
+
});
|
|
11445
|
+
}
|
|
11446
|
+
const serviceToken = await ensureCloudflareAccessServiceToken({
|
|
11447
|
+
accountId: coordinates.accountId,
|
|
11448
|
+
name: coordinates.serviceTokenName,
|
|
11449
|
+
apiToken: tokenFor(tokens, "accessApiToken"),
|
|
11450
|
+
existing: resources.access
|
|
11451
|
+
}, fetcher);
|
|
11452
|
+
resources = { ...resources, access: serviceToken.credentials };
|
|
11453
|
+
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
11454
|
+
format: 1,
|
|
11455
|
+
kind: "forgezero-cloudflare-bootstrap",
|
|
11456
|
+
phase: "access-token-provisioned",
|
|
11457
|
+
updatedAt: new Date().toISOString(),
|
|
11458
|
+
coordinates,
|
|
11459
|
+
resources
|
|
11460
|
+
});
|
|
11461
|
+
const policy = await ensureCloudflareAccessPolicy({
|
|
11462
|
+
accountId: coordinates.accountId,
|
|
11463
|
+
name: coordinates.policyName,
|
|
11464
|
+
serviceTokenId: serviceToken.credentials.tokenId,
|
|
11465
|
+
apiToken: tokenFor(tokens, "accessApiToken")
|
|
11466
|
+
}, fetcher);
|
|
11467
|
+
if (!policy.policy.id)
|
|
11468
|
+
throw new Error("Cloudflare did not return the Access policy id");
|
|
11469
|
+
resources = {
|
|
11470
|
+
...resources,
|
|
11471
|
+
access: { ...serviceToken.credentials, policyId: policy.policy.id }
|
|
11472
|
+
};
|
|
11473
|
+
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
11474
|
+
format: 1,
|
|
11475
|
+
kind: "forgezero-cloudflare-bootstrap",
|
|
11476
|
+
phase: "access-token-provisioned",
|
|
11477
|
+
updatedAt: new Date().toISOString(),
|
|
11478
|
+
coordinates,
|
|
11479
|
+
resources
|
|
11480
|
+
});
|
|
11481
|
+
let privateNetworkCreated = false;
|
|
11482
|
+
if (coordinates.privateNetwork && !resources.privateNetwork) {
|
|
11483
|
+
const managementToken = initialManagementToken(tokens);
|
|
11484
|
+
const virtualNetwork = await ensureCloudflareVirtualNetwork({
|
|
11485
|
+
accountId: coordinates.accountId,
|
|
11486
|
+
name: coordinates.privateNetwork.virtualNetworkName,
|
|
11487
|
+
comment: "ForgeZero private database network",
|
|
11488
|
+
apiToken: managementToken
|
|
11489
|
+
}, fetcher);
|
|
11490
|
+
const enrollment = await ensureCloudflareWarpEnrollmentApplication({
|
|
11491
|
+
accountId: coordinates.accountId,
|
|
11492
|
+
name: coordinates.privateNetwork.enrollmentApplicationName,
|
|
11493
|
+
policyId: policy.policy.id,
|
|
11494
|
+
apiToken: tokenFor(tokens, "accessApiToken")
|
|
11495
|
+
}, fetcher);
|
|
11496
|
+
const deviceProfile = await ensureCloudflareWarpDevicePolicy({
|
|
11497
|
+
accountId: coordinates.accountId,
|
|
11498
|
+
name: coordinates.privateNetwork.deviceProfileName,
|
|
11499
|
+
serviceTokenId: serviceToken.credentials.tokenId,
|
|
11500
|
+
virtualNetworkId: virtualNetwork.virtualNetwork.id,
|
|
11501
|
+
precedence: coordinates.privateNetwork.deviceProfilePrecedence,
|
|
11502
|
+
apiToken: managementToken
|
|
11503
|
+
}, fetcher);
|
|
11504
|
+
const routes = [];
|
|
11505
|
+
for (const node of coordinates.nodes.filter((item) => item.privateAddress)) {
|
|
11506
|
+
const resource = resources.nodes.find((item) => item.nodeName === node.nodeName);
|
|
11507
|
+
const route = await ensureCloudflarePrivateDatabaseRoute({
|
|
11508
|
+
accountId: coordinates.accountId,
|
|
11509
|
+
tunnelId: resource.tunnelId,
|
|
11510
|
+
privateAddress: node.privateAddress,
|
|
11511
|
+
virtualNetworkId: virtualNetwork.virtualNetwork.id,
|
|
11512
|
+
comment: `ForgeZero ${node.nodeName} database`,
|
|
11513
|
+
apiToken: managementToken
|
|
11514
|
+
}, fetcher);
|
|
11515
|
+
await ensureCloudflareWarpDatabaseInclude({
|
|
11516
|
+
accountId: coordinates.accountId,
|
|
11517
|
+
policyId: deviceProfile.policy.id,
|
|
11518
|
+
privateAddress: node.privateAddress,
|
|
11519
|
+
description: `ForgeZero ${node.nodeName} database`,
|
|
11520
|
+
apiToken: managementToken
|
|
11521
|
+
}, fetcher);
|
|
11522
|
+
routes.push({ nodeName: node.nodeName, routeId: route.route.id, privateAddress: node.privateAddress });
|
|
11523
|
+
}
|
|
11524
|
+
resources = {
|
|
11525
|
+
...resources,
|
|
11526
|
+
privateNetwork: {
|
|
11527
|
+
warpOrganization: coordinates.privateNetwork.warpOrganization,
|
|
11528
|
+
virtualNetworkId: virtualNetwork.virtualNetwork.id,
|
|
11529
|
+
deviceProfileId: deviceProfile.policy.id,
|
|
11530
|
+
enrollmentApplicationId: enrollment.application.id,
|
|
11531
|
+
routes
|
|
11532
|
+
}
|
|
11533
|
+
};
|
|
11534
|
+
privateNetworkCreated = virtualNetwork.created || enrollment.created || deviceProfile.created || routes.length > 0;
|
|
11535
|
+
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
11536
|
+
format: 1,
|
|
11537
|
+
kind: "forgezero-cloudflare-bootstrap",
|
|
11538
|
+
phase: "access-token-provisioned",
|
|
11539
|
+
updatedAt: new Date().toISOString(),
|
|
11540
|
+
coordinates,
|
|
11541
|
+
resources
|
|
11542
|
+
});
|
|
11543
|
+
}
|
|
11544
|
+
for (const node of coordinates.nodes) {
|
|
11545
|
+
const application = await ensureCloudflareAccessApplication({
|
|
11546
|
+
accountId: coordinates.accountId,
|
|
11547
|
+
name: node.applicationName,
|
|
11548
|
+
hostname: node.hostname,
|
|
11549
|
+
policyId: policy.policy.id,
|
|
11550
|
+
apiToken: tokenFor(tokens, "accessApiToken")
|
|
11551
|
+
}, fetcher);
|
|
11552
|
+
if (!application.application.id)
|
|
11553
|
+
throw new Error(`Cloudflare did not return the Access application id for ${node.nodeName}`);
|
|
11554
|
+
resources = {
|
|
11555
|
+
...resources,
|
|
11556
|
+
nodes: resources.nodes.map((resource) => resource.nodeName === node.nodeName ? { ...resource, applicationId: application.application.id } : resource),
|
|
11557
|
+
access: {
|
|
11558
|
+
...resources.access,
|
|
11559
|
+
...node.nodeName === coordinates.nodes[0].nodeName ? { applicationId: application.application.id } : {}
|
|
11560
|
+
}
|
|
11561
|
+
};
|
|
11562
|
+
const createdNode = createdNodes.find(({ nodeName }) => nodeName === node.nodeName);
|
|
11563
|
+
createdNode.application = application.created;
|
|
11564
|
+
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
11565
|
+
format: 1,
|
|
11566
|
+
kind: "forgezero-cloudflare-bootstrap",
|
|
11567
|
+
phase: "access-token-provisioned",
|
|
11568
|
+
updatedAt: new Date().toISOString(),
|
|
11569
|
+
coordinates,
|
|
11570
|
+
resources
|
|
11571
|
+
});
|
|
11572
|
+
}
|
|
11573
|
+
await configureCloudflareWorkerAccessSecrets({
|
|
11574
|
+
accountId: coordinates.accountId,
|
|
11575
|
+
scriptName: coordinates.workerScriptName,
|
|
11576
|
+
credentials: serviceToken.credentials,
|
|
11577
|
+
apiToken: tokenFor(tokens, "workerApiToken")
|
|
11578
|
+
}, fetcher);
|
|
11579
|
+
for (const node of resources.nodes) {
|
|
11580
|
+
await configureCloudflareEdge({
|
|
11581
|
+
accountId: coordinates.accountId,
|
|
11582
|
+
zoneId: coordinates.zoneId,
|
|
11583
|
+
tunnelId: node.tunnelId,
|
|
11584
|
+
hostname: node.hostname,
|
|
11585
|
+
service: node.service,
|
|
11586
|
+
apiToken: tokenFor(tokens, "tunnelApiToken"),
|
|
11587
|
+
tunnelApiToken: tokenFor(tokens, "tunnelApiToken"),
|
|
11588
|
+
dnsApiToken: tokenFor(tokens, "dnsApiToken")
|
|
11589
|
+
}, fetcher);
|
|
11590
|
+
}
|
|
11591
|
+
const output = {
|
|
11592
|
+
format: 1,
|
|
11593
|
+
kind: "forgezero-cloudflare-bootstrap",
|
|
11594
|
+
phase: "complete",
|
|
11595
|
+
updatedAt: new Date().toISOString(),
|
|
11596
|
+
coordinates,
|
|
11597
|
+
resources,
|
|
11598
|
+
created: {
|
|
11599
|
+
tunnel: createdNodes.some(({ tunnel }) => tunnel),
|
|
11600
|
+
kvNamespace: namespace.created,
|
|
11601
|
+
serviceToken: serviceToken.created,
|
|
11602
|
+
policy: policy.created,
|
|
11603
|
+
application: createdNodes.some(({ application }) => application),
|
|
11604
|
+
privateNetwork: privateNetworkCreated,
|
|
11605
|
+
workerDeployed: true,
|
|
11606
|
+
nodes: createdNodes
|
|
11607
|
+
}
|
|
11608
|
+
};
|
|
11609
|
+
await writeOwnerBootstrapOutput(absoluteOutput, output);
|
|
11610
|
+
return output;
|
|
11611
|
+
}
|
|
11612
|
+
async function runAttendedCloudflareBootstrap(request2, dependencies = {}) {
|
|
11613
|
+
const plan = planCloudflareBootstrap(request2.coordinates, request2.checkpointPath);
|
|
11614
|
+
if (request2.mode === "plan") {
|
|
11615
|
+
return {
|
|
11616
|
+
format: 1,
|
|
11617
|
+
kind: "forgezero-cloudflare-bootstrap-evidence",
|
|
11618
|
+
phase: "planned",
|
|
11619
|
+
checkpointFile: plan.outputFile,
|
|
11620
|
+
workerScriptName: plan.coordinates.workerScriptName,
|
|
11621
|
+
publicDomains: plan.coordinates.publicDomains,
|
|
11622
|
+
nodes: plan.coordinates.nodes.map(({ nodeName, hostname }) => ({ nodeName, hostname }))
|
|
11623
|
+
};
|
|
11624
|
+
}
|
|
11625
|
+
if (!request2.tokenFiles || !Object.values(request2.tokenFiles).some(Boolean)) {
|
|
11626
|
+
throw new Error("Cloudflare apply requires owner-only management token file paths");
|
|
11627
|
+
}
|
|
11628
|
+
if (plan.coordinates.createRuntimeTokens && !request2.tokenFiles.apiTokenFile) {
|
|
11629
|
+
throw new Error("Cloudflare runtime-token creation requires the initial management token file");
|
|
11630
|
+
}
|
|
11631
|
+
const tokens = await readCloudflareBootstrapTokens(request2.tokenFiles);
|
|
11632
|
+
const output = await applyCloudflareBootstrap(plan.coordinates, tokens, plan.outputFile, dependencies.fetcher ?? fetch, dependencies.workerRunner);
|
|
11633
|
+
return {
|
|
11634
|
+
format: 1,
|
|
11635
|
+
kind: "forgezero-cloudflare-bootstrap-evidence",
|
|
11636
|
+
phase: "complete",
|
|
11637
|
+
checkpointFile: plan.outputFile,
|
|
11638
|
+
kvNamespaceId: output.resources.kvNamespaceId,
|
|
11639
|
+
workerScriptName: output.coordinates.workerScriptName,
|
|
11640
|
+
publicDomains: output.resources.worker?.publicDomains ?? output.coordinates.publicDomains,
|
|
11641
|
+
runtimeTokenIds: {
|
|
11642
|
+
kv: output.resources.runtimeTokens?.kv.id,
|
|
11643
|
+
privateNetwork: output.resources.runtimeTokens?.privateNetwork?.id
|
|
11644
|
+
},
|
|
11645
|
+
nodes: output.resources.nodes.map(({ nodeName, hostname, tunnelId, applicationId }) => ({
|
|
11646
|
+
nodeName,
|
|
11647
|
+
hostname,
|
|
11648
|
+
tunnelId,
|
|
11649
|
+
applicationId
|
|
11650
|
+
}))
|
|
11651
|
+
};
|
|
11652
|
+
}
|
|
11653
|
+
|
|
11654
|
+
// src/bootstrap.ts
|
|
11655
|
+
init_dist();
|
|
11656
|
+
var PLATFORM_BOOTSTRAP_PROFILES = [
|
|
11657
|
+
"platform-db-api",
|
|
11658
|
+
"platform-api"
|
|
11659
|
+
];
|
|
11660
|
+
var STATE_PATH = "/var/lib/forgezero/bootstrap.json";
|
|
11661
|
+
var CREDS = "/etc/forgezero/creds";
|
|
11662
|
+
var JWT_CREDENTIAL = `${CREDS}/arangodb-jwt.cred`;
|
|
11663
|
+
var ENROL_CREDENTIAL = `${CREDS}/enrol-token.cred`;
|
|
11664
|
+
var TUNNEL_CREDENTIAL = `${CREDS}/cloudflared-token.cred`;
|
|
11665
|
+
var SEED_CREDENTIAL = `${CREDS}/seed-sync-root.cred`;
|
|
11666
|
+
var BACKUP_RECOVERY_CREDENTIAL = `${CREDS}/backup-recovery-root.cred`;
|
|
11667
|
+
var WARP_CLIENT_ID_CREDENTIAL = `${CREDS}/warp-auth-client-id.cred`;
|
|
11668
|
+
var WARP_CLIENT_SECRET_CREDENTIAL = `${CREDS}/warp-auth-client-secret.cred`;
|
|
11669
|
+
var BOOTSTRAP_SSH_CREDENTIAL = `${CREDS}/bootstrap-ssh-key.cred`;
|
|
11670
|
+
var PLATFORM_ENROL_SOURCE = "/run/forgezero-platform-enrol-token";
|
|
11671
|
+
var DB_MODE_EVIDENCE = "/var/lib/forgezero-cluster/server-mode.json";
|
|
11672
|
+
var LIFECYCLE_PROFILE = "/etc/forgezero/lifecycle.json";
|
|
11673
|
+
var PACKAGED_AGENT_BIN = fileURLToPath(new URL("./fz-agent.js", import.meta.url));
|
|
11674
|
+
var privateOrigin = (value) => {
|
|
11675
|
+
let url;
|
|
11676
|
+
try {
|
|
11677
|
+
url = new URL(value);
|
|
11678
|
+
} catch {
|
|
11679
|
+
throw new Error(`database coordinator is malformed: ${value}`);
|
|
11680
|
+
}
|
|
11681
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
|
11682
|
+
throw new Error(`database coordinators must be credential-free HTTP(S) origins: ${value}`);
|
|
11683
|
+
}
|
|
11684
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
11685
|
+
const v4 = host.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/)?.slice(1).map(Number);
|
|
11686
|
+
const privateV4 = v4 && v4.every((part) => part >= 0 && part <= 255) && (v4[0] === 10 || v4[0] === 172 && v4[1] >= 16 && v4[1] <= 31 || v4[0] === 192 && v4[1] === 168);
|
|
11687
|
+
const privateV6 = host === "::1" || /^f[cd][0-9a-f]:/i.test(host);
|
|
11688
|
+
if (!privateV4 && !privateV6 && host !== "127.0.0.1" && host !== "localhost") {
|
|
11689
|
+
throw new Error(`database coordinator must use a private address: ${value}`);
|
|
11690
|
+
}
|
|
11691
|
+
return url.origin;
|
|
11692
|
+
};
|
|
11693
|
+
var privateCidr = (value) => {
|
|
11694
|
+
const match = value.match(/^(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\/(\d|[12]\d|3[0-2])$/);
|
|
11695
|
+
if (!match || match[1].split(".").some((part) => Number(part) > 255))
|
|
11696
|
+
throw new Error(`private firewall CIDR is malformed: ${value}`);
|
|
11697
|
+
return value;
|
|
11698
|
+
};
|
|
11699
|
+
var privateFile = (host, path, label) => {
|
|
11700
|
+
if (!host.exists(path))
|
|
11701
|
+
throw new Error(`${label} file is missing: ${path}`);
|
|
11702
|
+
const metadata = host.inspect?.(path);
|
|
11703
|
+
if (metadata && (!metadata.regular || metadata.symbolic || metadata.uid !== 0 || metadata.links !== 1 || (metadata.mode & 63) !== 0 || metadata.size > 16 * 1024)) {
|
|
11704
|
+
throw new Error(`${label} must be a root-owned, owner-only regular file with one link and at most 16 KiB`);
|
|
11705
|
+
}
|
|
11706
|
+
const value = host.read(path).trim();
|
|
11707
|
+
if (!value)
|
|
11708
|
+
throw new Error(`${label} file is empty: ${path}`);
|
|
11709
|
+
return value;
|
|
11710
|
+
};
|
|
11711
|
+
function validateBootstrapConfig(value) {
|
|
11712
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?){2,}$/.test(value.nodeHostname)) {
|
|
11713
|
+
throw new Error("node hostname must be a lowercase public FQDN");
|
|
11714
|
+
}
|
|
11715
|
+
let telemetry;
|
|
11716
|
+
try {
|
|
11717
|
+
telemetry = new URL(value.telemetryEndpoint);
|
|
11718
|
+
} catch {
|
|
11719
|
+
throw new Error("telemetry endpoint is malformed");
|
|
11720
|
+
}
|
|
11721
|
+
if (telemetry.protocol !== "https:" || telemetry.port && telemetry.port !== "443" || telemetry.username || telemetry.password || telemetry.search || telemetry.hash) {
|
|
11722
|
+
throw new Error("telemetry endpoint must be public HTTPS on port 443 without credentials, query or fragment");
|
|
11723
|
+
}
|
|
11724
|
+
if (value.kind === "tenant") {
|
|
11725
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(value.realm))
|
|
11726
|
+
throw new Error("tenant realm is malformed");
|
|
11727
|
+
if (!/^https:\/\//.test(value.apiUrl) && !/^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/.test(value.apiUrl)) {
|
|
11728
|
+
throw new Error("tenant API must be public HTTPS or loopback HTTP");
|
|
11729
|
+
}
|
|
11730
|
+
if (!value.enrolTokenFile)
|
|
11731
|
+
throw new Error("tenant bootstrap requires --enrol-token-file");
|
|
11732
|
+
if (value.bootstrapRunner) {
|
|
11733
|
+
if (!value.bootstrapRunner.sshPrivateKeyFile.startsWith("/") || /[\r\n]/.test(value.bootstrapRunner.sshPrivateKeyFile)) {
|
|
11734
|
+
throw new Error("bootstrap runner SSH private-key file must be absolute");
|
|
11735
|
+
}
|
|
11736
|
+
let targetTelemetry;
|
|
11737
|
+
try {
|
|
11738
|
+
targetTelemetry = new URL(value.bootstrapRunner.targetTelemetryEndpoint);
|
|
11739
|
+
} catch {
|
|
11740
|
+
throw new Error("bootstrap target telemetry endpoint is malformed");
|
|
11741
|
+
}
|
|
11742
|
+
if (targetTelemetry.protocol !== "https:" || targetTelemetry.username || targetTelemetry.password || targetTelemetry.search || targetTelemetry.hash || targetTelemetry.port && targetTelemetry.port !== "443") {
|
|
11743
|
+
throw new Error("bootstrap target telemetry must be public HTTPS on port 443");
|
|
11744
|
+
}
|
|
11745
|
+
}
|
|
11746
|
+
return value;
|
|
11747
|
+
}
|
|
11748
|
+
if (!PLATFORM_BOOTSTRAP_PROFILES.includes(value.profile))
|
|
11749
|
+
throw new Error("unsupported platform software profile");
|
|
11750
|
+
if (!["production", "development"].includes(value.environment))
|
|
11751
|
+
throw new Error("platform environment must be production or development");
|
|
11752
|
+
let api;
|
|
11753
|
+
try {
|
|
11754
|
+
api = new URL(value.apiUrl);
|
|
11755
|
+
} catch {
|
|
11756
|
+
throw new Error("platform API URL is malformed");
|
|
11757
|
+
}
|
|
11758
|
+
if (api.protocol !== "https:" || api.username || api.password || api.search || api.hash) {
|
|
11759
|
+
throw new Error("platform API must be public HTTPS without credentials, query or fragment");
|
|
11760
|
+
}
|
|
11761
|
+
const expected = value.profile === "platform-api" ? { role: "none", mode: "default" } : { role: undefined, mode: "default" };
|
|
11762
|
+
if (expected.role && value.database.role !== expected.role || value.database.serverMode !== expected.mode || value.profile === "platform-db-api" && !["master", "joiner"].includes(value.database.role)) {
|
|
11763
|
+
throw new Error("platform profile, database role and Coordinator mode disagree");
|
|
11764
|
+
}
|
|
11765
|
+
if (value.database.role !== "none" && !value.database.address)
|
|
11766
|
+
throw new Error("database nodes require a private address");
|
|
11767
|
+
if (value.database.role === "joiner" && !value.database.master)
|
|
11768
|
+
throw new Error("database joiners require the master starter address");
|
|
11769
|
+
if (!/^(?:dev-)?fz-n[1-9][0-9]{0,2}$/.test(value.computeReference))
|
|
11770
|
+
throw new Error("platform compute reference is malformed");
|
|
11771
|
+
if (!value.database.bootstrapSecretFile)
|
|
11772
|
+
throw new Error("platform bootstrap requires the shared cluster bootstrap-code file");
|
|
11773
|
+
const write = value.database.coordinators.map(privateOrigin);
|
|
11774
|
+
if (write.length < 1 || write.length > 16 || new Set(write).size !== write.length) {
|
|
11775
|
+
throw new Error("database coordinators must contain 1-16 unique private origins");
|
|
11776
|
+
}
|
|
11777
|
+
value.database.coordinators = write;
|
|
11778
|
+
const runtime = validatePlatformSharedEnvironment(value.runtime.environment, {
|
|
11779
|
+
allowPendingCloudflareHandoff: Boolean(value.cloudflareHandoff)
|
|
11780
|
+
});
|
|
11781
|
+
if (runtime.softwareProfile !== value.profile || runtime.databaseRole !== value.database.role || runtime.nodeHostname !== value.nodeHostname || runtime.apiOrigin !== api.origin || runtime.repository !== value.repository || runtime.branch !== value.branch || runtime.databaseCoordinators.join(",") !== write.join(",")) {
|
|
11782
|
+
throw new Error("platform runtime coordinates disagree with immutable bootstrap coordinates");
|
|
11783
|
+
}
|
|
11784
|
+
if (runtime.deployProfile !== value.environment)
|
|
11785
|
+
throw new Error("runtime deployment profile disagrees with bootstrap environment");
|
|
11786
|
+
if (value.database.address !== runtime.databaseAddress || value.database.master !== runtime.databaseMaster) {
|
|
11787
|
+
throw new Error("runtime database topology disagrees with bootstrap topology");
|
|
11788
|
+
}
|
|
11789
|
+
if (!Number.isSafeInteger(value.firewall.sshPort) || value.firewall.sshPort < 1 || value.firewall.sshPort > 65535) {
|
|
11790
|
+
throw new Error("firewall SSH port is invalid");
|
|
11791
|
+
}
|
|
11792
|
+
value.firewall.privateCidrs = value.firewall.privateCidrs.map(privateCidr);
|
|
11793
|
+
if (value.firewall.enabled && (value.firewall.privateCidrs.length < 1 || new Set(value.firewall.privateCidrs).size !== value.firewall.privateCidrs.length)) {
|
|
11794
|
+
throw new Error("enabled firewall requires unique private cluster CIDRs");
|
|
11795
|
+
}
|
|
11796
|
+
if (Number(value.computeReference.match(/n(\d+)$/)?.[1]) > 3 && !value.platformEnrolTokenFile) {
|
|
11797
|
+
throw new Error("post-genesis platform computes require an API-issued enrolment token file");
|
|
11798
|
+
}
|
|
11799
|
+
if (value.cloudflareHandoff && (!value.installCloudflared || !value.cloudflareHandoff.checkpointFile || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(value.cloudflareHandoff.nodeName))) {
|
|
11800
|
+
throw new Error("Cloudflare handoff requires cloudflared installation, a checkpoint and a valid node name");
|
|
11801
|
+
}
|
|
11802
|
+
value.runtime.environment = runtime;
|
|
11803
|
+
return value;
|
|
11804
|
+
}
|
|
11805
|
+
function planBootstrap(input, initialized = false) {
|
|
11806
|
+
const config = validateBootstrapConfig(structuredClone(input));
|
|
11807
|
+
const software = config.kind === "tenant" ? [
|
|
11808
|
+
...config.software ?? [],
|
|
11809
|
+
...config.bootstrapRunner && !config.software?.some(({ id: id2 }) => id2 === "openssh-client") ? [{ id: "openssh-client", version: "ubuntu-26.04" }] : [],
|
|
11810
|
+
...config.installCloudflared ? [{ id: "cloudflared", version: "2026.7.3" }] : []
|
|
11811
|
+
] : [
|
|
11812
|
+
...config.firewall.enabled ? [{ id: "ufw", version: "ubuntu-26.04" }] : [],
|
|
11813
|
+
{ id: "bun", version: "1.3.14" },
|
|
11814
|
+
{ id: "nginx", version: "ubuntu-26.04" },
|
|
11815
|
+
...config.profile === "platform-api" ? [] : [{ id: "arangodb", version: "3.11.14" }],
|
|
11816
|
+
...config.installCloudflared ? [{ id: "cloudflared", version: "2026.7.3" }] : []
|
|
11817
|
+
];
|
|
11818
|
+
return {
|
|
11819
|
+
kind: config.kind,
|
|
11820
|
+
mode: initialized ? "repair" : "install",
|
|
11821
|
+
profile: config.kind === "platform" ? config.profile : config.profile ?? "tenant-managed",
|
|
11822
|
+
software,
|
|
11823
|
+
statePath: STATE_PATH,
|
|
11824
|
+
steps: [
|
|
11825
|
+
{ id: "agent", label: "Install and verify the common Agent supervision boundary", mutation: true },
|
|
11826
|
+
{ id: "software", label: "Install exact Agent-owned software coordinates in order", mutation: true },
|
|
11827
|
+
{ id: "credentials", label: "Seal bootstrap credentials with systemd-creds", mutation: true },
|
|
11828
|
+
...config.kind === "platform" && config.database.role !== "none" ? [{ id: "database", label: "Provision Community 3.11.14 cluster and Coordinator mode", mutation: true }] : [],
|
|
11829
|
+
...config.kind === "platform" ? [
|
|
11830
|
+
{ id: "runtime", label: "Install the shared environment, API slots, edge and activation boundary", mutation: true },
|
|
11831
|
+
{ id: "deploy", label: "Create the first invite when required and health-gate the initial deployment", mutation: true },
|
|
11832
|
+
{ id: "enrol", label: "Consume the API-bound platform capability and enable signed control", mutation: true }
|
|
11833
|
+
] : [],
|
|
11834
|
+
...config.installCloudflared ? [{ id: "cloudflared-install-only", label: "Install cloudflared without creating Cloudflare resources", mutation: true }] : [],
|
|
11835
|
+
{ id: "state", label: "Persist immutable bootstrap profile and evidence coordinates", mutation: true },
|
|
11836
|
+
{ id: "status", label: "Verify services and immutable profile", mutation: false }
|
|
11837
|
+
]
|
|
11838
|
+
};
|
|
11839
|
+
}
|
|
11840
|
+
var checked = async (host, argv, label, options) => {
|
|
11841
|
+
const result = await host.exec(argv, options);
|
|
11842
|
+
if (result.exitCode !== 0)
|
|
11843
|
+
throw new Error(`${label} failed: ${result.output.trim()}`);
|
|
11844
|
+
return result.output;
|
|
11845
|
+
};
|
|
11846
|
+
var shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
11847
|
+
var unitEscape = (value) => {
|
|
11848
|
+
if (/[^A-Za-z0-9_./:@,+-]/.test(value))
|
|
11849
|
+
throw new Error(`unsafe systemd coordinate: ${value}`);
|
|
11850
|
+
return value;
|
|
11851
|
+
};
|
|
11852
|
+
function databaseUnit(config) {
|
|
11853
|
+
const db = config.database;
|
|
11854
|
+
const join6 = db.role === "joiner" ? ` --starter.join=${unitEscape(db.master)}` : "";
|
|
11855
|
+
return `[Unit]
|
|
11856
|
+
Description=ForgeZero ArangoDB Community 3.11.14 cluster (${db.role})
|
|
11857
|
+
After=network-online.target
|
|
11858
|
+
Wants=network-online.target
|
|
11859
|
+
|
|
11860
|
+
[Service]
|
|
11861
|
+
Type=simple
|
|
11862
|
+
User=arangodb
|
|
11863
|
+
Group=arangodb
|
|
11864
|
+
LoadCredentialEncrypted=arangodb-jwt:${JWT_CREDENTIAL}
|
|
11865
|
+
ExecStart=/usr/bin/arangodb --starter.mode=cluster --starter.address=${unitEscape(db.address)} --starter.host=${unitEscape(db.address)} --starter.data-dir=/var/lib/forgezero-cluster --auth.jwt-secret=%d/arangodb-jwt${join6}
|
|
11866
|
+
Restart=always
|
|
11867
|
+
RestartSec=5
|
|
11868
|
+
UMask=0077
|
|
11869
|
+
NoNewPrivileges=true
|
|
11870
|
+
PrivateTmp=true
|
|
11871
|
+
PrivateDevices=true
|
|
11872
|
+
ProtectSystem=strict
|
|
11873
|
+
ProtectHome=true
|
|
11874
|
+
ReadWritePaths=/var/lib/forgezero-cluster
|
|
11875
|
+
ProtectKernelTunables=true
|
|
11876
|
+
ProtectKernelModules=true
|
|
11877
|
+
ProtectControlGroups=true
|
|
11878
|
+
RestrictSUIDSGID=true
|
|
11879
|
+
LockPersonality=true
|
|
11880
|
+
|
|
11881
|
+
[Install]
|
|
11882
|
+
WantedBy=multi-user.target
|
|
11883
|
+
`;
|
|
11884
|
+
}
|
|
11885
|
+
function databaseVerifyUnit(config) {
|
|
11886
|
+
const { address } = config.database;
|
|
11887
|
+
return `[Unit]
|
|
11888
|
+
Description=Verify ForgeZero ArangoDB Community 3.11.14 writable Coordinator
|
|
11889
|
+
Requires=forgezero-db.service
|
|
11890
|
+
After=forgezero-db.service
|
|
11891
|
+
PartOf=forgezero-db.service
|
|
11892
|
+
|
|
11893
|
+
[Service]
|
|
11894
|
+
Type=oneshot
|
|
11895
|
+
User=arangodb
|
|
11896
|
+
Group=arangodb
|
|
11897
|
+
LoadCredentialEncrypted=arangodb-jwt:${JWT_CREDENTIAL}
|
|
11898
|
+
ExecStart=/usr/bin/arangosh --server.endpoint tcp://${unitEscape(address)}:8529 --server.jwt-secret-keyfile %d/arangodb-jwt --javascript.execute-string 'const c=require("@arangodb").db._connection;let last;for(let i=0;i<90;i++){try{const v=c.GET("/_api/version?details=true");const s=c.GET("/_admin/status");const m=c.GET("/_admin/server/mode");if(!v.error&&v.version==="3.11.14"&&v.details&&v.details.license==="community"&&!s.error&&s.serverInfo&&s.serverInfo.role==="COORDINATOR"&&!m.error&&m.mode==="default")quit(0);last={v,s,m};}catch(e){last=String(e);}require("internal").wait(2);}throw new Error("writable Community Coordinator verification failed: "+JSON.stringify(last));'
|
|
11899
|
+
RemainAfterExit=yes
|
|
11900
|
+
TimeoutStartSec=200
|
|
11901
|
+
NoNewPrivileges=true
|
|
11902
|
+
PrivateTmp=true
|
|
11903
|
+
PrivateDevices=true
|
|
11904
|
+
ProtectSystem=strict
|
|
11905
|
+
ProtectHome=true
|
|
11906
|
+
|
|
11907
|
+
[Install]
|
|
11908
|
+
WantedBy=multi-user.target
|
|
11909
|
+
`;
|
|
11910
|
+
}
|
|
11911
|
+
function tunnelUnit() {
|
|
11912
|
+
return `[Unit]
|
|
11913
|
+
Description=ForgeZero Cloudflare Tunnel connector
|
|
11914
|
+
After=network-online.target
|
|
11915
|
+
Wants=network-online.target
|
|
11916
|
+
|
|
11917
|
+
[Service]
|
|
11918
|
+
Type=simple
|
|
11919
|
+
DynamicUser=yes
|
|
11920
|
+
LoadCredentialEncrypted=cloudflared-token:${TUNNEL_CREDENTIAL}
|
|
11921
|
+
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate run --token-file %d/cloudflared-token
|
|
11922
|
+
Restart=always
|
|
11923
|
+
RestartSec=5
|
|
11924
|
+
NoNewPrivileges=true
|
|
11925
|
+
PrivateTmp=true
|
|
11926
|
+
ProtectSystem=strict
|
|
11927
|
+
ProtectHome=true
|
|
11928
|
+
|
|
11929
|
+
[Install]
|
|
11930
|
+
WantedBy=multi-user.target
|
|
11931
|
+
`;
|
|
11932
|
+
}
|
|
11933
|
+
var derive = (root, label) => {
|
|
11934
|
+
if (!/^[a-f0-9]{64}$/i.test(root))
|
|
11935
|
+
throw new Error("cluster bootstrap code must contain exactly 64 hexadecimal characters");
|
|
11936
|
+
return createHmac("sha256", Buffer.from(root, "hex")).update(label).digest("hex");
|
|
11937
|
+
};
|
|
11938
|
+
async function seal(host, name, destination, value) {
|
|
11939
|
+
if (host.exists(destination))
|
|
11940
|
+
return;
|
|
11941
|
+
const result = await host.exec(["systemd-creds", "encrypt", `--name=${name}`, "-", destination], { stdin: value });
|
|
11942
|
+
if (result.exitCode !== 0)
|
|
11943
|
+
throw new Error(`could not seal ${name}: ${result.output.trim()}`);
|
|
11944
|
+
}
|
|
11945
|
+
function stateFor(config) {
|
|
11946
|
+
return `${JSON.stringify({
|
|
11947
|
+
format: 1,
|
|
11948
|
+
kind: config.kind,
|
|
11949
|
+
profile: config.kind === "platform" ? config.profile : config.profile ?? "tenant-managed",
|
|
11950
|
+
nodeHostname: config.nodeHostname,
|
|
11951
|
+
apiUrl: config.apiUrl,
|
|
11952
|
+
...config.kind === "platform" ? {
|
|
11953
|
+
environment: config.environment,
|
|
11954
|
+
databaseRole: config.database.role,
|
|
11955
|
+
databaseServerMode: config.database.serverMode,
|
|
11956
|
+
databaseAddress: config.database.address,
|
|
11957
|
+
databaseCoordinators: config.database.coordinators,
|
|
11958
|
+
databaseModeEvidence: config.database.role === "none" ? undefined : DB_MODE_EVIDENCE,
|
|
11959
|
+
collectorUnit: config.runtime.environment.otlpCollectorUnit,
|
|
11960
|
+
cloudflared: Boolean(config.cloudflareHandoff)
|
|
11961
|
+
} : { realm: config.realm }
|
|
11962
|
+
}, null, 2)}
|
|
11963
|
+
`;
|
|
11964
|
+
}
|
|
11965
|
+
async function bootstrapStatus(host = localBootstrapHost()) {
|
|
11966
|
+
if (!host.exists(STATE_PATH))
|
|
11967
|
+
return { initialized: false, services: {}, problems: ["bootstrap state is missing"] };
|
|
11968
|
+
let state;
|
|
11969
|
+
try {
|
|
11970
|
+
state = JSON.parse(host.read(STATE_PATH));
|
|
11971
|
+
} catch {
|
|
11972
|
+
return { initialized: false, services: {}, problems: ["bootstrap state is malformed"] };
|
|
11973
|
+
}
|
|
11974
|
+
const units = ["forgezero-agent.service", "forgezero-agent.socket"];
|
|
11975
|
+
if (state.kind === "platform")
|
|
11976
|
+
units.push("nginx.service");
|
|
11977
|
+
if (state.kind === "platform" && state.collectorUnit)
|
|
11978
|
+
units.push(state.collectorUnit);
|
|
11979
|
+
if (state.kind === "platform" && state.cloudflared)
|
|
11980
|
+
units.push("cloudflared.service");
|
|
11981
|
+
if (state.kind === "platform" && state.databaseRole !== "none")
|
|
11982
|
+
units.push("forgezero-db.service", "forgezero-db-verify.service");
|
|
11983
|
+
const services = {};
|
|
11984
|
+
const problems = [];
|
|
11985
|
+
for (const unit of units) {
|
|
11986
|
+
const result = await host.exec(["systemctl", "is-active", "--quiet", unit]);
|
|
11987
|
+
services[unit] = result.exitCode === 0;
|
|
11988
|
+
if (result.exitCode !== 0)
|
|
11989
|
+
problems.push(`${unit} is not active`);
|
|
11990
|
+
}
|
|
11991
|
+
if (state.kind === "platform") {
|
|
11992
|
+
const [blue, green] = await Promise.all([
|
|
11993
|
+
host.exec(["systemctl", "is-active", "--quiet", "forgezero@blue.service"]),
|
|
11994
|
+
host.exec(["systemctl", "is-active", "--quiet", "forgezero@green.service"])
|
|
11995
|
+
]);
|
|
11996
|
+
services["forgezero@active.service"] = blue.exitCode === 0 || green.exitCode === 0;
|
|
11997
|
+
if (!services["forgezero@active.service"])
|
|
11998
|
+
problems.push("neither API slot is active");
|
|
11999
|
+
}
|
|
12000
|
+
if (!host.exists("/var/lib/forgezero/enrolment.json"))
|
|
12001
|
+
problems.push("durable Agent enrolment state is missing");
|
|
12002
|
+
return { initialized: problems.length === 0, kind: state.kind, profile: state.profile, services, problems };
|
|
12003
|
+
}
|
|
12004
|
+
async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
12005
|
+
const config = validateBootstrapConfig(structuredClone(input));
|
|
12006
|
+
if (host.uid() !== 0)
|
|
12007
|
+
throw new Error("fz bootstrap --apply must run as root");
|
|
12008
|
+
if (config.kind === "tenant") {
|
|
12009
|
+
const token = privateFile(host, config.enrolTokenFile, "tenant enrolment token");
|
|
12010
|
+
if (!/^fze_[A-Za-z0-9_-]{32,128}$/.test(token))
|
|
12011
|
+
throw new Error("tenant enrolment token is malformed");
|
|
12012
|
+
}
|
|
12013
|
+
let cloudflare;
|
|
12014
|
+
if (config.kind === "platform" && config.cloudflareHandoff) {
|
|
12015
|
+
cloudflare = await readCloudflareHostHandoff(config.cloudflareHandoff.checkpointFile, config.cloudflareHandoff.nodeName);
|
|
12016
|
+
const expected = config.runtime.environment.cloudflare;
|
|
12017
|
+
if (cloudflare.hostname !== config.nodeHostname)
|
|
12018
|
+
throw new Error("Cloudflare checkpoint hostname disagrees with platform node hostname");
|
|
12019
|
+
if (expected && (cloudflare.service !== expected.tunnelService || cloudflare.accountId !== expected.accountId || cloudflare.zoneId !== expected.zoneId || cloudflare.kvNamespaceId !== expected.kvNamespaceId || cloudflare.tunnelId !== expected.tunnelId)) {
|
|
12020
|
+
throw new Error("Cloudflare checkpoint disagrees with immutable platform runtime coordinates");
|
|
12021
|
+
}
|
|
12022
|
+
const discovered = {
|
|
12023
|
+
accountId: cloudflare.accountId,
|
|
12024
|
+
zoneId: cloudflare.zoneId,
|
|
12025
|
+
kvNamespaceId: cloudflare.kvNamespaceId,
|
|
12026
|
+
tunnelId: cloudflare.tunnelId,
|
|
12027
|
+
tunnelService: cloudflare.service,
|
|
12028
|
+
...cloudflare.warp ? { warp: {
|
|
12029
|
+
organization: cloudflare.warp.organization,
|
|
12030
|
+
virtualNetworkId: cloudflare.warp.virtualNetworkId,
|
|
12031
|
+
deviceProfileId: cloudflare.warp.deviceProfileId
|
|
12032
|
+
} } : {}
|
|
12033
|
+
};
|
|
12034
|
+
if (expected && JSON.stringify(expected) !== JSON.stringify(discovered)) {
|
|
12035
|
+
throw new Error("Cloudflare checkpoint disagrees with immutable WARP/runtime coordinates");
|
|
12036
|
+
}
|
|
12037
|
+
config.runtime.environment.cloudflare = expected ?? discovered;
|
|
12038
|
+
if (config.runtime.environment.databaseNetworkMode === "cloudflare-warp" !== Boolean(cloudflare.warp)) {
|
|
12039
|
+
throw new Error("Cloudflare checkpoint private-network mode disagrees with the platform database network mode");
|
|
12040
|
+
}
|
|
12041
|
+
}
|
|
12042
|
+
const plan = planBootstrap(config, host.exists(STATE_PATH));
|
|
12043
|
+
const alreadyEnrolled = host.exists("/var/lib/forgezero/enrolment.json");
|
|
12044
|
+
host.mkdir(CREDS, 448);
|
|
12045
|
+
host.mkdir("/var/lib/forgezero", 448);
|
|
12046
|
+
if (config.kind === "tenant" && config.bootstrapRunner && !host.exists(BOOTSTRAP_SSH_CREDENTIAL)) {
|
|
12047
|
+
await seal(host, "bootstrap-ssh-key", BOOTSTRAP_SSH_CREDENTIAL, privateFile(host, config.bootstrapRunner.sshPrivateKeyFile, "bootstrap runner SSH private key"));
|
|
12048
|
+
host.remove(config.bootstrapRunner.sshPrivateKeyFile);
|
|
12049
|
+
}
|
|
12050
|
+
if (cloudflare?.warp) {
|
|
12051
|
+
await seal(host, "warp-auth-client-id", WARP_CLIENT_ID_CREDENTIAL, cloudflare.warp.clientId);
|
|
12052
|
+
await seal(host, "warp-auth-client-secret", WARP_CLIENT_SECRET_CREDENTIAL, cloudflare.warp.clientSecret);
|
|
12053
|
+
}
|
|
12054
|
+
await host.installAgent(config, alreadyEnrolled && config.kind === "platform" ? PLATFORM_ENROL_SOURCE : undefined);
|
|
12055
|
+
if (config.kind === "platform" && config.firewall.enabled) {
|
|
12056
|
+
await host.ensureSoftware(plan.software.filter(({ id: id2 }) => id2 === "ufw"));
|
|
12057
|
+
} else
|
|
12058
|
+
await host.ensureSoftware(plan.software);
|
|
12059
|
+
if (config.kind === "platform" && config.firewall.enabled) {
|
|
12060
|
+
await checked(host, ["ufw", "--force", "default", "deny", "incoming"], "firewall inbound policy");
|
|
12061
|
+
await checked(host, ["ufw", "--force", "default", "allow", "outgoing"], "firewall outbound policy");
|
|
12062
|
+
await checked(host, ["ufw", "allow", `${config.firewall.sshPort}/tcp`], "firewall SSH rule");
|
|
12063
|
+
for (const cidr of config.firewall.privateCidrs) {
|
|
12064
|
+
if (config.database.role !== "none")
|
|
12065
|
+
await checked(host, ["ufw", "allow", "from", cidr, "to", "any", "port", "8528:8539", "proto", "tcp"], "database firewall rule");
|
|
12066
|
+
for (const port of [config.runtime.bluePort, config.runtime.greenPort])
|
|
12067
|
+
await checked(host, ["ufw", "allow", "from", cidr, "to", "any", "port", String(port), "proto", "tcp"], "seed-mesh firewall rule");
|
|
12068
|
+
}
|
|
12069
|
+
await checked(host, ["ufw", "--force", "enable"], "firewall activation");
|
|
12070
|
+
await host.ensureSoftware(plan.software.filter(({ id: id2 }) => id2 !== "ufw"));
|
|
12071
|
+
}
|
|
12072
|
+
if (config.kind === "platform") {
|
|
12073
|
+
const root = privateFile(host, config.database.bootstrapSecretFile, "database bootstrap secret");
|
|
12074
|
+
if (!host.exists(JWT_CREDENTIAL)) {
|
|
12075
|
+
await seal(host, "arangodb-jwt", JWT_CREDENTIAL, derive(root, "forgezero/cluster/arangodb-jwt/v1"));
|
|
12076
|
+
}
|
|
12077
|
+
await seal(host, "seed-sync-root", SEED_CREDENTIAL, derive(root, "forgezero/cluster/seed-mesh/v1"));
|
|
12078
|
+
await seal(host, "backup-recovery-root", BACKUP_RECOVERY_CREDENTIAL, derive(root, "forgezero/backup/recovery-root/v1"));
|
|
12079
|
+
const credentialFiles = config.runtime.credentialFiles ?? {};
|
|
12080
|
+
for (const [name, source] of Object.entries({
|
|
12081
|
+
"bootstrap-smtp-password": credentialFiles.smtpPassword,
|
|
12082
|
+
"backup-s3-secret": credentialFiles.backupS3Secret
|
|
12083
|
+
})) {
|
|
12084
|
+
if (source) {
|
|
12085
|
+
const destination = `${CREDS}/${name}.cred`;
|
|
12086
|
+
if (!host.exists(destination)) {
|
|
12087
|
+
await seal(host, name, destination, privateFile(host, source, name));
|
|
12088
|
+
host.remove(source);
|
|
12089
|
+
}
|
|
12090
|
+
}
|
|
12091
|
+
}
|
|
12092
|
+
if (cloudflare) {
|
|
12093
|
+
await seal(host, "cloudflare-kv-token", `${CREDS}/cloudflare-kv-token.cred`, cloudflare.kvRuntimeToken);
|
|
12094
|
+
if (cloudflare.privateNetworkRuntimeToken)
|
|
12095
|
+
await seal(host, "cloudflare-network-token", `${CREDS}/cloudflare-network-token.cred`, cloudflare.privateNetworkRuntimeToken);
|
|
12096
|
+
}
|
|
12097
|
+
const runtime = config.runtime;
|
|
12098
|
+
const envPath = `${runtime.environment.sharedDirectory}/.env`;
|
|
12099
|
+
const credentials = platformApiCredentialSpecs({
|
|
12100
|
+
smtp: Boolean(credentialFiles.smtpPassword),
|
|
12101
|
+
cloudflareKv: Boolean(cloudflare),
|
|
12102
|
+
cloudflareNetwork: Boolean(cloudflare?.privateNetworkRuntimeToken)
|
|
12103
|
+
});
|
|
12104
|
+
const units = renderPlatformApiUnits({
|
|
12105
|
+
serviceUser: runtime.serviceUser,
|
|
12106
|
+
sharedDirectory: runtime.environment.sharedDirectory,
|
|
12107
|
+
sharedEnvironmentFile: envPath,
|
|
12108
|
+
slotsDirectory: runtime.slotsDirectory,
|
|
12109
|
+
bluePort: runtime.bluePort,
|
|
12110
|
+
greenPort: runtime.greenPort,
|
|
12111
|
+
collectorUnit: runtime.environment.otlpCollectorUnit,
|
|
12112
|
+
credentials
|
|
12113
|
+
});
|
|
12114
|
+
const edge = renderPlatformNginx({ publicPort: runtime.environment.publicApiPort, initialSlotPort: runtime.bluePort });
|
|
12115
|
+
const activation = renderPlatformActivationFiles({
|
|
12116
|
+
root: config.deployRoot ?? "/opt/forgezero",
|
|
12117
|
+
serviceUser: runtime.serviceUser,
|
|
12118
|
+
bluePort: runtime.bluePort,
|
|
12119
|
+
greenPort: runtime.greenPort,
|
|
12120
|
+
healthPath: runtime.healthPath,
|
|
12121
|
+
keepReleases: runtime.keepReleases
|
|
12122
|
+
});
|
|
12123
|
+
await checked(host, ["useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", runtime.serviceUser], "API service account").catch(async () => {
|
|
12124
|
+
await checked(host, ["id", runtime.serviceUser], "existing API service account");
|
|
12125
|
+
});
|
|
12126
|
+
host.mkdir(runtime.environment.sharedDirectory, 488);
|
|
12127
|
+
host.mkdir(runtime.slotsDirectory, 493);
|
|
12128
|
+
host.write(envPath, renderPlatformSharedEnvironment(runtime.environment), 416);
|
|
12129
|
+
await checked(host, ["chown", `root:${runtime.serviceUser}`, runtime.environment.sharedDirectory, envPath], "runtime ownership");
|
|
12130
|
+
host.write("/etc/systemd/system/forgezero@.service", units.template, 420);
|
|
12131
|
+
host.write("/etc/systemd/system/forgezero@blue.service.d/port.conf", units.dropIns.blue, 420);
|
|
12132
|
+
host.write("/etc/systemd/system/forgezero@green.service.d/port.conf", units.dropIns.green, 420);
|
|
12133
|
+
host.write("/etc/nginx/conf.d/forgezero-upstream.conf", edge.upstream, 420);
|
|
12134
|
+
host.write("/etc/nginx/conf.d/forgezero.conf", edge.site, 420);
|
|
12135
|
+
host.write("/etc/forgezero/deploy.env", activation.environment, 420);
|
|
12136
|
+
host.write("/usr/local/libexec/forgezero-activate", activation.helper, 493);
|
|
12137
|
+
host.write("/etc/sudoers.d/forgezero-runner", activation.sudoers, 288);
|
|
12138
|
+
await checked(host, ["visudo", "-cf", "/etc/sudoers.d/forgezero-runner"], "activation sudo policy");
|
|
12139
|
+
const telemetry = planLocalOtlpProof(runtime.environment.otlpEndpoint, runtime.environment.otlpCollectorUnit);
|
|
12140
|
+
await checked(host, telemetry.unitCheck.argv, "OTLP collector supervision");
|
|
12141
|
+
const otlpStatus = (await checked(host, [telemetry.receiverCheck.command, ...telemetry.receiverCheck.argv], "OTLP receiver")).trim();
|
|
12142
|
+
if (!/^2\d\d$/.test(otlpStatus))
|
|
12143
|
+
throw new Error(`OTLP receiver returned HTTP ${otlpStatus || "unknown"}`);
|
|
12144
|
+
await checked(host, ["nginx", "-t"], "nginx configuration");
|
|
12145
|
+
await checked(host, ["systemctl", "daemon-reload"], "systemd reload");
|
|
12146
|
+
await checked(host, ["systemctl", "enable", "--now", "nginx.service"], "nginx supervision");
|
|
12147
|
+
if (config.database.role === "master") {
|
|
12148
|
+
const invite = `${runtime.environment.sharedDirectory}/platform-invite.token`;
|
|
12149
|
+
if (!host.exists(invite)) {
|
|
12150
|
+
host.write(invite, `plt_${randomBytes5(24).toString("hex")}
|
|
12151
|
+
`, 384);
|
|
12152
|
+
await checked(host, ["chown", `${runtime.serviceUser}:${runtime.serviceUser}`, invite], "platform invite ownership");
|
|
12153
|
+
}
|
|
12154
|
+
}
|
|
12155
|
+
if (config.database.role !== "none") {
|
|
12156
|
+
host.mkdir("/var/lib/forgezero-cluster", 448);
|
|
12157
|
+
await checked(host, ["chown", "arangodb:arangodb", "/var/lib/forgezero-cluster"], "database state ownership");
|
|
12158
|
+
host.write("/etc/systemd/system/forgezero-db.service", databaseUnit(config), 420);
|
|
12159
|
+
host.write("/etc/systemd/system/forgezero-db-verify.service", databaseVerifyUnit(config), 420);
|
|
12160
|
+
await checked(host, ["systemctl", "daemon-reload"], "database unit reload");
|
|
12161
|
+
await checked(host, ["systemctl", "enable", "--now", "forgezero-db.service", "forgezero-db-verify.service"], "database supervision");
|
|
12162
|
+
const evidence = {
|
|
12163
|
+
expectedMode: "default",
|
|
12164
|
+
role: "COORDINATOR",
|
|
12165
|
+
unit: "forgezero-db-verify.service",
|
|
12166
|
+
verifiedAt: new Date().toISOString()
|
|
12167
|
+
};
|
|
12168
|
+
host.write(DB_MODE_EVIDENCE, `${JSON.stringify(evidence, null, 2)}
|
|
12169
|
+
`, 384);
|
|
12170
|
+
}
|
|
12171
|
+
if (!alreadyEnrolled) {
|
|
12172
|
+
const enrolToken = config.platformEnrolTokenFile ? privateFile(host, config.platformEnrolTokenFile, "platform enrolment token") : `fze_${derive(derive(root, "forgezero/cluster/arangodb-jwt/v1"), `forgezero/platform-enrolment/v1/${config.computeReference}`)}`;
|
|
12173
|
+
if (!/^fze_[A-Za-z0-9_-]{32,128}$/.test(enrolToken))
|
|
12174
|
+
throw new Error("platform enrolment token is malformed");
|
|
12175
|
+
host.write(PLATFORM_ENROL_SOURCE, `${enrolToken}
|
|
12176
|
+
`, 384);
|
|
12177
|
+
}
|
|
12178
|
+
await checked(host, [
|
|
12179
|
+
"runuser",
|
|
12180
|
+
"-u",
|
|
12181
|
+
"forgezero-agent",
|
|
12182
|
+
"--",
|
|
12183
|
+
"/usr/local/bin/fz-agent",
|
|
12184
|
+
"deploy",
|
|
12185
|
+
...config.database.role === "master" ? ["--release-executor"] : []
|
|
12186
|
+
], "initial Agent deployment");
|
|
12187
|
+
if (!alreadyEnrolled) {
|
|
12188
|
+
await host.installAgent(config, PLATFORM_ENROL_SOURCE);
|
|
12189
|
+
if (config.platformEnrolTokenFile)
|
|
12190
|
+
host.remove(config.platformEnrolTokenFile);
|
|
12191
|
+
}
|
|
12192
|
+
}
|
|
12193
|
+
if (config.kind === "platform" && cloudflare) {
|
|
12194
|
+
if (!host.exists(TUNNEL_CREDENTIAL)) {
|
|
12195
|
+
await seal(host, "cloudflared-token", TUNNEL_CREDENTIAL, cloudflare.connectorToken);
|
|
12196
|
+
}
|
|
12197
|
+
host.write("/etc/systemd/system/cloudflared.service", tunnelUnit(), 420);
|
|
12198
|
+
await checked(host, ["systemctl", "daemon-reload"], "cloudflared unit reload");
|
|
12199
|
+
await checked(host, ["systemctl", "enable", "--now", "cloudflared.service"], "cloudflared connector supervision");
|
|
12200
|
+
}
|
|
12201
|
+
host.write(STATE_PATH, stateFor(config), 384);
|
|
12202
|
+
const status = await bootstrapStatus(host);
|
|
12203
|
+
if (!status.initialized)
|
|
12204
|
+
throw new Error(`bootstrap verification failed: ${status.problems.join("; ")}`);
|
|
12205
|
+
let launch;
|
|
12206
|
+
if (config.kind === "platform" && config.database.role === "master") {
|
|
12207
|
+
const invitePath = `${config.runtime.environment.sharedDirectory}/platform-invite.token`;
|
|
12208
|
+
if (!host.exists(invitePath))
|
|
12209
|
+
throw new Error("platform invite is missing after deployment");
|
|
12210
|
+
const token = host.read(invitePath).trim();
|
|
12211
|
+
if (!/^plt_[a-f0-9]{48}$/.test(token))
|
|
12212
|
+
throw new Error("platform invite is malformed");
|
|
12213
|
+
launch = {
|
|
12214
|
+
command: `sudo -u ${shellQuote(config.runtime.serviceUser)} env FZ_SHARED_DIR=${shellQuote(config.runtime.environment.sharedDirectory)} /usr/local/bin/fz genesis --mode 2-of-3 --api ${shellQuote(config.apiUrl)} --app ${shellQuote(config.runtime.environment.appOrigin)}`,
|
|
12215
|
+
inviteUrl: `${config.runtime.environment.appOrigin}/invite?token=${encodeURIComponent(token)}`
|
|
12216
|
+
};
|
|
12217
|
+
}
|
|
12218
|
+
return { plan, applied: true, status, ...launch ? { launch } : {} };
|
|
12219
|
+
}
|
|
12220
|
+
var exactKeys2 = (value, allowed, label) => {
|
|
12221
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
12222
|
+
throw new Error(`${label} must be an object`);
|
|
12223
|
+
const record2 = value;
|
|
12224
|
+
const unknown = Object.keys(record2).filter((key) => !allowed.includes(key));
|
|
12225
|
+
if (unknown.length)
|
|
12226
|
+
throw new Error(`${label} contains unknown fields: ${unknown.join(", ")}`);
|
|
12227
|
+
return record2;
|
|
12228
|
+
};
|
|
12229
|
+
function strictBootstrapDocument(value) {
|
|
12230
|
+
const root = exactKeys2(value, [
|
|
12231
|
+
"kind",
|
|
12232
|
+
"environment",
|
|
12233
|
+
"profile",
|
|
12234
|
+
"computeReference",
|
|
12235
|
+
"nodeHostname",
|
|
12236
|
+
"apiUrl",
|
|
12237
|
+
"repository",
|
|
12238
|
+
"branch",
|
|
12239
|
+
"deployRoot",
|
|
12240
|
+
"telemetryEndpoint",
|
|
12241
|
+
"database",
|
|
12242
|
+
"platformEnrolTokenFile",
|
|
12243
|
+
"runtime",
|
|
12244
|
+
"firewall",
|
|
12245
|
+
"installCloudflared",
|
|
12246
|
+
"cloudflareHandoff",
|
|
12247
|
+
"realm",
|
|
12248
|
+
"enrolTokenFile",
|
|
12249
|
+
"software",
|
|
12250
|
+
"bootstrapRunner"
|
|
12251
|
+
], "bootstrap config");
|
|
12252
|
+
if (root.kind === "platform") {
|
|
12253
|
+
exactKeys2(root.firewall, ["enabled", "sshPort", "privateCidrs"], "firewall config");
|
|
12254
|
+
if (root.cloudflareHandoff !== undefined)
|
|
12255
|
+
exactKeys2(root.cloudflareHandoff, ["checkpointFile", "nodeName"], "Cloudflare handoff");
|
|
12256
|
+
exactKeys2(root.database, ["role", "serverMode", "address", "master", "coordinators", "bootstrapSecretFile"], "database config");
|
|
12257
|
+
const runtime = exactKeys2(root.runtime, [
|
|
12258
|
+
"environment",
|
|
12259
|
+
"serviceUser",
|
|
12260
|
+
"slotsDirectory",
|
|
12261
|
+
"bluePort",
|
|
12262
|
+
"greenPort",
|
|
12263
|
+
"healthPath",
|
|
12264
|
+
"keepReleases",
|
|
12265
|
+
"credentialFiles"
|
|
12266
|
+
], "runtime config");
|
|
12267
|
+
exactKeys2(runtime.environment, [
|
|
12268
|
+
"softwareProfile",
|
|
12269
|
+
"databaseRole",
|
|
12270
|
+
"databaseCoordinators",
|
|
12271
|
+
"databaseAddress",
|
|
12272
|
+
"databaseMaster",
|
|
12273
|
+
"databaseNetworkMode",
|
|
12274
|
+
"databaseReplicationFactor",
|
|
12275
|
+
"databaseWriteConcern",
|
|
12276
|
+
"nodeHostname",
|
|
12277
|
+
"nodeRegion",
|
|
12278
|
+
"databaseUser",
|
|
12279
|
+
"nodeRole",
|
|
12280
|
+
"appOrigin",
|
|
12281
|
+
"apiOrigin",
|
|
12282
|
+
"publicApiPort",
|
|
12283
|
+
"sharedDirectory",
|
|
12284
|
+
"seedSyncPeers",
|
|
12285
|
+
"seedSyncMembers",
|
|
12286
|
+
"seedSyncEpoch",
|
|
12287
|
+
"concurrencyLimit",
|
|
12288
|
+
"drainDeadlineMs",
|
|
12289
|
+
"otlpEndpoint",
|
|
12290
|
+
"otlpCollectorUnit",
|
|
12291
|
+
"agentOtlpEndpoint",
|
|
12292
|
+
"custodianEmail",
|
|
12293
|
+
"smtp",
|
|
12294
|
+
"repository",
|
|
12295
|
+
"branch",
|
|
12296
|
+
"deployProfile",
|
|
12297
|
+
"otlpFlushIntervalMs",
|
|
12298
|
+
"otlpTraceSampleRatio",
|
|
12299
|
+
"backup",
|
|
12300
|
+
"cloudflare"
|
|
12301
|
+
], "runtime environment");
|
|
12302
|
+
if (runtime.credentialFiles !== undefined)
|
|
12303
|
+
exactKeys2(runtime.credentialFiles, ["smtpPassword", "backupS3Secret"], "runtime credential files");
|
|
12304
|
+
const environment = runtime.environment;
|
|
12305
|
+
if (environment.smtp !== undefined)
|
|
12306
|
+
exactKeys2(environment.smtp, ["host", "port", "user", "from"], "SMTP config");
|
|
12307
|
+
if (environment.backup !== undefined)
|
|
12308
|
+
exactKeys2(environment.backup, ["endpoint", "region", "bucket", "accessKeyId"], "backup config");
|
|
12309
|
+
if (environment.cloudflare !== undefined)
|
|
12310
|
+
exactKeys2(environment.cloudflare, ["accountId", "zoneId", "kvNamespaceId", "tunnelId", "tunnelService", "warp"], "Cloudflare runtime config");
|
|
12311
|
+
if (environment.cloudflare && typeof environment.cloudflare === "object" && environment.cloudflare.warp !== undefined) {
|
|
12312
|
+
exactKeys2(environment.cloudflare.warp, ["organization", "virtualNetworkId", "deviceProfileId"], "Cloudflare WARP runtime config");
|
|
12313
|
+
}
|
|
12314
|
+
} else if (root.kind === "tenant") {
|
|
12315
|
+
if (root.bootstrapRunner !== undefined)
|
|
12316
|
+
exactKeys2(root.bootstrapRunner, ["sshPrivateKeyFile", "targetTelemetryEndpoint"], "bootstrap runner config");
|
|
12317
|
+
for (const key of ["environment", "profile", "computeReference", "database", "platformEnrolTokenFile", "runtime", "cloudflareHandoff"]) {
|
|
12318
|
+
if (root[key] !== undefined && key !== "profile")
|
|
12319
|
+
throw new Error(`tenant bootstrap cannot contain ${key}`);
|
|
12320
|
+
}
|
|
12321
|
+
} else
|
|
12322
|
+
throw new Error("bootstrap config kind must be platform or tenant");
|
|
12323
|
+
return value;
|
|
12324
|
+
}
|
|
12325
|
+
function readBootstrapConfig(path) {
|
|
12326
|
+
const metadata = lstatSync2(path);
|
|
12327
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.uid !== (process.getuid?.() ?? metadata.uid) || metadata.nlink !== 1 || (metadata.mode & 63) !== 0 || metadata.size > 64 * 1024) {
|
|
12328
|
+
throw new Error("bootstrap config must be an owner-only regular file with one link and at most 64 KiB");
|
|
12329
|
+
}
|
|
12330
|
+
return validateBootstrapConfig(strictBootstrapDocument(JSON.parse(readFileSync4(path, "utf8"))));
|
|
12331
|
+
}
|
|
12332
|
+
function localBootstrapHost() {
|
|
12333
|
+
const execute = async (argv, options = {}) => {
|
|
12334
|
+
const child = Bun.spawn([...argv], { stdin: options.stdin === undefined ? "ignore" : "pipe", stdout: "pipe", stderr: "pipe" });
|
|
12335
|
+
if (options.stdin !== undefined && child.stdin && typeof child.stdin !== "number") {
|
|
12336
|
+
child.stdin.write(options.stdin);
|
|
12337
|
+
child.stdin.end();
|
|
12338
|
+
}
|
|
12339
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
12340
|
+
new Response(child.stdout).text(),
|
|
12341
|
+
new Response(child.stderr).text(),
|
|
12342
|
+
child.exited
|
|
12343
|
+
]);
|
|
12344
|
+
return { exitCode, output: `${stdout}${stderr}` };
|
|
12345
|
+
};
|
|
12346
|
+
return {
|
|
12347
|
+
uid: () => process.getuid?.() ?? -1,
|
|
12348
|
+
exists: existsSync4,
|
|
12349
|
+
read: (path) => readFileSync4(path, "utf8"),
|
|
12350
|
+
write(path, content, mode) {
|
|
12351
|
+
mkdirSync4(dirname4(path), { recursive: true, mode: 493 });
|
|
12352
|
+
const temporary = `${path}.next.${process.pid}`;
|
|
12353
|
+
writeFileSync4(temporary, content, { mode });
|
|
12354
|
+
chmodSync2(temporary, mode);
|
|
12355
|
+
renameSync3(temporary, path);
|
|
12356
|
+
},
|
|
12357
|
+
mkdir: (path, mode) => mkdirSync4(path, { recursive: true, mode }),
|
|
12358
|
+
remove: (path) => rmSync(path, { force: true }),
|
|
12359
|
+
inspect(path) {
|
|
12360
|
+
const value = lstatSync2(path);
|
|
12361
|
+
return { regular: value.isFile(), symbolic: value.isSymbolicLink(), uid: value.uid, mode: value.mode, links: value.nlink, size: value.size };
|
|
12362
|
+
},
|
|
12363
|
+
exec: execute,
|
|
12364
|
+
async ensureSoftware(requirements) {
|
|
12365
|
+
const result = await execute([
|
|
12366
|
+
"runuser",
|
|
12367
|
+
"-u",
|
|
12368
|
+
"forgezero-agent",
|
|
12369
|
+
"--",
|
|
12370
|
+
"/usr/local/bin/fz-agent",
|
|
12371
|
+
"software-ensure",
|
|
12372
|
+
...requirements.map(({ id: id2, version }) => `--require=${id2}@${version}`)
|
|
12373
|
+
]);
|
|
12374
|
+
if (result.exitCode !== 0)
|
|
12375
|
+
throw new Error(`Agent software requirements failed: ${result.output.trim()}`);
|
|
12376
|
+
return result;
|
|
12377
|
+
},
|
|
12378
|
+
async installAgent(config, enrolTokenSourcePath) {
|
|
12379
|
+
const capabilities = await readCapabilities(localRunner);
|
|
12380
|
+
const deployRoot = config.deployRoot ?? "/opt/forgezero";
|
|
12381
|
+
if (config.kind === "platform") {
|
|
12382
|
+
const lifecycle = config.database.role === "none" ? {
|
|
12383
|
+
apiUnits: ["forgezero@blue.service", "forgezero@green.service"],
|
|
12384
|
+
apiHealthUrl: "http://127.0.0.1:3000/api/health"
|
|
12385
|
+
} : {
|
|
12386
|
+
apiUnits: ["forgezero@blue.service", "forgezero@green.service"],
|
|
12387
|
+
databaseUnit: "forgezero-db.service",
|
|
12388
|
+
apiHealthUrl: "http://127.0.0.1:3000/api/health",
|
|
12389
|
+
databaseHealthUrl: `http://${config.database.address}:8529/_api/version`,
|
|
12390
|
+
databasePorts: [8529]
|
|
12391
|
+
};
|
|
12392
|
+
mkdirSync4(dirname4(LIFECYCLE_PROFILE), { recursive: true, mode: 493 });
|
|
12393
|
+
writeFileSync4(LIFECYCLE_PROFILE, `${JSON.stringify(lifecycle, null, 2)}
|
|
12394
|
+
`, { mode: 256 });
|
|
12395
|
+
}
|
|
12396
|
+
const plan = planInstall({
|
|
12397
|
+
capabilities,
|
|
12398
|
+
socketPath: DEFAULT_SOCKET,
|
|
12399
|
+
seedPath: "/var/lib/forgezero/node.seed",
|
|
12400
|
+
controlSocketPath: "/run/forgezero/control.sock",
|
|
12401
|
+
repository: config.repository,
|
|
12402
|
+
branch: config.branch,
|
|
12403
|
+
profile: config.kind === "platform" ? config.profile : config.profile,
|
|
12404
|
+
deployRoot,
|
|
12405
|
+
publicApiUrl: config.apiUrl,
|
|
12406
|
+
gitCredentialPath: "/etc/forgezero/creds/git-deploy-key.cred",
|
|
12407
|
+
gitPublicKeyPath: "/etc/forgezero/git/deploy.pub",
|
|
12408
|
+
generateGitIdentity: true,
|
|
12409
|
+
pullDeployments: true,
|
|
12410
|
+
pullMigrations: config.kind === "platform",
|
|
12411
|
+
pullBootstrap: config.kind === "tenant" && Boolean(config.bootstrapRunner),
|
|
12412
|
+
bootstrapSshCredentialPath: config.kind === "tenant" && config.bootstrapRunner ? BOOTSTRAP_SSH_CREDENTIAL : undefined,
|
|
12413
|
+
bootstrapTargetTelemetryEndpoint: config.kind === "tenant" ? config.bootstrapRunner?.targetTelemetryEndpoint : undefined,
|
|
12414
|
+
lifecycleProfilePath: config.kind === "platform" ? LIFECYCLE_PROFILE : undefined,
|
|
12415
|
+
...config.kind === "platform" && config.runtime.environment.cloudflare?.warp ? {
|
|
12416
|
+
warpOrganization: config.runtime.environment.cloudflare.warp.organization,
|
|
12417
|
+
warpClientIdCredentialPath: WARP_CLIENT_ID_CREDENTIAL,
|
|
12418
|
+
warpClientSecretCredentialPath: WARP_CLIENT_SECRET_CREDENTIAL,
|
|
12419
|
+
cloudflareAccountId: config.runtime.environment.cloudflare.accountId,
|
|
12420
|
+
cloudflareTunnelId: config.runtime.environment.cloudflare.tunnelId,
|
|
12421
|
+
cloudflareVirtualNetworkId: config.runtime.environment.cloudflare.warp.virtualNetworkId,
|
|
12422
|
+
cloudflareWarpPolicyId: config.runtime.environment.cloudflare.warp.deviceProfileId
|
|
12423
|
+
} : {},
|
|
12424
|
+
enforceEgress: true,
|
|
12425
|
+
nodeHostname: config.nodeHostname,
|
|
12426
|
+
telemetryEndpoint: config.telemetryEndpoint,
|
|
12427
|
+
binPath: "/usr/local/lib/forgezero/agent/fz-agent",
|
|
12428
|
+
sourceBinPath: PACKAGED_AGENT_BIN,
|
|
12429
|
+
...config.kind === "tenant" || enrolTokenSourcePath ? {
|
|
12430
|
+
enrolTokenSourcePath: config.kind === "tenant" ? config.enrolTokenFile : enrolTokenSourcePath,
|
|
12431
|
+
enrolTokenCredentialPath: ENROL_CREDENTIAL,
|
|
12432
|
+
enrolStatePath: "/var/lib/forgezero/enrolment.json",
|
|
12433
|
+
apiUrl: config.apiUrl,
|
|
12434
|
+
project: config.kind === "tenant" ? config.realm : "platform",
|
|
12435
|
+
environment: config.kind === "tenant" ? undefined : config.environment,
|
|
12436
|
+
nodeLabel: config.kind === "tenant" ? config.nodeHostname : config.computeReference
|
|
12437
|
+
} : {}
|
|
12438
|
+
});
|
|
12439
|
+
for (const unit of [{ path: plan.unitPath, unit: plan.unit }, ...plan.auxiliaryUnits]) {
|
|
12440
|
+
mkdirSync4(dirname4(unit.path), { recursive: true, mode: 493 });
|
|
12441
|
+
writeFileSync4(unit.path, unit.unit, { mode: 420 });
|
|
12442
|
+
}
|
|
12443
|
+
await applyPlan(plan, localRunner);
|
|
12444
|
+
return plan;
|
|
12445
|
+
}
|
|
12446
|
+
};
|
|
12447
|
+
}
|
|
12448
|
+
|
|
12449
|
+
// src/cli/cloudflare-bootstrap.ts
|
|
12450
|
+
import { constants as constants2, closeSync, fstatSync, openSync, readFileSync as readFileSync5 } from "fs";
|
|
12451
|
+
import { dirname as dirname5, resolve as resolve3 } from "path";
|
|
12452
|
+
var exactKeys3 = (value, allowed, label) => {
|
|
12453
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
12454
|
+
if (unknown.length)
|
|
12455
|
+
throw new Error(`${label} contains unsupported field ${unknown[0]}`);
|
|
12456
|
+
};
|
|
12457
|
+
var record2 = (value, label) => {
|
|
12458
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
12459
|
+
throw new Error(`${label} must be an object`);
|
|
12460
|
+
return value;
|
|
12461
|
+
};
|
|
12462
|
+
function readOwnerConfig(path) {
|
|
12463
|
+
const absolute = resolve3(path);
|
|
12464
|
+
let descriptor;
|
|
12465
|
+
try {
|
|
12466
|
+
descriptor = openSync(absolute, constants2.O_RDONLY | constants2.O_NOFOLLOW);
|
|
12467
|
+
const metadata = fstatSync(descriptor);
|
|
12468
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
12469
|
+
if (!metadata.isFile() || metadata.nlink !== 1 || metadata.size < 2 || metadata.size > 131072 || uid !== undefined && metadata.uid !== uid || (metadata.mode & 63) !== 0) {
|
|
12470
|
+
throw new Error(`${absolute} must be one operator-owned 0600 regular file`);
|
|
12471
|
+
}
|
|
12472
|
+
return JSON.parse(readFileSync5(descriptor, "utf8"));
|
|
12473
|
+
} catch (cause) {
|
|
12474
|
+
if (cause instanceof SyntaxError)
|
|
12475
|
+
throw new Error(`${absolute} is not valid Cloudflare bootstrap JSON`);
|
|
12476
|
+
if (cause instanceof Error && cause.message.startsWith(absolute))
|
|
12477
|
+
throw cause;
|
|
12478
|
+
throw new Error(`cannot securely read Cloudflare bootstrap config ${absolute}`);
|
|
12479
|
+
} finally {
|
|
12480
|
+
if (descriptor !== undefined)
|
|
12481
|
+
closeSync(descriptor);
|
|
12482
|
+
}
|
|
12483
|
+
}
|
|
12484
|
+
function readCloudflareBootstrapCommandConfig(path, mode) {
|
|
12485
|
+
const input = record2(readOwnerConfig(path), "Cloudflare bootstrap config");
|
|
12486
|
+
const baseDirectory = dirname5(resolve3(path));
|
|
12487
|
+
exactKeys3(input, ["format", "kind", "checkpointPath", "coordinates", "tokenFiles"], "Cloudflare bootstrap config");
|
|
12488
|
+
if (input.format !== 1 || input.kind !== "forgezero-cloudflare-bootstrap-request") {
|
|
12489
|
+
throw new Error("Cloudflare bootstrap config format/kind is invalid");
|
|
12490
|
+
}
|
|
12491
|
+
if (typeof input.checkpointPath !== "string" || !input.checkpointPath.trim()) {
|
|
12492
|
+
throw new Error("Cloudflare bootstrap checkpointPath is required");
|
|
12493
|
+
}
|
|
12494
|
+
const coordinateSource = record2(input.coordinates, "Cloudflare bootstrap coordinates");
|
|
12495
|
+
exactKeys3(coordinateSource, [
|
|
12496
|
+
"accountId",
|
|
12497
|
+
"zoneId",
|
|
12498
|
+
"hostname",
|
|
12499
|
+
"service",
|
|
12500
|
+
"tunnelName",
|
|
12501
|
+
"kvNamespaceTitle",
|
|
12502
|
+
"workerScriptName",
|
|
12503
|
+
"serviceTokenName",
|
|
12504
|
+
"policyName",
|
|
12505
|
+
"applicationName",
|
|
12506
|
+
"workerDirectory",
|
|
12507
|
+
"workerMain",
|
|
12508
|
+
"workerCompatibilityDate",
|
|
12509
|
+
"publicDomains",
|
|
12510
|
+
"createRuntimeTokens",
|
|
12511
|
+
"createPrivateNetworkRuntimeToken",
|
|
12512
|
+
"runtimeTokenNamePrefix",
|
|
12513
|
+
"nodes",
|
|
12514
|
+
"privateNetwork"
|
|
12515
|
+
], "Cloudflare bootstrap coordinates");
|
|
12516
|
+
if (coordinateSource.nodes !== undefined) {
|
|
12517
|
+
if (!Array.isArray(coordinateSource.nodes))
|
|
12518
|
+
throw new Error("Cloudflare bootstrap coordinates.nodes must be an array");
|
|
12519
|
+
for (const node of coordinateSource.nodes) {
|
|
12520
|
+
exactKeys3(record2(node, "Cloudflare bootstrap node"), [
|
|
12521
|
+
"nodeName",
|
|
12522
|
+
"hostname",
|
|
12523
|
+
"service",
|
|
12524
|
+
"tunnelName",
|
|
12525
|
+
"applicationName",
|
|
12526
|
+
"privateAddress"
|
|
12527
|
+
], "Cloudflare bootstrap node");
|
|
12528
|
+
}
|
|
12529
|
+
}
|
|
12530
|
+
if (coordinateSource.privateNetwork !== undefined) {
|
|
12531
|
+
exactKeys3(record2(coordinateSource.privateNetwork, "Cloudflare bootstrap privateNetwork"), [
|
|
12532
|
+
"warpOrganization",
|
|
12533
|
+
"virtualNetworkName",
|
|
12534
|
+
"deviceProfileName",
|
|
12535
|
+
"enrollmentApplicationName",
|
|
12536
|
+
"deviceProfilePrecedence"
|
|
12537
|
+
], "Cloudflare bootstrap privateNetwork");
|
|
12538
|
+
}
|
|
12539
|
+
if (typeof coordinateSource.workerDirectory !== "string" || !coordinateSource.workerDirectory.trim()) {
|
|
12540
|
+
throw new Error("Cloudflare bootstrap coordinates.workerDirectory is required");
|
|
12541
|
+
}
|
|
12542
|
+
const coordinates = validateCloudflareBootstrapCoordinates({
|
|
12543
|
+
...coordinateSource,
|
|
12544
|
+
workerDirectory: resolve3(baseDirectory, coordinateSource.workerDirectory)
|
|
12545
|
+
});
|
|
12546
|
+
let tokenFiles;
|
|
12547
|
+
if (input.tokenFiles !== undefined) {
|
|
12548
|
+
const source = record2(input.tokenFiles, "Cloudflare bootstrap tokenFiles");
|
|
12549
|
+
const keys = [
|
|
12550
|
+
"apiTokenFile",
|
|
12551
|
+
"tunnelApiTokenFile",
|
|
12552
|
+
"dnsApiTokenFile",
|
|
12553
|
+
"kvApiTokenFile",
|
|
12554
|
+
"accessApiTokenFile",
|
|
12555
|
+
"workerApiTokenFile"
|
|
12556
|
+
];
|
|
12557
|
+
exactKeys3(source, keys, "Cloudflare bootstrap tokenFiles");
|
|
12558
|
+
for (const key of keys) {
|
|
12559
|
+
if (source[key] !== undefined && (typeof source[key] !== "string" || !source[key].trim())) {
|
|
12560
|
+
throw new Error(`Cloudflare bootstrap tokenFiles.${key} must be a non-empty file path`);
|
|
12561
|
+
}
|
|
12562
|
+
}
|
|
12563
|
+
tokenFiles = Object.fromEntries(keys.flatMap((key) => typeof source[key] === "string" ? [[key, resolve3(baseDirectory, source[key])]] : []));
|
|
12564
|
+
}
|
|
12565
|
+
if (mode === "apply" && (!tokenFiles || !Object.values(tokenFiles).some(Boolean))) {
|
|
12566
|
+
throw new Error("Cloudflare apply config requires tokenFiles with owner-only file paths");
|
|
12567
|
+
}
|
|
12568
|
+
return {
|
|
12569
|
+
mode,
|
|
12570
|
+
coordinates,
|
|
12571
|
+
checkpointPath: resolve3(baseDirectory, input.checkpointPath),
|
|
12572
|
+
...tokenFiles ? { tokenFiles } : {}
|
|
12573
|
+
};
|
|
12574
|
+
}
|
|
12575
|
+
async function runCloudflareBootstrapCommand(configPath, apply, dependencies = {}) {
|
|
12576
|
+
const request2 = readCloudflareBootstrapCommandConfig(configPath, apply ? "apply" : "plan");
|
|
12577
|
+
const evidence = await (dependencies.run ?? runAttendedCloudflareBootstrap)(request2);
|
|
12578
|
+
(dependencies.write ?? ((text3) => process.stdout.write(text3)))(`${JSON.stringify(evidence, null, 2)}
|
|
12579
|
+
`);
|
|
12580
|
+
return evidence;
|
|
12581
|
+
}
|
|
12582
|
+
|
|
12583
|
+
// src/metal-bootstrap.ts
|
|
12584
|
+
import { createHash as createHash3, randomBytes as randomBytes6 } from "crypto";
|
|
12585
|
+
import {
|
|
12586
|
+
chmodSync as chmodSync3,
|
|
12587
|
+
chownSync,
|
|
12588
|
+
copyFileSync,
|
|
12589
|
+
existsSync as existsSync5,
|
|
12590
|
+
lstatSync as lstatSync3,
|
|
12591
|
+
mkdirSync as mkdirSync6,
|
|
12592
|
+
readFileSync as readFileSync6,
|
|
12593
|
+
realpathSync,
|
|
12594
|
+
renameSync as renameSync4,
|
|
12595
|
+
statSync,
|
|
12596
|
+
symlinkSync,
|
|
12597
|
+
unlinkSync as unlinkSync2,
|
|
12598
|
+
writeFileSync as writeFileSync6
|
|
12599
|
+
} from "fs";
|
|
12600
|
+
import { dirname as dirname7, isAbsolute as isAbsolute2, join as join8, resolve as resolve4 } from "path";
|
|
12601
|
+
import { isIP as isIP5 } from "net";
|
|
12602
|
+
|
|
12603
|
+
// src/metal-isolation.ts
|
|
12604
|
+
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
12605
|
+
import { join as join7 } from "path";
|
|
12606
|
+
|
|
12607
|
+
// src/metal-provision.ts
|
|
12608
|
+
import { dirname as dirname6, isAbsolute, join as join6 } from "path";
|
|
12609
|
+
import { isIP as isIP4 } from "net";
|
|
12610
|
+
|
|
12611
|
+
// src/ubuntu.ts
|
|
12612
|
+
var SUPPORTED_GUEST_IMAGE = Object.freeze({
|
|
12613
|
+
key: "ubuntu-resolute-20260731",
|
|
12614
|
+
family: "ubuntu-26.04",
|
|
12615
|
+
version: "2026-07-31",
|
|
12616
|
+
label: "Ubuntu 26.04 LTS Resolute",
|
|
12617
|
+
url: "https://cloud-images.ubuntu.com/releases/resolute/release-20260731/ubuntu-26.04-server-cloudimg-amd64.img",
|
|
12618
|
+
sha256: "9dc7c5363c0146a08ba0c9aa834d82c2c6dfbb1c471ad9a2f0aba1189e21be05"
|
|
12619
|
+
});
|
|
12620
|
+
|
|
12621
|
+
// src/metal-provision.ts
|
|
12622
|
+
var SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
|
|
12623
|
+
var DEVICE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,14}$/;
|
|
12624
|
+
var IPV4_PREFIX = /^(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)$/;
|
|
12625
|
+
var LINUX_LIST = /^\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$/;
|
|
12626
|
+
|
|
12627
|
+
class MetalProvisionError extends Error {
|
|
12628
|
+
}
|
|
12629
|
+
function membersOfLinuxList(value, label) {
|
|
12630
|
+
if (!LINUX_LIST.test(value))
|
|
12631
|
+
throw new MetalProvisionError(`invalid ${label} list`);
|
|
12632
|
+
const members = [];
|
|
12633
|
+
for (const part of value.split(",")) {
|
|
12634
|
+
const [startText, endText = startText] = part.split("-");
|
|
12635
|
+
const start = Number(startText);
|
|
12636
|
+
const end = Number(endText);
|
|
12637
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end > 65535) {
|
|
12638
|
+
throw new MetalProvisionError(`invalid ${label} list`);
|
|
12639
|
+
}
|
|
12640
|
+
for (let value2 = start;value2 <= end; value2 += 1)
|
|
12641
|
+
members.push(value2);
|
|
12642
|
+
}
|
|
12643
|
+
if (new Set(members).size !== members.length)
|
|
12644
|
+
throw new MetalProvisionError(`${label} list overlaps itself`);
|
|
12645
|
+
return members;
|
|
12646
|
+
}
|
|
12647
|
+
function validateMetalProfile(profile) {
|
|
12648
|
+
if (!SAFE_NAME.test(profile.volumeGroup))
|
|
12649
|
+
throw new MetalProvisionError("invalid volume group");
|
|
12650
|
+
if (!DEVICE.test(profile.bridge))
|
|
12651
|
+
throw new MetalProvisionError("invalid bridge");
|
|
12652
|
+
if (!IPV4_PREFIX.test(profile.subnetPrefix))
|
|
12653
|
+
throw new MetalProvisionError("invalid subnet prefix");
|
|
12654
|
+
if (!Number.isInteger(profile.addressStart) || !Number.isInteger(profile.addressEnd) || profile.addressStart < 2 || profile.addressEnd > 254 || profile.addressStart > profile.addressEnd)
|
|
12655
|
+
throw new MetalProvisionError("invalid guest address range");
|
|
12656
|
+
for (const path of [profile.stateDir, profile.seedDir, profile.unitDir]) {
|
|
12657
|
+
if (!isAbsolute(path))
|
|
12658
|
+
throw new MetalProvisionError("metal paths must be absolute");
|
|
12659
|
+
}
|
|
12660
|
+
new URL(profile.apiUrl);
|
|
12661
|
+
let telemetryEndpoint;
|
|
12662
|
+
try {
|
|
12663
|
+
telemetryEndpoint = new URL(profile.agentTelemetryEndpoint);
|
|
12664
|
+
} catch {
|
|
12665
|
+
throw new MetalProvisionError("Agent telemetry endpoint must be an absolute public HTTPS URL");
|
|
12666
|
+
}
|
|
12667
|
+
if (telemetryEndpoint.protocol !== "https:" || telemetryEndpoint.username || telemetryEndpoint.password || telemetryEndpoint.search || telemetryEndpoint.hash || isIP4(telemetryEndpoint.hostname) !== 0 || !telemetryEndpoint.hostname.includes(".") || telemetryEndpoint.hostname === "localhost" || telemetryEndpoint.hostname.endsWith(".local"))
|
|
12668
|
+
throw new MetalProvisionError("Agent telemetry endpoint must be a public HTTPS DNS coordinate without credentials, query or fragment");
|
|
12669
|
+
const imageKeys = Object.keys(profile.images);
|
|
12670
|
+
if (imageKeys.length !== 1 || imageKeys[0] !== SUPPORTED_GUEST_IMAGE.key || profile.images[SUPPORTED_GUEST_IMAGE.key]?.sha256 !== SUPPORTED_GUEST_IMAGE.sha256) {
|
|
12671
|
+
throw new MetalProvisionError(`metal profile must contain only the pinned ${SUPPORTED_GUEST_IMAGE.key} image contract`);
|
|
12672
|
+
}
|
|
12673
|
+
if (!Array.isArray(profile.cpuPools) || profile.cpuPools.length === 0) {
|
|
12674
|
+
throw new MetalProvisionError("at least one exclusive CPU pool is required");
|
|
12675
|
+
}
|
|
12676
|
+
const keys = new Set;
|
|
12677
|
+
const assigned = new Set;
|
|
12678
|
+
const assignedMemory = new Set;
|
|
12679
|
+
let poolsWithMemory = 0;
|
|
12680
|
+
for (const pool of profile.cpuPools) {
|
|
12681
|
+
if (!SAFE_NAME.test(pool.key) || keys.has(pool.key))
|
|
12682
|
+
throw new MetalProvisionError("invalid or duplicate CPU pool key");
|
|
12683
|
+
keys.add(pool.key);
|
|
12684
|
+
const cpus = membersOfLinuxList(pool.cpus, "CPU");
|
|
12685
|
+
if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
|
|
12686
|
+
throw new MetalProvisionError("invalid CPU pool physical-core count");
|
|
12687
|
+
}
|
|
12688
|
+
for (const cpu of cpus) {
|
|
12689
|
+
if (assigned.has(cpu))
|
|
12690
|
+
throw new MetalProvisionError("CPU pools overlap");
|
|
12691
|
+
assigned.add(cpu);
|
|
12692
|
+
}
|
|
12693
|
+
if (pool.memoryNodes) {
|
|
12694
|
+
poolsWithMemory += 1;
|
|
12695
|
+
for (const node of membersOfLinuxList(pool.memoryNodes, "memory-node")) {
|
|
12696
|
+
if (assignedMemory.has(node))
|
|
12697
|
+
throw new MetalProvisionError("guest memory-node pools overlap");
|
|
12698
|
+
assignedMemory.add(node);
|
|
12699
|
+
}
|
|
12700
|
+
}
|
|
12701
|
+
}
|
|
12702
|
+
if (poolsWithMemory !== 0 && poolsWithMemory !== profile.cpuPools.length) {
|
|
12703
|
+
throw new MetalProvisionError("every CPU pool must name memory nodes when NUMA isolation is enabled");
|
|
12704
|
+
}
|
|
12705
|
+
const housekeeping = membersOfLinuxList(profile.housekeepingCpus, "housekeeping CPU");
|
|
12706
|
+
if (housekeeping.some((cpu) => assigned.has(cpu))) {
|
|
12707
|
+
throw new MetalProvisionError("housekeeping CPUs overlap guest CPU pools");
|
|
12708
|
+
}
|
|
12709
|
+
if (profile.housekeepingMemoryNodes) {
|
|
12710
|
+
const housekeepingMemory = membersOfLinuxList(profile.housekeepingMemoryNodes, "housekeeping memory-node");
|
|
12711
|
+
if (housekeepingMemory.some((node) => assignedMemory.has(node))) {
|
|
12712
|
+
throw new MetalProvisionError("housekeeping memory nodes overlap guest memory-node pools");
|
|
12713
|
+
}
|
|
12714
|
+
} else if (assignedMemory.size > 0) {
|
|
12715
|
+
throw new MetalProvisionError("NUMA-isolated guest pools require housekeeping memory nodes");
|
|
12716
|
+
}
|
|
12717
|
+
}
|
|
12718
|
+
|
|
12719
|
+
// src/metal-isolation.ts
|
|
12720
|
+
var members = (list) => list.split(",").flatMap((part) => {
|
|
12721
|
+
const [first, last = first] = part.split("-").map(Number);
|
|
12722
|
+
return Array.from({ length: last - first + 1 }, (_, index) => first + index);
|
|
12723
|
+
});
|
|
12724
|
+
var compact = (values) => {
|
|
12725
|
+
const sorted = [...new Set(values)].sort((left, right) => left - right);
|
|
12726
|
+
const ranges = [];
|
|
12727
|
+
for (let index = 0;index < sorted.length; ) {
|
|
12728
|
+
const first = sorted[index];
|
|
12729
|
+
let last = first;
|
|
12730
|
+
while (sorted[index + 1] === last + 1)
|
|
12731
|
+
last = sorted[++index];
|
|
12732
|
+
ranges.push(first === last ? String(first) : `${first}-${last}`);
|
|
12733
|
+
index += 1;
|
|
12734
|
+
}
|
|
12735
|
+
return ranges.join(",");
|
|
12736
|
+
};
|
|
12737
|
+
var memoryDirective = (nodes) => nodes ? `AllowedMemoryNodes=${nodes}
|
|
12738
|
+
` : "";
|
|
12739
|
+
function metalGuestSliceUnit(profile) {
|
|
12740
|
+
validateMetalProfile(profile);
|
|
12741
|
+
const cpus = compact(profile.cpuPools.flatMap((pool) => members(pool.cpus)));
|
|
12742
|
+
const nodes = compact(profile.cpuPools.flatMap((pool) => pool.memoryNodes ? members(pool.memoryNodes) : [])) || undefined;
|
|
12743
|
+
return `[Unit]
|
|
12744
|
+
Description=ForgeZero exclusive guest CPU and memory boundary
|
|
12745
|
+
|
|
12746
|
+
[Slice]
|
|
12747
|
+
AllowedCPUs=${cpus}
|
|
12748
|
+
${memoryDirective(nodes)}`;
|
|
12749
|
+
}
|
|
12750
|
+
function metalHousekeepingDropIn(profile, kind) {
|
|
12751
|
+
validateMetalProfile(profile);
|
|
12752
|
+
return `[${kind === "slice" ? "Slice" : "Scope"}]
|
|
12753
|
+
AllowedCPUs=${profile.housekeepingCpus}
|
|
12754
|
+
${memoryDirective(profile.housekeepingMemoryNodes)}`;
|
|
12755
|
+
}
|
|
12756
|
+
var defaultExec = async (argv) => {
|
|
12757
|
+
const child = Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe" });
|
|
12758
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
12759
|
+
child.exited,
|
|
12760
|
+
new Response(child.stdout).text(),
|
|
12761
|
+
new Response(child.stderr).text()
|
|
12762
|
+
]);
|
|
12763
|
+
return { exitCode, stdout, stderr };
|
|
12764
|
+
};
|
|
12765
|
+
var checked2 = async (exec, argv) => {
|
|
12766
|
+
const result = await exec(argv);
|
|
12767
|
+
if (result.exitCode !== 0)
|
|
12768
|
+
throw new Error(`${argv[0]} failed: ${(result.stderr || result.stdout).trim()}`);
|
|
12769
|
+
return result;
|
|
12770
|
+
};
|
|
12771
|
+
var requireGuestsInSlice = async (exec) => {
|
|
12772
|
+
const active = await checked2(exec, [
|
|
12773
|
+
"systemctl",
|
|
12774
|
+
"list-units",
|
|
12775
|
+
"--type=service",
|
|
12776
|
+
"--state=running",
|
|
12777
|
+
"--plain",
|
|
12778
|
+
"--no-legend",
|
|
12779
|
+
"forgezero-guest@*.service"
|
|
12780
|
+
]);
|
|
12781
|
+
for (const line of active.stdout.split(`
|
|
12782
|
+
`)) {
|
|
12783
|
+
const service = line.trim().split(/\s+/)[0];
|
|
12784
|
+
if (!service)
|
|
12785
|
+
continue;
|
|
12786
|
+
const cgroup = await checked2(exec, ["systemctl", "show", "-p", "ControlGroup", "--value", service]);
|
|
12787
|
+
if (!cgroup.stdout.trim().includes("/forgezero-guests.slice/")) {
|
|
12788
|
+
throw new Error(`${service} must be drained and restarted into forgezero-guests.slice`);
|
|
12789
|
+
}
|
|
12790
|
+
}
|
|
12791
|
+
};
|
|
12792
|
+
async function applyMetalIsolation(profile, exec = defaultExec) {
|
|
12793
|
+
validateMetalProfile(profile);
|
|
12794
|
+
await requireGuestsInSlice(exec);
|
|
12795
|
+
const unitDir = profile.unitDir;
|
|
12796
|
+
mkdirSync5(unitDir, { recursive: true });
|
|
12797
|
+
writeFileSync5(join7(unitDir, "forgezero-guests.slice"), metalGuestSliceUnit(profile), { mode: 420 });
|
|
12798
|
+
for (const unit of ["system.slice", "user.slice"]) {
|
|
12799
|
+
const directory = join7(unitDir, `${unit}.d`);
|
|
12800
|
+
mkdirSync5(directory, { recursive: true });
|
|
12801
|
+
writeFileSync5(join7(directory, "50-forgezero-housekeeping.conf"), metalHousekeepingDropIn(profile, "slice"), { mode: 420 });
|
|
12802
|
+
}
|
|
12803
|
+
const initDirectory = join7(unitDir, "init.scope.d");
|
|
12804
|
+
mkdirSync5(initDirectory, { recursive: true });
|
|
12805
|
+
writeFileSync5(join7(initDirectory, "50-forgezero-housekeeping.conf"), metalHousekeepingDropIn(profile, "scope"), { mode: 420 });
|
|
12806
|
+
await checked2(exec, ["systemctl", "daemon-reload"]);
|
|
12807
|
+
await requireGuestsInSlice(exec);
|
|
12808
|
+
const properties = [`AllowedCPUs=${profile.housekeepingCpus}`];
|
|
12809
|
+
if (profile.housekeepingMemoryNodes)
|
|
12810
|
+
properties.push(`AllowedMemoryNodes=${profile.housekeepingMemoryNodes}`);
|
|
12811
|
+
for (const unit of ["system.slice", "user.slice", "init.scope"]) {
|
|
12812
|
+
await checked2(exec, ["systemctl", "set-property", "--runtime", unit, ...properties]);
|
|
12813
|
+
}
|
|
12814
|
+
}
|
|
12815
|
+
|
|
12816
|
+
// src/metal-bootstrap.ts
|
|
12817
|
+
var PROFILE_PATH = "/etc/forgezero/metal.json";
|
|
12818
|
+
var STATE_PATH2 = "/etc/forgezero/metal.initialized.json";
|
|
12819
|
+
var SEED_CREDENTIAL_PATH = "/etc/forgezero/creds/metal-agent-seed.cred";
|
|
12820
|
+
var UNIT_DIRECTORY = "/etc/systemd/system";
|
|
12821
|
+
var AGENT_PATH = "/usr/local/bin/fz-agent";
|
|
12822
|
+
var HELPER_SOCKET = "/run/forgezero-metal/helper.sock";
|
|
12823
|
+
var UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
|
|
12824
|
+
var MAX_CONFIG_BYTES = 256 * 1024;
|
|
12825
|
+
var SUPPORTED_BUN_VERSION = "1.3.14";
|
|
12826
|
+
var SUPPORTED_BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
|
|
12827
|
+
|
|
12828
|
+
class MetalBootstrapError extends Error {
|
|
12829
|
+
}
|
|
12830
|
+
var defaultExec2 = async (argv, stdin) => {
|
|
12831
|
+
const child = Bun.spawn([...argv], {
|
|
12832
|
+
stdin: stdin === undefined ? undefined : new Blob([stdin]),
|
|
12833
|
+
stdout: "pipe",
|
|
12834
|
+
stderr: "pipe",
|
|
12835
|
+
env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }
|
|
12836
|
+
});
|
|
12837
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
12838
|
+
child.exited,
|
|
12839
|
+
new Response(child.stdout).text(),
|
|
12840
|
+
new Response(child.stderr).text()
|
|
12841
|
+
]);
|
|
12842
|
+
return { exitCode, stdout, stderr };
|
|
12843
|
+
};
|
|
12844
|
+
var runChecked = async (exec, argv, stdin) => {
|
|
12845
|
+
const result = await exec(argv, stdin);
|
|
12846
|
+
if (result.exitCode !== 0) {
|
|
12847
|
+
throw new MetalBootstrapError(`${argv.join(" ")} failed: ${(result.stderr || result.stdout).trim() || `exit ${result.exitCode}`}`);
|
|
12848
|
+
}
|
|
12849
|
+
return result;
|
|
12850
|
+
};
|
|
12851
|
+
var exactKeys4 = (value, allowed, label) => {
|
|
12852
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
12853
|
+
if (unknown.length)
|
|
12854
|
+
throw new MetalBootstrapError(`${label} contains unknown fields: ${unknown.join(", ")}`);
|
|
12855
|
+
};
|
|
12856
|
+
var validUnit = (unit) => {
|
|
12857
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}\.service$/.test(unit)) {
|
|
12858
|
+
throw new MetalBootstrapError("hostTelemetryUnit must be a systemd .service unit");
|
|
12859
|
+
}
|
|
12860
|
+
if (/^(forgezero@.*|forgezero-agent|forgezero-metal-agent|forgezero-metal-helper|forgezero-db)\.service$/.test(unit)) {
|
|
12861
|
+
throw new MetalBootstrapError("the OTLP collector must be independently supervised");
|
|
12862
|
+
}
|
|
12863
|
+
};
|
|
12864
|
+
function validateMetalBootstrapConfig(config) {
|
|
12865
|
+
if (!config || typeof config !== "object")
|
|
12866
|
+
throw new MetalBootstrapError("metal bootstrap config must be an object");
|
|
12867
|
+
exactKeys4(config, ["kind", "metalHostname", "profile", "hostTelemetryEndpoint", "hostTelemetryUnit", "agentSeedFile"], "metal bootstrap config");
|
|
12868
|
+
if (config.kind !== "metal")
|
|
12869
|
+
throw new MetalBootstrapError("metal bootstrap config kind must be metal");
|
|
12870
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9.-]{1,252}$/.test(config.metalHostname)) {
|
|
12871
|
+
throw new MetalBootstrapError("invalid metal inventory hostname");
|
|
12872
|
+
}
|
|
12873
|
+
if (!config.profile || typeof config.profile !== "object")
|
|
12874
|
+
throw new MetalBootstrapError("metal profile must be an object");
|
|
12875
|
+
exactKeys4(config.profile, [
|
|
12876
|
+
"volumeGroup",
|
|
12877
|
+
"bridge",
|
|
12878
|
+
"subnetPrefix",
|
|
12879
|
+
"addressStart",
|
|
12880
|
+
"addressEnd",
|
|
12881
|
+
"gateway",
|
|
12882
|
+
"nameservers",
|
|
12883
|
+
"stateDir",
|
|
12884
|
+
"seedDir",
|
|
12885
|
+
"unitDir",
|
|
12886
|
+
"apiUrl",
|
|
12887
|
+
"agentTelemetryEndpoint",
|
|
12888
|
+
"images",
|
|
12889
|
+
"cpuPools",
|
|
12890
|
+
"housekeepingCpus",
|
|
12891
|
+
"housekeepingMemoryNodes",
|
|
12892
|
+
"bunVersion",
|
|
12893
|
+
"bunInstallerSha256",
|
|
12894
|
+
"agentVersion",
|
|
12895
|
+
"confidential"
|
|
12896
|
+
], "metal profile");
|
|
12897
|
+
if (Array.isArray(config.profile.cpuPools)) {
|
|
12898
|
+
for (const pool of config.profile.cpuPools)
|
|
12899
|
+
exactKeys4(pool, ["key", "cpus", "physicalCores", "memoryNodes"], "metal CPU pool");
|
|
12900
|
+
}
|
|
12901
|
+
validateMetalProfile(config.profile);
|
|
12902
|
+
if (config.profile.stateDir !== "/etc/forgezero/metal-guests" || config.profile.seedDir !== "/var/lib/forgezero/seed" || config.profile.unitDir !== UNIT_DIRECTORY || config.profile.legacyStateDir !== undefined) {
|
|
12903
|
+
throw new MetalBootstrapError("metal bootstrap uses fixed state, seed, and systemd unit directories");
|
|
12904
|
+
}
|
|
12905
|
+
const image = config.profile.images[Object.keys(config.profile.images)[0]];
|
|
12906
|
+
if (!image.path.startsWith("/var/lib/forgezero/images/") || resolve4(image.path) !== image.path || /[\0\r\n]/.test(image.path)) {
|
|
12907
|
+
throw new MetalBootstrapError("the pinned guest image must use the fixed image directory");
|
|
12908
|
+
}
|
|
12909
|
+
if (config.profile.bunVersion !== SUPPORTED_BUN_VERSION || config.profile.bunInstallerSha256 !== SUPPORTED_BUN_INSTALLER_SHA256 || config.profile.agentVersion !== VERSION2) {
|
|
12910
|
+
throw new MetalBootstrapError("metal guest runtime must use the package-owned Bun and Agent release coordinates");
|
|
12911
|
+
}
|
|
12912
|
+
let api;
|
|
12913
|
+
try {
|
|
12914
|
+
api = new URL(config.profile.apiUrl);
|
|
12915
|
+
} catch {
|
|
12916
|
+
throw new MetalBootstrapError("metal API must be a public HTTPS origin");
|
|
12917
|
+
}
|
|
12918
|
+
if (api.protocol !== "https:" || api.username || api.password || api.pathname !== "/" || api.search || api.hash || isIP5(api.hostname) !== 0 || !api.hostname.includes(".") || api.hostname.endsWith(".local")) {
|
|
12919
|
+
throw new MetalBootstrapError("metal API must be a credential-free public HTTPS origin");
|
|
12920
|
+
}
|
|
12921
|
+
if (config.profile.nameservers?.some((address) => isIP5(address) === 0)) {
|
|
12922
|
+
throw new MetalBootstrapError("metal nameservers must be literal IP addresses");
|
|
12923
|
+
}
|
|
12924
|
+
if (isIP5(`${config.profile.subnetPrefix}.1`) !== 4 || isIP5(config.profile.gateway) !== 4 || !config.profile.gateway.startsWith(`${config.profile.subnetPrefix}.`)) {
|
|
12925
|
+
throw new MetalBootstrapError("metal gateway must be an IPv4 address in the reviewed subnet");
|
|
12926
|
+
}
|
|
12927
|
+
if (config.profile.confidential && (!Number.isSafeInteger(config.profile.confidential.cbitpos) || config.profile.confidential.cbitpos < 1 || config.profile.confidential.cbitpos > 63 || !Number.isSafeInteger(config.profile.confidential.reducedPhysBits) || config.profile.confidential.reducedPhysBits < 0 || config.profile.confidential.reducedPhysBits > 63 || !/^0x[0-9a-fA-F]{1,16}$/.test(config.profile.confidential.policy)))
|
|
12928
|
+
throw new MetalBootstrapError("invalid confidential-compute coordinate");
|
|
12929
|
+
if (config.hostTelemetryEndpoint !== "http://127.0.0.1:4318") {
|
|
12930
|
+
throw new MetalBootstrapError("metal host OTLP must use exact loopback http://127.0.0.1:4318");
|
|
12931
|
+
}
|
|
12932
|
+
validUnit(config.hostTelemetryUnit);
|
|
12933
|
+
if (config.agentSeedFile)
|
|
12934
|
+
validateOwnerOnlyPath(config.agentSeedFile, false);
|
|
12935
|
+
return config;
|
|
12936
|
+
}
|
|
12937
|
+
function validateOwnerOnlyPath(path, requireRootOwner) {
|
|
12938
|
+
if (!isAbsolute2(path) || resolve4(path) !== path || path.includes("/../")) {
|
|
12939
|
+
throw new MetalBootstrapError("private bootstrap paths must be canonical absolute paths");
|
|
12940
|
+
}
|
|
12941
|
+
const metadata = lstatSync3(path);
|
|
12942
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || realpathSync(path) !== path) {
|
|
12943
|
+
throw new MetalBootstrapError("private bootstrap path must be a regular non-symlink file");
|
|
12944
|
+
}
|
|
12945
|
+
if ((metadata.mode & 63) !== 0)
|
|
12946
|
+
throw new MetalBootstrapError("private bootstrap file must be owner-only");
|
|
12947
|
+
const caller = typeof process.getuid === "function" ? process.getuid() : -1;
|
|
12948
|
+
if (requireRootOwner && metadata.uid !== 0 || !requireRootOwner && metadata.uid !== 0 && metadata.uid !== caller) {
|
|
12949
|
+
throw new MetalBootstrapError(requireRootOwner ? "private bootstrap file must be root-owned" : "private bootstrap file has an unexpected owner");
|
|
12950
|
+
}
|
|
12951
|
+
}
|
|
12952
|
+
function readMetalBootstrapConfig(path) {
|
|
12953
|
+
validateOwnerOnlyPath(path, false);
|
|
12954
|
+
const metadata = statSync(path);
|
|
12955
|
+
if (metadata.size < 2 || metadata.size > MAX_CONFIG_BYTES)
|
|
12956
|
+
throw new MetalBootstrapError("metal bootstrap config size is invalid");
|
|
12957
|
+
let parsed;
|
|
12958
|
+
try {
|
|
12959
|
+
parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
12960
|
+
} catch {
|
|
12961
|
+
throw new MetalBootstrapError("metal bootstrap config is not valid JSON");
|
|
12962
|
+
}
|
|
12963
|
+
return validateMetalBootstrapConfig(parsed);
|
|
12964
|
+
}
|
|
12965
|
+
function planMetalBootstrap(config) {
|
|
12966
|
+
validateMetalBootstrapConfig(config);
|
|
12967
|
+
return {
|
|
12968
|
+
kind: "metal",
|
|
12969
|
+
metalHostname: config.metalHostname,
|
|
12970
|
+
profilePath: PROFILE_PATH,
|
|
12971
|
+
units: [
|
|
12972
|
+
"forgezero-metal-helper.service",
|
|
12973
|
+
"forgezero-agent-update-helper.service",
|
|
12974
|
+
"forgezero-metal-agent-egress.service",
|
|
12975
|
+
"forgezero-metal-agent.service"
|
|
12976
|
+
],
|
|
12977
|
+
steps: [
|
|
12978
|
+
"install fixed KVM/LVM/cloud-init/nftables prerequisites",
|
|
12979
|
+
"verify bridge, volume group, pinned guest image, KVM and optional SEV device",
|
|
12980
|
+
"install the published Agent binary and root-owned immutable metal profile",
|
|
12981
|
+
"seal or generate the metal identity seed as a systemd credential",
|
|
12982
|
+
"apply reviewed CPU/NUMA host and guest isolation",
|
|
12983
|
+
"install helper, update, egress and identity-only Agent units",
|
|
12984
|
+
"enable units and prove services, local sockets, egress policy and OTLP collector"
|
|
12985
|
+
],
|
|
12986
|
+
requiresRoot: true,
|
|
12987
|
+
consumes: config.agentSeedFile ? [config.agentSeedFile] : []
|
|
12988
|
+
};
|
|
12989
|
+
}
|
|
12990
|
+
var atomicWrite2 = (path, body, mode) => {
|
|
12991
|
+
mkdirSync6(dirname7(path), { recursive: true, mode: 493 });
|
|
12992
|
+
const temporary = `${path}.next-${process.pid}`;
|
|
12993
|
+
writeFileSync6(temporary, body, { mode, flag: "wx" });
|
|
12994
|
+
chmodSync3(temporary, mode);
|
|
12995
|
+
chownSync(temporary, 0, 0);
|
|
12996
|
+
renameSync4(temporary, path);
|
|
12997
|
+
};
|
|
12998
|
+
var validateAgentSourcePath = (source) => {
|
|
12999
|
+
if (!isAbsolute2(source) || !lstatSync3(source).isFile() || lstatSync3(source).isSymbolicLink()) {
|
|
13000
|
+
throw new MetalBootstrapError("published Agent source path must be an absolute regular non-symlink file");
|
|
13001
|
+
}
|
|
13002
|
+
};
|
|
13003
|
+
var ensureAccount = async (exec) => {
|
|
13004
|
+
if ((await exec(["/usr/bin/getent", "group", "forgezero-metal"])).exitCode !== 0) {
|
|
13005
|
+
await runChecked(exec, ["/usr/sbin/groupadd", "--system", "forgezero-metal"]);
|
|
13006
|
+
}
|
|
13007
|
+
if ((await exec(["/usr/bin/getent", "group", "forgezero-update"])).exitCode !== 0) {
|
|
13008
|
+
await runChecked(exec, ["/usr/sbin/groupadd", "--system", "forgezero-update"]);
|
|
13009
|
+
}
|
|
13010
|
+
if ((await exec(["/usr/bin/id", "forgezero-metal"])).exitCode !== 0) {
|
|
13011
|
+
await runChecked(exec, [
|
|
13012
|
+
"/usr/sbin/useradd",
|
|
13013
|
+
"--system",
|
|
13014
|
+
"--no-create-home",
|
|
13015
|
+
"--shell",
|
|
13016
|
+
"/usr/sbin/nologin",
|
|
13017
|
+
"--gid",
|
|
13018
|
+
"forgezero-metal",
|
|
13019
|
+
"forgezero-metal"
|
|
13020
|
+
]);
|
|
13021
|
+
}
|
|
13022
|
+
await runChecked(exec, ["/usr/sbin/usermod", "-a", "-G", "forgezero-update", "forgezero-metal"]);
|
|
13023
|
+
};
|
|
13024
|
+
function renderMetalUnits(config) {
|
|
13025
|
+
validateMetalBootstrapConfig(config);
|
|
13026
|
+
const egress = systemdAgentEgressDirectives([4318]);
|
|
13027
|
+
const updateEgress = systemdAgentEgressDirectives();
|
|
13028
|
+
return {
|
|
13029
|
+
"forgezero-metal-helper.service": `[Unit]
|
|
13030
|
+
Description=ForgeZero constrained physical-host helper
|
|
13031
|
+
After=local-fs.target
|
|
13032
|
+
|
|
13033
|
+
[Service]
|
|
13034
|
+
Type=simple
|
|
13035
|
+
User=root
|
|
13036
|
+
Group=forgezero-metal
|
|
13037
|
+
UMask=0007
|
|
13038
|
+
RuntimeDirectory=forgezero-metal
|
|
13039
|
+
RuntimeDirectoryMode=0770
|
|
13040
|
+
ExecStart=${AGENT_PATH} metal-helper --profile=${PROFILE_PATH}
|
|
13041
|
+
Restart=on-failure
|
|
13042
|
+
RestartSec=5
|
|
13043
|
+
TimeoutStopSec=10min
|
|
13044
|
+
LimitCORE=0
|
|
13045
|
+
PrivateTmp=true
|
|
13046
|
+
ProtectHome=true
|
|
13047
|
+
RestrictAddressFamilies=AF_UNIX
|
|
13048
|
+
|
|
13049
|
+
[Install]
|
|
13050
|
+
WantedBy=multi-user.target
|
|
13051
|
+
`,
|
|
13052
|
+
"forgezero-agent-update-helper.service": `[Unit]
|
|
13053
|
+
Description=ForgeZero verified Agent update helper
|
|
13054
|
+
After=network-online.target
|
|
13055
|
+
Wants=network-online.target
|
|
13056
|
+
|
|
13057
|
+
[Service]
|
|
13058
|
+
Type=simple
|
|
13059
|
+
User=root
|
|
13060
|
+
Group=forgezero-update
|
|
13061
|
+
Environment=FZ_AGENT_UPDATE_SOCKET=${UPDATE_SOCKET}
|
|
13062
|
+
ExecStart=${AGENT_PATH} update-helper
|
|
13063
|
+
Restart=always
|
|
13064
|
+
RestartSec=2
|
|
13065
|
+
RuntimeDirectory=forgezero-update
|
|
13066
|
+
RuntimeDirectoryMode=0750
|
|
13067
|
+
UMask=0007
|
|
13068
|
+
LimitCORE=0
|
|
13069
|
+
NoNewPrivileges=true
|
|
13070
|
+
PrivateTmp=true
|
|
13071
|
+
ProtectSystem=strict
|
|
13072
|
+
ProtectHome=true
|
|
13073
|
+
ProtectKernelTunables=true
|
|
13074
|
+
ProtectKernelModules=true
|
|
13075
|
+
ProtectControlGroups=true
|
|
13076
|
+
RestrictSUIDSGID=true
|
|
13077
|
+
RestrictRealtime=true
|
|
13078
|
+
LockPersonality=true
|
|
13079
|
+
ReadWritePaths=/opt/forgezero/agent /var/lib/forgezero
|
|
13080
|
+
${updateEgress}
|
|
13081
|
+
|
|
13082
|
+
[Install]
|
|
13083
|
+
WantedBy=multi-user.target
|
|
13084
|
+
`,
|
|
13085
|
+
"forgezero-metal-agent-egress.service": `[Unit]
|
|
13086
|
+
Description=ForgeZero physical Agent host egress policy
|
|
13087
|
+
After=systemd-resolved.service nftables.service
|
|
13088
|
+
Requires=systemd-resolved.service
|
|
13089
|
+
Before=forgezero-metal-agent.service
|
|
13090
|
+
|
|
13091
|
+
[Service]
|
|
13092
|
+
Type=notify
|
|
13093
|
+
NotifyAccess=all
|
|
13094
|
+
User=root
|
|
13095
|
+
Group=root
|
|
13096
|
+
ExecStart=${AGENT_PATH} egress-policy --user=forgezero-metal --loopback-user=forgezero-metal --loopback-tcp-port=4318 --public-tcp-port=443
|
|
13097
|
+
Restart=on-failure
|
|
13098
|
+
RestartSec=2
|
|
13099
|
+
LimitCORE=0
|
|
13100
|
+
NoNewPrivileges=true
|
|
13101
|
+
PrivateTmp=true
|
|
13102
|
+
ProtectSystem=strict
|
|
13103
|
+
ProtectHome=true
|
|
13104
|
+
ProtectKernelTunables=true
|
|
13105
|
+
ProtectKernelModules=true
|
|
13106
|
+
ProtectControlGroups=true
|
|
13107
|
+
RestrictSUIDSGID=true
|
|
13108
|
+
RestrictRealtime=true
|
|
13109
|
+
MemoryDenyWriteExecute=true
|
|
13110
|
+
LockPersonality=true
|
|
13111
|
+
CapabilityBoundingSet=CAP_NET_ADMIN
|
|
13112
|
+
RestrictAddressFamilies=AF_UNIX AF_NETLINK
|
|
13113
|
+
|
|
13114
|
+
[Install]
|
|
13115
|
+
WantedBy=multi-user.target
|
|
13116
|
+
`,
|
|
13117
|
+
"forgezero-metal-agent.service": `[Unit]
|
|
13118
|
+
Description=ForgeZero identity-only physical-host Agent
|
|
13119
|
+
After=network-online.target ${config.hostTelemetryUnit} forgezero-metal-helper.service forgezero-agent-update-helper.service forgezero-metal-agent-egress.service
|
|
13120
|
+
Wants=network-online.target ${config.hostTelemetryUnit}
|
|
13121
|
+
Requires=forgezero-metal-helper.service forgezero-agent-update-helper.service forgezero-metal-agent-egress.service
|
|
13122
|
+
BindsTo=forgezero-metal-agent-egress.service
|
|
13123
|
+
|
|
13124
|
+
[Service]
|
|
13125
|
+
Type=simple
|
|
13126
|
+
User=forgezero-metal
|
|
13127
|
+
Group=forgezero-metal
|
|
13128
|
+
SupplementaryGroups=forgezero-update
|
|
13129
|
+
LoadCredentialEncrypted=metal-agent-seed:${SEED_CREDENTIAL_PATH}
|
|
13130
|
+
Environment=FZ_SEED_CREDENTIAL=metal-agent-seed
|
|
13131
|
+
Environment=FZ_AGENT_ROLE=metal
|
|
13132
|
+
Environment=FZ_METAL_HOSTNAME=${config.metalHostname}
|
|
13133
|
+
Environment=FZ_API=${config.profile.apiUrl}
|
|
13134
|
+
Environment=FZ_METAL_HELPER_SOCKET=${HELPER_SOCKET}
|
|
13135
|
+
Environment=FZ_DRAIN_DEADLINE_MS=120000
|
|
13136
|
+
Environment=NODE_ENV=production
|
|
13137
|
+
Environment=OTEL_EXPORTER_OTLP_ENDPOINT=${config.hostTelemetryEndpoint}
|
|
13138
|
+
Environment=OTEL_SERVICE_NAME=forgezero-metal-agent
|
|
13139
|
+
ExecStart=${AGENT_PATH}
|
|
13140
|
+
Restart=on-failure
|
|
13141
|
+
RestartSec=5
|
|
13142
|
+
TimeoutStopSec=130s
|
|
13143
|
+
LimitCORE=0
|
|
13144
|
+
NoNewPrivileges=true
|
|
13145
|
+
PrivateTmp=true
|
|
13146
|
+
ProtectSystem=strict
|
|
13147
|
+
ProtectHome=true
|
|
13148
|
+
ProtectKernelTunables=true
|
|
13149
|
+
ProtectKernelModules=true
|
|
13150
|
+
ProtectControlGroups=true
|
|
13151
|
+
RestrictSUIDSGID=true
|
|
13152
|
+
LockPersonality=true
|
|
13153
|
+
${egress}
|
|
13154
|
+
|
|
13155
|
+
[Install]
|
|
13156
|
+
WantedBy=multi-user.target
|
|
13157
|
+
`
|
|
13158
|
+
};
|
|
13159
|
+
}
|
|
13160
|
+
var installAgentBinary = (source, version) => {
|
|
13161
|
+
validateAgentSourcePath(source);
|
|
13162
|
+
const release = `/opt/forgezero/agent/versions/${version}/dist`;
|
|
13163
|
+
mkdirSync6(release, { recursive: true, mode: 493 });
|
|
13164
|
+
copyFileSync(source, join8(release, "fz-agent.js"));
|
|
13165
|
+
chmodSync3(join8(release, "fz-agent.js"), 493);
|
|
13166
|
+
chownSync(join8(release, "fz-agent.js"), 0, 0);
|
|
13167
|
+
mkdirSync6("/opt/forgezero/agent", { recursive: true, mode: 493 });
|
|
13168
|
+
for (const [link, target] of [
|
|
13169
|
+
["/opt/forgezero/agent/current.next", `versions/${version}`],
|
|
13170
|
+
[AGENT_PATH, "/opt/forgezero/agent/current/dist/fz-agent.js"]
|
|
13171
|
+
]) {
|
|
13172
|
+
try {
|
|
13173
|
+
unlinkSync2(link);
|
|
13174
|
+
} catch {}
|
|
13175
|
+
symlinkSync(target, link);
|
|
13176
|
+
if (link.endsWith("current.next"))
|
|
13177
|
+
renameSync4(link, "/opt/forgezero/agent/current");
|
|
13178
|
+
}
|
|
13179
|
+
};
|
|
13180
|
+
var preflight = async (config, exec) => {
|
|
13181
|
+
await runChecked(exec, ["/usr/bin/systemctl", "is-active", "--quiet", config.hostTelemetryUnit]);
|
|
13182
|
+
await runChecked(exec, [
|
|
13183
|
+
"/usr/bin/curl",
|
|
13184
|
+
"--silent",
|
|
13185
|
+
"--show-error",
|
|
13186
|
+
"--fail",
|
|
13187
|
+
"--max-time",
|
|
13188
|
+
"5",
|
|
13189
|
+
"--request",
|
|
13190
|
+
"POST",
|
|
13191
|
+
"--header",
|
|
13192
|
+
"Content-Type: application/json",
|
|
13193
|
+
"--data-binary",
|
|
13194
|
+
"{}",
|
|
13195
|
+
`${config.hostTelemetryEndpoint}/v1/metrics`
|
|
13196
|
+
]);
|
|
13197
|
+
await runChecked(exec, ["/usr/sbin/vgs", config.profile.volumeGroup]);
|
|
13198
|
+
await runChecked(exec, ["/usr/sbin/ip", "link", "show", config.profile.bridge]);
|
|
13199
|
+
if (!existsSync5("/dev/kvm"))
|
|
13200
|
+
throw new MetalBootstrapError("/dev/kvm is required");
|
|
13201
|
+
if (config.profile.confidential) {
|
|
13202
|
+
if (!existsSync5("/dev/sev"))
|
|
13203
|
+
throw new MetalBootstrapError("/dev/sev is required by the confidential profile");
|
|
13204
|
+
await runChecked(exec, ["/usr/bin/qemu-system-x86_64", "-object", "sev-snp-guest,help"]);
|
|
13205
|
+
}
|
|
13206
|
+
const digest = (await runChecked(exec, ["/usr/bin/sha256sum", config.profile.images[Object.keys(config.profile.images)[0]].path])).stdout.split(/\s+/)[0];
|
|
13207
|
+
if (digest !== config.profile.images[Object.keys(config.profile.images)[0]].sha256)
|
|
13208
|
+
throw new MetalBootstrapError("pinned guest image digest mismatch");
|
|
13209
|
+
};
|
|
13210
|
+
var assertSupportedMetalHost = () => {
|
|
13211
|
+
if (process.platform !== "linux" || process.arch !== "x64") {
|
|
13212
|
+
throw new MetalBootstrapError("metal bootstrap supports only Ubuntu 26.04 x86_64 hosts");
|
|
13213
|
+
}
|
|
13214
|
+
const release = readFileSync6("/etc/os-release", "utf8");
|
|
13215
|
+
if (!/^ID=ubuntu$/m.test(release) || !/^VERSION_ID="?26\.04"?$/m.test(release)) {
|
|
13216
|
+
throw new MetalBootstrapError("metal bootstrap supports only Ubuntu 26.04 x86_64 hosts");
|
|
13217
|
+
}
|
|
13218
|
+
};
|
|
13219
|
+
var ensurePinnedGuestImage = async (config, exec) => {
|
|
13220
|
+
const image = config.profile.images[SUPPORTED_GUEST_IMAGE.key];
|
|
13221
|
+
if (existsSync5(image.path))
|
|
13222
|
+
return;
|
|
13223
|
+
mkdirSync6(dirname7(image.path), { recursive: true, mode: 493 });
|
|
13224
|
+
const temporary = `${image.path}.next-${process.pid}`;
|
|
13225
|
+
try {
|
|
13226
|
+
await runChecked(exec, [
|
|
13227
|
+
"/usr/bin/curl",
|
|
13228
|
+
"--fail",
|
|
13229
|
+
"--location",
|
|
13230
|
+
"--proto",
|
|
13231
|
+
"=https",
|
|
13232
|
+
"--tlsv1.2",
|
|
13233
|
+
"--output",
|
|
13234
|
+
temporary,
|
|
13235
|
+
SUPPORTED_GUEST_IMAGE.url
|
|
13236
|
+
]);
|
|
13237
|
+
const digest = (await runChecked(exec, ["/usr/bin/sha256sum", temporary])).stdout.split(/\s+/)[0];
|
|
13238
|
+
if (digest !== image.sha256)
|
|
13239
|
+
throw new MetalBootstrapError("downloaded guest image digest mismatch");
|
|
13240
|
+
chmodSync3(temporary, 292);
|
|
13241
|
+
chownSync(temporary, 0, 0);
|
|
13242
|
+
renameSync4(temporary, image.path);
|
|
13243
|
+
} catch (cause) {
|
|
13244
|
+
try {
|
|
13245
|
+
unlinkSync2(temporary);
|
|
13246
|
+
} catch {}
|
|
13247
|
+
throw cause;
|
|
13248
|
+
}
|
|
13249
|
+
};
|
|
13250
|
+
async function applyMetalBootstrap(config, options) {
|
|
13251
|
+
validateMetalBootstrapConfig(config);
|
|
13252
|
+
if ((options.getuid ?? process.getuid)?.() !== 0)
|
|
13253
|
+
throw new MetalBootstrapError("fz bootstrap metal --apply must run as root");
|
|
13254
|
+
assertSupportedMetalHost();
|
|
13255
|
+
if (existsSync5(STATE_PATH2) && !options.repair)
|
|
13256
|
+
throw new MetalBootstrapError("metal host is already initialized; use explicit repair");
|
|
13257
|
+
if (config.agentSeedFile)
|
|
13258
|
+
validateOwnerOnlyPath(config.agentSeedFile, true);
|
|
13259
|
+
if (config.agentSeedFile && existsSync5(SEED_CREDENTIAL_PATH)) {
|
|
13260
|
+
throw new MetalBootstrapError("repair refuses replacement seed material while the sealed metal identity exists");
|
|
13261
|
+
}
|
|
13262
|
+
validateAgentSourcePath(options.agentSourcePath);
|
|
13263
|
+
const exec = options.exec ?? defaultExec2;
|
|
13264
|
+
await runChecked(exec, ["/usr/bin/apt-get", "update"]);
|
|
13265
|
+
await runChecked(exec, [
|
|
13266
|
+
"/usr/bin/apt-get",
|
|
13267
|
+
"install",
|
|
13268
|
+
"-y",
|
|
13269
|
+
"--no-install-recommends",
|
|
13270
|
+
"qemu-system-x86",
|
|
13271
|
+
"qemu-utils",
|
|
13272
|
+
"cloud-image-utils",
|
|
13273
|
+
"lvm2",
|
|
13274
|
+
"nftables",
|
|
13275
|
+
"curl"
|
|
13276
|
+
]);
|
|
13277
|
+
await ensurePinnedGuestImage(config, exec);
|
|
13278
|
+
await preflight(config, exec);
|
|
13279
|
+
await ensureAccount(exec);
|
|
13280
|
+
installAgentBinary(options.agentSourcePath, config.profile.agentVersion);
|
|
13281
|
+
mkdirSync6("/etc/forgezero/creds", { recursive: true, mode: 448 });
|
|
13282
|
+
mkdirSync6(config.profile.stateDir, { recursive: true, mode: 448 });
|
|
13283
|
+
mkdirSync6(config.profile.seedDir, { recursive: true, mode: 448 });
|
|
13284
|
+
const persistedProfile = {
|
|
13285
|
+
...config.profile,
|
|
13286
|
+
metalHostname: config.metalHostname,
|
|
13287
|
+
hostTelemetryEndpoint: config.hostTelemetryEndpoint,
|
|
13288
|
+
hostTelemetryUnit: config.hostTelemetryUnit
|
|
13289
|
+
};
|
|
13290
|
+
atomicWrite2(PROFILE_PATH, `${JSON.stringify(persistedProfile, null, 2)}
|
|
13291
|
+
`, 384);
|
|
13292
|
+
if (!existsSync5(SEED_CREDENTIAL_PATH)) {
|
|
13293
|
+
const seed = config.agentSeedFile ? readFileSync6(config.agentSeedFile, "utf8").trim() : randomBytes6(32).toString("base64url");
|
|
13294
|
+
if (seed.length < 32 || /[\0\r\n]/.test(seed))
|
|
13295
|
+
throw new MetalBootstrapError("metal Agent seed is invalid");
|
|
13296
|
+
await runChecked(exec, ["/usr/bin/systemd-creds", "encrypt", "--name=metal-agent-seed", "-", SEED_CREDENTIAL_PATH], `${seed}
|
|
13297
|
+
`);
|
|
13298
|
+
chmodSync3(SEED_CREDENTIAL_PATH, 256);
|
|
13299
|
+
chownSync(SEED_CREDENTIAL_PATH, 0, 0);
|
|
13300
|
+
if (config.agentSeedFile)
|
|
13301
|
+
unlinkSync2(config.agentSeedFile);
|
|
13302
|
+
}
|
|
13303
|
+
await applyMetalIsolation(config.profile, (argv) => exec(argv));
|
|
13304
|
+
for (const [unit, body] of Object.entries(renderMetalUnits(config)))
|
|
13305
|
+
atomicWrite2(join8(UNIT_DIRECTORY, unit), body, 420);
|
|
13306
|
+
await runChecked(exec, ["/usr/bin/systemctl", "daemon-reload"]);
|
|
13307
|
+
await runChecked(exec, [
|
|
13308
|
+
"/usr/bin/systemctl",
|
|
13309
|
+
"enable",
|
|
13310
|
+
"--now",
|
|
13311
|
+
"forgezero-agent-update-helper.service",
|
|
13312
|
+
"forgezero-metal-helper.service",
|
|
13313
|
+
"forgezero-metal-agent-egress.service",
|
|
13314
|
+
"forgezero-metal-agent.service"
|
|
13315
|
+
]);
|
|
13316
|
+
for (let attempt = 0;attempt < 100 && (!socketReady(HELPER_SOCKET) || !socketReady(UPDATE_SOCKET)); attempt += 1) {
|
|
13317
|
+
await Bun.sleep(100);
|
|
13318
|
+
}
|
|
13319
|
+
const status = await metalBootstrapStatus(exec);
|
|
13320
|
+
const operationalProblems = options.repair ? status.problems.filter((problem) => problem !== "metal initialized state does not bind the current profile") : status.problems;
|
|
13321
|
+
if (operationalProblems.length)
|
|
13322
|
+
throw new MetalBootstrapError(`metal bootstrap verification failed: ${operationalProblems.join("; ")}`);
|
|
13323
|
+
const identity = await runChecked(exec, [
|
|
13324
|
+
"/usr/bin/systemd-run",
|
|
13325
|
+
"--pipe",
|
|
13326
|
+
"--wait",
|
|
13327
|
+
"--quiet",
|
|
13328
|
+
"--collect",
|
|
13329
|
+
`--property=LoadCredentialEncrypted=metal-agent-seed:${SEED_CREDENTIAL_PATH}`,
|
|
13330
|
+
"--setenv=FZ_SEED_CREDENTIAL=metal-agent-seed",
|
|
13331
|
+
AGENT_PATH,
|
|
13332
|
+
"identity"
|
|
13333
|
+
]);
|
|
13334
|
+
const publicIdentity = identity.stdout.trim();
|
|
13335
|
+
if (!publicIdentity || /[\r\n]/.test(publicIdentity))
|
|
13336
|
+
throw new MetalBootstrapError("metal public identity proof was invalid");
|
|
13337
|
+
const state = {
|
|
13338
|
+
initializedAt: new Date().toISOString(),
|
|
13339
|
+
role: "metal",
|
|
13340
|
+
metalHostname: config.metalHostname,
|
|
13341
|
+
profileSha256: createHash3("sha256").update(JSON.stringify(config.profile)).digest("hex")
|
|
13342
|
+
};
|
|
13343
|
+
atomicWrite2(STATE_PATH2, `${JSON.stringify(state, null, 2)}
|
|
13344
|
+
`, 384);
|
|
13345
|
+
return { ...status, initialized: true, problems: [], metalHostname: config.metalHostname, publicIdentity };
|
|
13346
|
+
}
|
|
13347
|
+
var socketReady = (path) => {
|
|
13348
|
+
try {
|
|
13349
|
+
return lstatSync3(path).isSocket();
|
|
13350
|
+
} catch {
|
|
13351
|
+
return false;
|
|
13352
|
+
}
|
|
13353
|
+
};
|
|
13354
|
+
async function metalBootstrapStatus(exec = defaultExec2) {
|
|
13355
|
+
const problems = [];
|
|
13356
|
+
let profileValid = false, imageVerified = false, metalHostname, profileSha256;
|
|
13357
|
+
let profileMode = null;
|
|
13358
|
+
if (existsSync5(PROFILE_PATH)) {
|
|
13359
|
+
try {
|
|
13360
|
+
const metadata = statSync(PROFILE_PATH);
|
|
13361
|
+
profileMode = metadata.mode & 511;
|
|
13362
|
+
if (profileMode !== 384 || metadata.uid !== 0)
|
|
13363
|
+
problems.push("metal profile is not root-owned mode 0600");
|
|
13364
|
+
const persisted = JSON.parse(readFileSync6(PROFILE_PATH, "utf8"));
|
|
13365
|
+
const { metalHostname: profileHostname, hostTelemetryEndpoint, hostTelemetryUnit, ...profile } = persisted;
|
|
13366
|
+
validateMetalProfile(profile);
|
|
13367
|
+
profileValid = true;
|
|
13368
|
+
profileSha256 = createHash3("sha256").update(JSON.stringify(profile)).digest("hex");
|
|
13369
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9.-]{1,252}$/.test(profileHostname) || hostTelemetryEndpoint !== "http://127.0.0.1:4318") {
|
|
13370
|
+
problems.push("persisted metal host coordinates are invalid");
|
|
13371
|
+
}
|
|
13372
|
+
try {
|
|
13373
|
+
validUnit(hostTelemetryUnit);
|
|
13374
|
+
} catch {
|
|
13375
|
+
problems.push("persisted metal OTLP collector unit is invalid");
|
|
13376
|
+
}
|
|
13377
|
+
if ((await exec(["/usr/bin/systemctl", "is-active", "--quiet", hostTelemetryUnit])).exitCode !== 0) {
|
|
13378
|
+
problems.push(`${hostTelemetryUnit} is inactive`);
|
|
13379
|
+
} else if ((await exec([
|
|
13380
|
+
"/usr/bin/curl",
|
|
13381
|
+
"--silent",
|
|
13382
|
+
"--show-error",
|
|
13383
|
+
"--fail",
|
|
13384
|
+
"--max-time",
|
|
13385
|
+
"5",
|
|
13386
|
+
"--request",
|
|
13387
|
+
"POST",
|
|
13388
|
+
"--header",
|
|
13389
|
+
"Content-Type: application/json",
|
|
13390
|
+
"--data-binary",
|
|
13391
|
+
"{}",
|
|
13392
|
+
`${hostTelemetryEndpoint}/v1/metrics`
|
|
13393
|
+
])).exitCode !== 0) {
|
|
13394
|
+
problems.push("local OTLP metrics receiver did not accept a proof request");
|
|
13395
|
+
}
|
|
13396
|
+
const image = profile.images[Object.keys(profile.images)[0]];
|
|
13397
|
+
if (existsSync5(image.path)) {
|
|
13398
|
+
const digest = (await exec(["/usr/bin/sha256sum", image.path])).stdout.split(/\s+/)[0];
|
|
13399
|
+
imageVerified = digest === image.sha256;
|
|
13400
|
+
}
|
|
13401
|
+
if (!imageVerified)
|
|
13402
|
+
problems.push("pinned guest image is absent or has the wrong digest");
|
|
13403
|
+
} catch (cause) {
|
|
13404
|
+
problems.push(`metal profile invalid: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
13405
|
+
}
|
|
13406
|
+
} else
|
|
13407
|
+
problems.push("metal profile is missing");
|
|
13408
|
+
if (existsSync5(STATE_PATH2)) {
|
|
13409
|
+
try {
|
|
13410
|
+
const state = JSON.parse(readFileSync6(STATE_PATH2, "utf8"));
|
|
13411
|
+
metalHostname = state.metalHostname;
|
|
13412
|
+
if (state.role !== "metal" || !metalHostname || state.profileSha256 !== profileSha256) {
|
|
13413
|
+
problems.push("metal initialized state does not bind the current profile");
|
|
13414
|
+
}
|
|
13415
|
+
} catch {
|
|
13416
|
+
problems.push("metal initialized state is invalid");
|
|
13417
|
+
}
|
|
13418
|
+
}
|
|
13419
|
+
const units = {};
|
|
13420
|
+
for (const unit of [
|
|
13421
|
+
"forgezero-metal-helper.service",
|
|
13422
|
+
"forgezero-agent-update-helper.service",
|
|
13423
|
+
"forgezero-metal-agent-egress.service",
|
|
13424
|
+
"forgezero-metal-agent.service"
|
|
13425
|
+
]) {
|
|
13426
|
+
if (!existsSync5(join8(UNIT_DIRECTORY, unit)))
|
|
13427
|
+
units[unit] = "missing";
|
|
13428
|
+
else
|
|
13429
|
+
units[unit] = (await exec(["/usr/bin/systemctl", "is-active", "--quiet", unit])).exitCode === 0 ? "active" : "inactive";
|
|
13430
|
+
if (units[unit] !== "active")
|
|
13431
|
+
problems.push(`${unit} is ${units[unit]}`);
|
|
13432
|
+
}
|
|
13433
|
+
const helperSocketReady = socketReady(HELPER_SOCKET);
|
|
13434
|
+
const updateSocketReady = socketReady(UPDATE_SOCKET);
|
|
13435
|
+
if (!helperSocketReady)
|
|
13436
|
+
problems.push("metal helper socket is not ready");
|
|
13437
|
+
if (!updateSocketReady)
|
|
13438
|
+
problems.push("Agent update helper socket is not ready");
|
|
13439
|
+
return {
|
|
13440
|
+
initialized: existsSync5(STATE_PATH2),
|
|
13441
|
+
profileValid,
|
|
13442
|
+
profileMode,
|
|
13443
|
+
imageVerified,
|
|
13444
|
+
units,
|
|
13445
|
+
helperSocketReady,
|
|
13446
|
+
updateSocketReady,
|
|
13447
|
+
metalHostname,
|
|
13448
|
+
problems
|
|
13449
|
+
};
|
|
13450
|
+
}
|
|
13451
|
+
|
|
13452
|
+
// src/cli/index.ts
|
|
13453
|
+
var RECOMMENDED_MODE = (THRESHOLD_MODES.find((mode) => mode.recommended) ?? THRESHOLD_MODES[0]).id;
|
|
13454
|
+
var DEFAULT_MODE = RECOMMENDED_MODE;
|
|
13455
|
+
var PACKAGED_AGENT_BIN2 = fileURLToPath2(new URL("./fz-agent.js", import.meta.url));
|
|
13456
|
+
function parseOptions(argv) {
|
|
13457
|
+
const options = {
|
|
13458
|
+
api: process.env.FZ_API ?? "http://localhost:8787",
|
|
13459
|
+
apiExplicit: Boolean(process.env.FZ_API),
|
|
13460
|
+
app: process.env.FZ_APP,
|
|
13461
|
+
realm: process.env.FZ_REALM ?? "platform",
|
|
13462
|
+
realmExplicit: Boolean(process.env.FZ_REALM),
|
|
13463
|
+
json: false,
|
|
13464
|
+
apply: false,
|
|
13465
|
+
enrol: false,
|
|
13466
|
+
socket: process.env.SSH_AUTH_SOCK,
|
|
13467
|
+
mode: DEFAULT_MODE,
|
|
13468
|
+
user: process.env.FZ_USER ?? "operator",
|
|
13469
|
+
userExplicit: Boolean(process.env.FZ_USER),
|
|
13470
|
+
phraseStdin: false,
|
|
13471
|
+
preserveEnv: false,
|
|
13472
|
+
projectRoot: process.cwd(),
|
|
13473
|
+
deployProfile: "app",
|
|
13474
|
+
deploySoftware: [],
|
|
13475
|
+
deployChannel: "production",
|
|
13476
|
+
requireAttestation: false,
|
|
13477
|
+
force: false,
|
|
13478
|
+
noBrowser: false,
|
|
13479
|
+
provider: "github",
|
|
13480
|
+
branch: "main",
|
|
13481
|
+
authMode: "public",
|
|
13482
|
+
targets: [],
|
|
13483
|
+
wait: false,
|
|
13484
|
+
timeoutSeconds: 900,
|
|
13485
|
+
queries: []
|
|
13486
|
+
};
|
|
13487
|
+
const positional = [];
|
|
13488
|
+
for (let index = 0;index < argv.length; index += 1) {
|
|
13489
|
+
const token = argv[index];
|
|
13490
|
+
if (token === "--api") {
|
|
13491
|
+
options.api = argv[++index] ?? options.api;
|
|
13492
|
+
options.apiExplicit = true;
|
|
13493
|
+
} else if (token === "--app")
|
|
13494
|
+
options.app = argv[++index];
|
|
13495
|
+
else if (token === "--realm") {
|
|
13496
|
+
options.realm = argv[++index] ?? options.realm;
|
|
13497
|
+
options.realmExplicit = true;
|
|
13498
|
+
} else if (token === "--socket")
|
|
13499
|
+
options.socket = argv[++index];
|
|
13500
|
+
else if (token === "--json")
|
|
13501
|
+
options.json = true;
|
|
13502
|
+
else if (token === "--apply")
|
|
13503
|
+
options.apply = true;
|
|
13504
|
+
else if (token === "--enrol")
|
|
13505
|
+
options.enrol = true;
|
|
13506
|
+
else if (token === "--preserve-env")
|
|
13507
|
+
options.preserveEnv = true;
|
|
13508
|
+
else if (token === "--root")
|
|
13509
|
+
options.projectRoot = argv[++index] ?? options.projectRoot;
|
|
13510
|
+
else if (token === "--name")
|
|
13511
|
+
options.projectName = argv[++index];
|
|
13512
|
+
else if (token === "--purpose")
|
|
13513
|
+
options.projectPurpose = argv[++index];
|
|
13514
|
+
else if (token === "--profile")
|
|
13515
|
+
options.deployProfile = argv[++index] ?? options.deployProfile;
|
|
13516
|
+
else if (token === "--software")
|
|
13517
|
+
options.deploySoftware.push(argv[++index] ?? "");
|
|
13518
|
+
else if (token === "--channel") {
|
|
13519
|
+
const channel = argv[++index];
|
|
13520
|
+
if (channel === "development" || channel === "production")
|
|
13521
|
+
options.deployChannel = channel;
|
|
13522
|
+
else
|
|
13523
|
+
options.optionError = "--channel must be production or development.";
|
|
13524
|
+
} else if (token === "--attestation")
|
|
13525
|
+
options.requireAttestation = true;
|
|
13526
|
+
else if (token === "--force")
|
|
13527
|
+
options.force = true;
|
|
13528
|
+
else if (token === "--no-browser")
|
|
13529
|
+
options.noBrowser = true;
|
|
13530
|
+
else if (token === "--project")
|
|
13531
|
+
options.projectKey = argv[++index];
|
|
13532
|
+
else if (token === "--pipeline")
|
|
13533
|
+
options.pipelineKey = argv[++index];
|
|
13534
|
+
else if (token === "--revision")
|
|
13535
|
+
options.revision = argv[++index];
|
|
13536
|
+
else if (token === "--label")
|
|
13537
|
+
options.label = argv[++index];
|
|
13538
|
+
else if (token === "--provider") {
|
|
13539
|
+
const value = argv[++index];
|
|
13540
|
+
if (value === "github" || value === "gitlab" || value === "generic")
|
|
13541
|
+
options.provider = value;
|
|
13542
|
+
else
|
|
13543
|
+
options.optionError = "--provider must be github, gitlab, or generic.";
|
|
13544
|
+
} else if (token === "--repository")
|
|
13545
|
+
options.repository = argv[++index];
|
|
13546
|
+
else if (token === "--branch")
|
|
13547
|
+
options.branch = argv[++index] ?? options.branch;
|
|
13548
|
+
else if (token === "--clone-url")
|
|
13549
|
+
options.cloneUrl = argv[++index];
|
|
13550
|
+
else if (token === "--auth") {
|
|
13551
|
+
const value = argv[++index];
|
|
13552
|
+
if (value === "public" || value === "node-ssh" || value === "vault-token")
|
|
13553
|
+
options.authMode = value;
|
|
13554
|
+
else
|
|
13555
|
+
options.optionError = "--auth must be public, node-ssh, or vault-token.";
|
|
13556
|
+
} else if (token === "--git-secret")
|
|
13557
|
+
options.gitSecret = argv[++index];
|
|
13558
|
+
else if (token === "--git-username")
|
|
13559
|
+
options.gitUsername = argv[++index];
|
|
13560
|
+
else if (token === "--known-hosts")
|
|
13561
|
+
options.knownHostsFile = argv[++index];
|
|
13562
|
+
else if (token === "--target")
|
|
13563
|
+
options.targets.push(argv[++index] ?? "");
|
|
13564
|
+
else if (token === "--target-key")
|
|
13565
|
+
options.targetKey = argv[++index];
|
|
13566
|
+
else if (token === "--wait")
|
|
13567
|
+
options.wait = true;
|
|
13568
|
+
else if (token === "--timeout") {
|
|
13569
|
+
const value = Number(argv[++index] ?? "");
|
|
13570
|
+
if (Number.isFinite(value) && value > 0 && value <= 86400)
|
|
13571
|
+
options.timeoutSeconds = value;
|
|
13572
|
+
else
|
|
13573
|
+
options.optionError = "--timeout must be between 1 and 86400 seconds.";
|
|
13574
|
+
} else if (token === "--data")
|
|
13575
|
+
options.data = argv[++index];
|
|
13576
|
+
else if (token === "--data-file")
|
|
13577
|
+
options.dataFile = argv[++index];
|
|
13578
|
+
else if (token === "--bootstrap-config")
|
|
13579
|
+
options.bootstrapConfigPath = argv[++index];
|
|
13580
|
+
else if (token === "--query")
|
|
13581
|
+
options.queries.push(argv[++index] ?? "");
|
|
13582
|
+
else if (token === "--key")
|
|
13583
|
+
options.key = argv[++index];
|
|
13584
|
+
else if (token === "--mode")
|
|
13585
|
+
options.mode = argv[++index] ?? options.mode;
|
|
13586
|
+
else if (token === "--user") {
|
|
13587
|
+
options.user = argv[++index] ?? options.user;
|
|
13588
|
+
options.userExplicit = true;
|
|
13589
|
+
} else if (token === "--token-file")
|
|
13590
|
+
options.tokenFile = argv[++index];
|
|
13591
|
+
else if (token === "--phrase-file")
|
|
13592
|
+
options.phraseFile = argv[++index];
|
|
13593
|
+
else if (token === "--phrase-stdin")
|
|
13594
|
+
options.phraseStdin = true;
|
|
13595
|
+
else
|
|
13596
|
+
positional.push(token);
|
|
13597
|
+
}
|
|
13598
|
+
return { command: positional[0] ?? "help", args: positional.slice(1), options };
|
|
13599
|
+
}
|
|
13600
|
+
var out = {
|
|
13601
|
+
line: (text3 = "") => process.stdout.write(`${text3}
|
|
13602
|
+
`),
|
|
13603
|
+
step: (text3) => process.stdout.write(` ${text3}
|
|
13604
|
+
`),
|
|
13605
|
+
warn: (text3) => process.stderr.write(` ! ${text3}
|
|
13606
|
+
`),
|
|
13607
|
+
fail: (text3) => process.stderr.write(` \u2717 ${text3}
|
|
13608
|
+
`),
|
|
13609
|
+
ok: (text3) => process.stdout.write(` \u2713 ${text3}
|
|
13610
|
+
`)
|
|
13611
|
+
};
|
|
13612
|
+
var sessionCookie = null;
|
|
13613
|
+
var storedSession = null;
|
|
13614
|
+
function captureSession(response) {
|
|
13615
|
+
const setCookie = response.headers.get("set-cookie");
|
|
13616
|
+
if (!setCookie)
|
|
13617
|
+
return;
|
|
13618
|
+
const cookie = setCookie.split(";")[0];
|
|
13619
|
+
sessionCookie = cookie;
|
|
13620
|
+
if (storedSession && (cookie !== storedSession.cookie || Date.now() - storedSession.lastUsedAtTs >= 60000)) {
|
|
13621
|
+
storedSession = { ...storedSession, cookie, lastUsedAtTs: Date.now() };
|
|
13622
|
+
saveSession(storedSession);
|
|
13623
|
+
}
|
|
13624
|
+
}
|
|
13625
|
+
function requestHeaders(apiBase, cookie) {
|
|
13626
|
+
return {
|
|
13627
|
+
"content-type": "application/json",
|
|
13628
|
+
origin: new URL(apiBase).origin,
|
|
13629
|
+
...cookie ? { cookie } : {}
|
|
13630
|
+
};
|
|
13631
|
+
}
|
|
13632
|
+
async function api(options, path, init) {
|
|
13633
|
+
const base = options.realm === "platform" ? "/api" : `/api/t/${encodeURIComponent(options.realm)}`;
|
|
13634
|
+
const response = await fetch(`${options.api}${base}${path}`, {
|
|
13635
|
+
method: init?.method ?? "GET",
|
|
13636
|
+
headers: { ...requestHeaders(options.api, sessionCookie), "user-agent": `forgezero-cli/${VERSION2}` },
|
|
13637
|
+
body: init?.body === undefined ? undefined : JSON.stringify(init.body)
|
|
13638
|
+
});
|
|
13639
|
+
captureSession(response);
|
|
13640
|
+
let body = null;
|
|
13641
|
+
try {
|
|
13642
|
+
body = await response.json();
|
|
13643
|
+
} catch {
|
|
13644
|
+
body = null;
|
|
13645
|
+
}
|
|
13646
|
+
const challenge = body?.security;
|
|
13647
|
+
if (response.status === 428 && challenge?.scope === "action" && challenge.requestKey && custodyIdentity && !path.startsWith("/security/step-up/")) {
|
|
13648
|
+
const proved = await proveWithAgent(options, challenge.requestKey);
|
|
13649
|
+
if (proved) {
|
|
13650
|
+
const replay = await fetch(`${options.api}${base}${path}`, {
|
|
13651
|
+
method: init?.method ?? "GET",
|
|
13652
|
+
headers: {
|
|
13653
|
+
...requestHeaders(options.api, sessionCookie),
|
|
13654
|
+
"x-security-request-key": challenge.requestKey
|
|
13655
|
+
},
|
|
13656
|
+
body: init?.body === undefined ? undefined : JSON.stringify(init.body)
|
|
13657
|
+
});
|
|
13658
|
+
captureSession(replay);
|
|
13659
|
+
let replayed = null;
|
|
13660
|
+
try {
|
|
13661
|
+
replayed = await replay.json();
|
|
13662
|
+
} catch {
|
|
13663
|
+
replayed = null;
|
|
13664
|
+
}
|
|
13665
|
+
return { status: replay.status, body: replayed };
|
|
13666
|
+
}
|
|
13667
|
+
}
|
|
13668
|
+
if (response.status === 428 && challenge?.scope === "action" && challenge.requestKey && storedSession && !path.startsWith("/security/step-up/")) {
|
|
13669
|
+
const proved = await proveWithBrowser(options, challenge.requestKey, base);
|
|
13670
|
+
if (proved) {
|
|
13671
|
+
const replay = await fetch(`${options.api}${base}${path}`, {
|
|
13672
|
+
method: init?.method ?? "GET",
|
|
13673
|
+
headers: {
|
|
13674
|
+
...requestHeaders(options.api, sessionCookie),
|
|
13675
|
+
"user-agent": `forgezero-cli/${VERSION2}`,
|
|
13676
|
+
"x-security-request-key": challenge.requestKey
|
|
13677
|
+
},
|
|
13678
|
+
body: init?.body === undefined ? undefined : JSON.stringify(init.body)
|
|
13679
|
+
});
|
|
13680
|
+
captureSession(replay);
|
|
13681
|
+
let replayed = null;
|
|
13682
|
+
try {
|
|
13683
|
+
replayed = await replay.json();
|
|
13684
|
+
} catch {}
|
|
13685
|
+
return { status: replay.status, body: replayed };
|
|
13686
|
+
}
|
|
13687
|
+
}
|
|
13688
|
+
if (response.status === 401 && storedSession) {
|
|
13689
|
+
removeSession(storedSession.api, storedSession.realm);
|
|
13690
|
+
storedSession = null;
|
|
13691
|
+
sessionCookie = null;
|
|
13692
|
+
}
|
|
13693
|
+
return { status: response.status, body };
|
|
13694
|
+
}
|
|
13695
|
+
function sleep(ms) {
|
|
13696
|
+
return new Promise((resolve5) => setTimeout(resolve5, ms));
|
|
10336
13697
|
}
|
|
10337
13698
|
function browserCommand(url) {
|
|
10338
13699
|
if (process.platform === "darwin")
|
|
@@ -10438,7 +13799,7 @@ function requireSession() {
|
|
|
10438
13799
|
function readOwnerOnlySecret(path, label) {
|
|
10439
13800
|
let metadata;
|
|
10440
13801
|
try {
|
|
10441
|
-
metadata =
|
|
13802
|
+
metadata = lstatSync4(path);
|
|
10442
13803
|
} catch {
|
|
10443
13804
|
throw new Error(`The ${label} file ${path} is unreadable.`);
|
|
10444
13805
|
}
|
|
@@ -10452,7 +13813,7 @@ function readOwnerOnlySecret(path, label) {
|
|
|
10452
13813
|
if (uid !== undefined && uid !== 0 && metadata.uid !== uid) {
|
|
10453
13814
|
throw new Error(`The ${label} file ${path} is not owned by the current user.`);
|
|
10454
13815
|
}
|
|
10455
|
-
const value =
|
|
13816
|
+
const value = readFileSync7(path, "utf8").trim();
|
|
10456
13817
|
if (!value)
|
|
10457
13818
|
throw new Error(`The ${label} file ${path} is empty.`);
|
|
10458
13819
|
return value;
|
|
@@ -10625,7 +13986,7 @@ async function cmdApi(options, args) {
|
|
|
10625
13986
|
let body = undefined;
|
|
10626
13987
|
if (options.data !== undefined && options.dataFile)
|
|
10627
13988
|
throw new Error("Use only one of --data or --data-file.");
|
|
10628
|
-
const encoded = options.data === "-" ? await Bun.stdin.text() : options.dataFile ?
|
|
13989
|
+
const encoded = options.data === "-" ? await Bun.stdin.text() : options.dataFile ? readFileSync7(options.dataFile, "utf8") : options.data;
|
|
10629
13990
|
if (encoded !== undefined)
|
|
10630
13991
|
body = JSON.parse(encoded);
|
|
10631
13992
|
const result = await api(options, `${url.pathname}${url.search}`, { method, body });
|
|
@@ -10750,7 +14111,7 @@ async function cmdAgent(options, args) {
|
|
|
10750
14111
|
deployRoot: process.env.FZ_DEPLOY_ROOT ?? "/opt/forgezero"
|
|
10751
14112
|
} : {},
|
|
10752
14113
|
binPath: process.env.FZ_AGENT_BIN ?? "/usr/local/lib/forgezero/agent/fz-agent",
|
|
10753
|
-
sourceBinPath: process.env.FZ_AGENT_SOURCE_BIN ??
|
|
14114
|
+
sourceBinPath: process.env.FZ_AGENT_SOURCE_BIN ?? PACKAGED_AGENT_BIN2,
|
|
10754
14115
|
user: process.env.FZ_AGENT_USER,
|
|
10755
14116
|
apiUrl: options.api,
|
|
10756
14117
|
project: process.env.FZ_PROJECT,
|
|
@@ -10774,17 +14135,17 @@ async function cmdAgent(options, args) {
|
|
|
10774
14135
|
return 0;
|
|
10775
14136
|
}
|
|
10776
14137
|
try {
|
|
10777
|
-
|
|
14138
|
+
writeFileSync7(plan.unitPath, plan.unit, { mode: 420 });
|
|
10778
14139
|
out.ok(`Wrote ${plan.unitPath}`);
|
|
10779
14140
|
for (const auxiliary of plan.auxiliaryUnits) {
|
|
10780
|
-
|
|
10781
|
-
|
|
14141
|
+
mkdirSync7(dirname8(auxiliary.path), { recursive: true, mode: 493 });
|
|
14142
|
+
writeFileSync7(auxiliary.path, auxiliary.unit, { mode: 420 });
|
|
10782
14143
|
out.ok(`Wrote ${auxiliary.path}`);
|
|
10783
14144
|
}
|
|
10784
14145
|
if (options.enrol) {
|
|
10785
|
-
if (
|
|
10786
|
-
const source =
|
|
10787
|
-
const token =
|
|
14146
|
+
if (existsSync6(enrolTokenSourcePath)) {
|
|
14147
|
+
const source = statSync2(enrolTokenSourcePath);
|
|
14148
|
+
const token = readFileSync7(enrolTokenSourcePath, "utf8").trim();
|
|
10788
14149
|
if (!source.isFile() || (source.mode & 511) !== 384 || source.uid !== 0) {
|
|
10789
14150
|
throw new Error("The preloaded enrolment token must be a root-owned 0600 file in /run.");
|
|
10790
14151
|
}
|
|
@@ -10797,7 +14158,7 @@ async function cmdAgent(options, args) {
|
|
|
10797
14158
|
if (await prompt.exited !== 0 || !/^fze_[A-Za-z0-9_-]{40,100}$/.test(token)) {
|
|
10798
14159
|
throw new Error("A valid fze_ enrolment token was not provided.");
|
|
10799
14160
|
}
|
|
10800
|
-
|
|
14161
|
+
writeFileSync7(enrolTokenSourcePath, `${token}
|
|
10801
14162
|
`, { mode: 384, flag: "wx" });
|
|
10802
14163
|
}
|
|
10803
14164
|
}
|
|
@@ -10808,13 +14169,13 @@ async function cmdAgent(options, args) {
|
|
|
10808
14169
|
out.line();
|
|
10809
14170
|
out.line(" Add this machine-specific PUBLIC key as a read-only deploy key:");
|
|
10810
14171
|
out.line();
|
|
10811
|
-
out.line(` ${
|
|
14172
|
+
out.line(` ${readFileSync7(gitPublicKeyPath, "utf8").trim()}`);
|
|
10812
14173
|
out.line();
|
|
10813
14174
|
return 0;
|
|
10814
14175
|
} catch (cause) {
|
|
10815
14176
|
if (options.enrol) {
|
|
10816
14177
|
try {
|
|
10817
|
-
|
|
14178
|
+
unlinkSync3(enrolTokenSourcePath);
|
|
10818
14179
|
} catch {}
|
|
10819
14180
|
}
|
|
10820
14181
|
out.fail(`Could not write ${plan.unitPath}: ${cause.message}`);
|
|
@@ -10822,6 +14183,203 @@ async function cmdAgent(options, args) {
|
|
|
10822
14183
|
return 1;
|
|
10823
14184
|
}
|
|
10824
14185
|
}
|
|
14186
|
+
var bootstrapAnswer = (question, fallback) => {
|
|
14187
|
+
const suffix = fallback ? ` [${fallback}]` : "";
|
|
14188
|
+
const value = globalThis.prompt(`${question}${suffix}:`)?.trim();
|
|
14189
|
+
if (value)
|
|
14190
|
+
return value;
|
|
14191
|
+
if (fallback !== undefined)
|
|
14192
|
+
return fallback;
|
|
14193
|
+
throw new Error(`${question} is required (or use --bootstrap-config).`);
|
|
14194
|
+
};
|
|
14195
|
+
function interactiveBootstrap(kind) {
|
|
14196
|
+
if (!process.stdin.isTTY)
|
|
14197
|
+
throw new Error("non-interactive bootstrap requires --bootstrap-config <private-json-file>");
|
|
14198
|
+
if (kind === "tenant") {
|
|
14199
|
+
const runnerKey = bootstrapAnswer("Bootstrap runner SSH private-key file (blank to disable)", "");
|
|
14200
|
+
return {
|
|
14201
|
+
kind,
|
|
14202
|
+
apiUrl: bootstrapAnswer("Platform API URL"),
|
|
14203
|
+
realm: bootstrapAnswer("Tenant realm slug"),
|
|
14204
|
+
nodeHostname: bootstrapAnswer("Node hostname", hostname()),
|
|
14205
|
+
telemetryEndpoint: bootstrapAnswer("Public HTTPS OTLP endpoint"),
|
|
14206
|
+
enrolTokenFile: bootstrapAnswer("Root-only enrolment token file"),
|
|
14207
|
+
profile: bootstrapAnswer("Deploy profile", "app"),
|
|
14208
|
+
...runnerKey ? { bootstrapRunner: {
|
|
14209
|
+
sshPrivateKeyFile: runnerKey,
|
|
14210
|
+
targetTelemetryEndpoint: bootstrapAnswer("Public HTTPS OTLP endpoint for bootstrapped targets")
|
|
14211
|
+
} } : {}
|
|
14212
|
+
};
|
|
14213
|
+
}
|
|
14214
|
+
const environment = bootstrapAnswer("Environment (production/development)", "production");
|
|
14215
|
+
const profile = bootstrapAnswer("Profile (platform-db-api/platform-api)", "platform-db-api");
|
|
14216
|
+
const role = profile === "platform-api" ? "none" : bootstrapAnswer("Database role (master/joiner)", "master");
|
|
14217
|
+
const serverMode = "default";
|
|
14218
|
+
const address = role === "none" ? undefined : bootstrapAnswer("Private database address");
|
|
14219
|
+
const master = role === "joiner" ? bootstrapAnswer("Master starter private address") : undefined;
|
|
14220
|
+
const coordinators = bootstrapAnswer("Writable Coordinator origins (comma-separated)").split(",").map((item) => item.trim()).filter(Boolean);
|
|
14221
|
+
const computeReference = bootstrapAnswer("API-owned compute reference", environment === "production" ? "fz-n1" : "dev-fz-n1");
|
|
14222
|
+
const nodeHostname = bootstrapAnswer("Public node hostname");
|
|
14223
|
+
const apiUrl = bootstrapAnswer("Platform API URL");
|
|
14224
|
+
const appOrigin = bootstrapAnswer("Public App origin");
|
|
14225
|
+
const repository = bootstrapAnswer("API Git repository");
|
|
14226
|
+
const branch = bootstrapAnswer("Deployment branch", environment === "production" ? "main" : "dev");
|
|
14227
|
+
const telemetryEndpoint = bootstrapAnswer("Public HTTPS Agent OTLP endpoint");
|
|
14228
|
+
const collectorUnit = bootstrapAnswer("Independent local OTLP collector unit", "otelcol.service");
|
|
14229
|
+
const replicationFactor = Number(bootstrapAnswer("Database replication factor", "3"));
|
|
14230
|
+
const writeConcern = Number(bootstrapAnswer("Database write concern", "2"));
|
|
14231
|
+
const seedPeers = bootstrapAnswer("Seed WebSocket peers (comma-separated)").split(",").map((item) => item.trim()).filter(Boolean);
|
|
14232
|
+
const smtpHost = bootstrapAnswer("Bootstrap SMTP host (blank to disable)", "");
|
|
14233
|
+
const smtpPort = smtpHost ? Number(bootstrapAnswer("Bootstrap SMTP port", "587")) : undefined;
|
|
14234
|
+
const smtpUser = smtpHost ? bootstrapAnswer("Bootstrap SMTP user (blank if none)", "") : undefined;
|
|
14235
|
+
const smtpFrom = smtpHost ? bootstrapAnswer("Bootstrap SMTP From address") : undefined;
|
|
14236
|
+
const smtpPassword = smtpHost ? bootstrapAnswer("Root-only SMTP password file") : undefined;
|
|
14237
|
+
const backupEndpoint = bootstrapAnswer("Backup S3 HTTPS endpoint (blank to disable)", "");
|
|
14238
|
+
const backupRegion = backupEndpoint ? bootstrapAnswer("Backup S3 region") : undefined;
|
|
14239
|
+
const backupBucket = backupEndpoint ? bootstrapAnswer("Backup S3 bucket") : undefined;
|
|
14240
|
+
const backupAccessKeyId = backupEndpoint ? bootstrapAnswer("Backup S3 access-key id") : undefined;
|
|
14241
|
+
const backupS3Secret = backupEndpoint ? bootstrapAnswer("Root-only backup S3 secret file") : undefined;
|
|
14242
|
+
const cloudflareCheckpoint = bootstrapAnswer("Completed Cloudflare bootstrap checkpoint (blank for no public edge)", "");
|
|
14243
|
+
const cloudflareNodeName = cloudflareCheckpoint ? bootstrapAnswer("Cloudflare checkpoint node name", computeReference) : undefined;
|
|
14244
|
+
const databaseNetworkMode = cloudflareCheckpoint ? bootstrapAnswer("Database network (private-lan/cloudflare-warp)", "private-lan") : "private-lan";
|
|
14245
|
+
const bootstrapSecretFile = bootstrapAnswer("Root-only shared cluster bootstrap-code file");
|
|
14246
|
+
const index = Number(computeReference.match(/n(\d+)$/)?.[1] ?? 0);
|
|
14247
|
+
const platformEnrolTokenFile = index > 3 ? bootstrapAnswer("Root-only API-issued platform enrolment-token file") : undefined;
|
|
14248
|
+
return {
|
|
14249
|
+
kind,
|
|
14250
|
+
environment,
|
|
14251
|
+
profile,
|
|
14252
|
+
computeReference,
|
|
14253
|
+
nodeHostname,
|
|
14254
|
+
apiUrl,
|
|
14255
|
+
repository,
|
|
14256
|
+
branch,
|
|
14257
|
+
telemetryEndpoint,
|
|
14258
|
+
database: {
|
|
14259
|
+
role,
|
|
14260
|
+
serverMode,
|
|
14261
|
+
address,
|
|
14262
|
+
master,
|
|
14263
|
+
coordinators,
|
|
14264
|
+
bootstrapSecretFile
|
|
14265
|
+
},
|
|
14266
|
+
platformEnrolTokenFile,
|
|
14267
|
+
runtime: {
|
|
14268
|
+
environment: {
|
|
14269
|
+
softwareProfile: profile,
|
|
14270
|
+
databaseRole: role,
|
|
14271
|
+
databaseCoordinators: coordinators,
|
|
14272
|
+
databaseAddress: address,
|
|
14273
|
+
databaseMaster: master,
|
|
14274
|
+
databaseNetworkMode,
|
|
14275
|
+
databaseReplicationFactor: replicationFactor,
|
|
14276
|
+
databaseWriteConcern: writeConcern,
|
|
14277
|
+
databaseUser: "forgezero-api",
|
|
14278
|
+
nodeHostname,
|
|
14279
|
+
nodeRegion: bootstrapAnswer("Published node region"),
|
|
14280
|
+
nodeRole: "guest",
|
|
14281
|
+
appOrigin,
|
|
14282
|
+
apiOrigin: apiUrl,
|
|
14283
|
+
publicApiPort: Number(bootstrapAnswer("Loopback public API port", "3000")),
|
|
14284
|
+
sharedDirectory: "/opt/forgezero/shared",
|
|
14285
|
+
seedSyncPeers: seedPeers,
|
|
14286
|
+
seedSyncMembers: Number(bootstrapAnswer("Seed quorum members", "3")),
|
|
14287
|
+
seedSyncEpoch: bootstrapAnswer("Seed epoch"),
|
|
14288
|
+
concurrencyLimit: Number(bootstrapAnswer("Initial concurrency limit", "128")),
|
|
14289
|
+
drainDeadlineMs: Number(bootstrapAnswer("Drain deadline milliseconds", "30000")),
|
|
14290
|
+
otlpEndpoint: "http://127.0.0.1:4318",
|
|
14291
|
+
otlpCollectorUnit: collectorUnit,
|
|
14292
|
+
agentOtlpEndpoint: telemetryEndpoint,
|
|
14293
|
+
otlpFlushIntervalMs: 1e4,
|
|
14294
|
+
otlpTraceSampleRatio: 0.1,
|
|
14295
|
+
custodianEmail: role === "master" ? bootstrapAnswer("First custodian email") : undefined,
|
|
14296
|
+
...smtpHost && smtpPort && smtpFrom ? { smtp: { host: smtpHost, port: smtpPort, user: smtpUser || undefined, from: smtpFrom } } : {},
|
|
14297
|
+
...backupEndpoint && backupRegion && backupBucket && backupAccessKeyId ? {
|
|
14298
|
+
backup: { endpoint: backupEndpoint, region: backupRegion, bucket: backupBucket, accessKeyId: backupAccessKeyId }
|
|
14299
|
+
} : {},
|
|
14300
|
+
repository,
|
|
14301
|
+
branch,
|
|
14302
|
+
deployProfile: environment
|
|
14303
|
+
},
|
|
14304
|
+
serviceUser: "forgezero-api",
|
|
14305
|
+
slotsDirectory: "/opt/forgezero/slots",
|
|
14306
|
+
bluePort: 3001,
|
|
14307
|
+
greenPort: 3002,
|
|
14308
|
+
healthPath: "/api/health",
|
|
14309
|
+
keepReleases: 5,
|
|
14310
|
+
credentialFiles: {
|
|
14311
|
+
smtpPassword,
|
|
14312
|
+
backupS3Secret
|
|
14313
|
+
}
|
|
14314
|
+
},
|
|
14315
|
+
firewall: {
|
|
14316
|
+
enabled: bootstrapAnswer("Enable host firewall? (yes/no)", "yes") === "yes",
|
|
14317
|
+
sshPort: Number(bootstrapAnswer("SSH port", "22")),
|
|
14318
|
+
privateCidrs: bootstrapAnswer("Private cluster CIDRs (comma-separated)").split(",").map((item) => item.trim()).filter(Boolean)
|
|
14319
|
+
},
|
|
14320
|
+
installCloudflared: cloudflareCheckpoint ? true : bootstrapAnswer("Install cloudflared binary only? (yes/no)", "no") === "yes",
|
|
14321
|
+
...cloudflareCheckpoint && cloudflareNodeName ? {
|
|
14322
|
+
cloudflareHandoff: { checkpointFile: cloudflareCheckpoint, nodeName: cloudflareNodeName }
|
|
14323
|
+
} : {}
|
|
14324
|
+
};
|
|
14325
|
+
}
|
|
14326
|
+
async function cmdBootstrap(options, args) {
|
|
14327
|
+
try {
|
|
14328
|
+
const operation = args[0] ?? "status";
|
|
14329
|
+
if (operation === "platform" && args[1] === "cloudflare") {
|
|
14330
|
+
if (!options.bootstrapConfigPath) {
|
|
14331
|
+
throw new Error("Cloudflare bootstrap requires --bootstrap-config <owner-only-cloudflare-json>");
|
|
14332
|
+
}
|
|
14333
|
+
await runCloudflareBootstrapCommand(options.bootstrapConfigPath, options.apply);
|
|
14334
|
+
return 0;
|
|
14335
|
+
}
|
|
14336
|
+
if (operation === "status") {
|
|
14337
|
+
if (existsSync6("/etc/forgezero/metal.initialized.json")) {
|
|
14338
|
+
const status2 = await metalBootstrapStatus();
|
|
14339
|
+
out.line(JSON.stringify(status2, null, 2));
|
|
14340
|
+
return status2.initialized && status2.problems.length === 0 ? 0 : 1;
|
|
14341
|
+
}
|
|
14342
|
+
const status = await bootstrapStatus();
|
|
14343
|
+
out.line(JSON.stringify(status, null, 2));
|
|
14344
|
+
return status.initialized ? 0 : 1;
|
|
14345
|
+
}
|
|
14346
|
+
if (operation === "metal") {
|
|
14347
|
+
if (!options.bootstrapConfigPath) {
|
|
14348
|
+
throw new Error("metal bootstrap requires --bootstrap-config <owner-only-metal-json>");
|
|
14349
|
+
}
|
|
14350
|
+
const config2 = readMetalBootstrapConfig(options.bootstrapConfigPath);
|
|
14351
|
+
if (!options.apply) {
|
|
14352
|
+
out.line(JSON.stringify(planMetalBootstrap(config2), null, 2));
|
|
14353
|
+
out.step("Review the plan, then repeat with --apply as root.");
|
|
14354
|
+
return 0;
|
|
14355
|
+
}
|
|
14356
|
+
const result2 = await applyMetalBootstrap(config2, { agentSourcePath: PACKAGED_AGENT_BIN2 });
|
|
14357
|
+
out.line(JSON.stringify(result2, null, 2));
|
|
14358
|
+
return 0;
|
|
14359
|
+
}
|
|
14360
|
+
if (!["platform", "tenant", "repair"].includes(operation)) {
|
|
14361
|
+
throw new Error("Usage: fz bootstrap platform [cloudflare]|tenant|metal|status|repair [--bootstrap-config <path>] [--apply]");
|
|
14362
|
+
}
|
|
14363
|
+
const config = options.bootstrapConfigPath ? readBootstrapConfig(options.bootstrapConfigPath) : operation === "repair" ? (() => {
|
|
14364
|
+
throw new Error("repair requires --bootstrap-config so immutable coordinates are revalidated");
|
|
14365
|
+
})() : interactiveBootstrap(operation);
|
|
14366
|
+
if (operation !== "repair" && config.kind !== operation) {
|
|
14367
|
+
throw new Error(`bootstrap config kind ${config.kind} does not match requested ${operation}`);
|
|
14368
|
+
}
|
|
14369
|
+
const plan = planBootstrap(config, operation === "repair");
|
|
14370
|
+
if (!options.apply) {
|
|
14371
|
+
out.line(JSON.stringify(plan, null, 2));
|
|
14372
|
+
out.step("Review the plan, then repeat with --apply as root.");
|
|
14373
|
+
return 0;
|
|
14374
|
+
}
|
|
14375
|
+
const result = await applyBootstrap(config);
|
|
14376
|
+
out.line(JSON.stringify(result, null, 2));
|
|
14377
|
+
return 0;
|
|
14378
|
+
} catch (cause) {
|
|
14379
|
+
out.fail(cause instanceof Error ? cause.message : String(cause));
|
|
14380
|
+
return 1;
|
|
14381
|
+
}
|
|
14382
|
+
}
|
|
10825
14383
|
async function cmdGenesis(options) {
|
|
10826
14384
|
try {
|
|
10827
14385
|
if (options.realm !== "platform") {
|
|
@@ -10840,10 +14398,10 @@ async function cmdGenesis(options) {
|
|
|
10840
14398
|
` + ` On the server: the token file is read automatically, or set FZ_SHARED_DIR
|
|
10841
14399
|
` + ` From a laptop: copy it to a chmod 600 file and pass --token-file <path>
|
|
10842
14400
|
|
|
10843
|
-
` + " The token is what
|
|
14401
|
+
` + " The token is what `fz bootstrap platform` created. It is single use.");
|
|
10844
14402
|
}
|
|
10845
14403
|
if (!/^plt_[A-Za-z0-9_-]{32,100}$/.test(token)) {
|
|
10846
|
-
throw new Error("The platform invitation is malformed; expected the one-use plt_ token from
|
|
14404
|
+
throw new Error("The platform invitation is malformed; expected the one-use plt_ token from `fz bootstrap platform`.");
|
|
10847
14405
|
}
|
|
10848
14406
|
const session = await authorizeDeviceSession(options, (approval, app) => {
|
|
10849
14407
|
const ceremony = new URL("/custody/ceremony", app);
|
|
@@ -10880,7 +14438,7 @@ async function cmdUnlock(options) {
|
|
|
10880
14438
|
if (options.key || options.userExplicit) {
|
|
10881
14439
|
throw new Error("SSH/user unlock coordinates are not accepted. SSH keys cannot open a WebAuthn-PRF custody envelope; use --phrase-file or --phrase-stdin.");
|
|
10882
14440
|
}
|
|
10883
|
-
const phraseText = options.phraseFile ? readOwnerOnlySecret(options.phraseFile, "recovery phrase") : options.phraseStdin ?
|
|
14441
|
+
const phraseText = options.phraseFile ? readOwnerOnlySecret(options.phraseFile, "recovery phrase") : options.phraseStdin ? readFileSync7(0, "utf8").trim() : "";
|
|
10884
14442
|
const phrase = phraseText ? phraseText.split(/\s+/) : [];
|
|
10885
14443
|
if (phrase.length !== 24) {
|
|
10886
14444
|
throw new Error(`Recovery phrase must contain exactly 24 words; received ${phrase.length}. Use --phrase-file or --phrase-stdin.`);
|
|
@@ -10999,7 +14557,7 @@ function projectFromCheckout(options) {
|
|
|
10999
14557
|
try {
|
|
11000
14558
|
return loadConfig({
|
|
11001
14559
|
cwd: options.projectRoot,
|
|
11002
|
-
readFile: (path) =>
|
|
14560
|
+
readFile: (path) => existsSync6(path) ? readFileSync7(path, "utf8") : undefined
|
|
11003
14561
|
}).config.project;
|
|
11004
14562
|
} catch (cause) {
|
|
11005
14563
|
throw new Error(`No --project was given and the checkout has no usable .fz/config.json: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
@@ -11118,7 +14676,7 @@ async function cmdDeploy(options, args) {
|
|
|
11118
14676
|
branch: options.branch,
|
|
11119
14677
|
cloneUrl: required(options.cloneUrl, "--clone-url"),
|
|
11120
14678
|
sourceAuth,
|
|
11121
|
-
...options.knownHostsFile ? { knownHosts:
|
|
14679
|
+
...options.knownHostsFile ? { knownHosts: readFileSync7(options.knownHostsFile, "utf8") } : {},
|
|
11122
14680
|
projectKey
|
|
11123
14681
|
} });
|
|
11124
14682
|
const pipelineKey2 = String(created.pipelineKey);
|
|
@@ -11215,6 +14773,14 @@ function usage() {
|
|
|
11215
14773
|
fz agent install Install the node agent as a systemd service, so
|
|
11216
14774
|
applications on this box read secrets through a
|
|
11217
14775
|
local socket instead of holding an API key
|
|
14776
|
+
fz bootstrap platform Install/repair a typed elastic platform compute
|
|
14777
|
+
fz bootstrap platform cloudflare
|
|
14778
|
+
Plan/apply token-file-only KV, Worker, Access, DNS
|
|
14779
|
+
and one Tunnel per explicit platform API node
|
|
14780
|
+
fz bootstrap tenant Install/enrol reusable tenant compute tooling
|
|
14781
|
+
fz bootstrap metal Plan/install an identity-only physical provisioner
|
|
14782
|
+
fz bootstrap status Verify persisted profile and supervised units
|
|
14783
|
+
fz bootstrap repair Reapply an explicitly supplied reviewed config
|
|
11218
14784
|
fz project init Create vendor-neutral, Git-persisted AI context
|
|
11219
14785
|
fz project sync Regenerate Claude/Codex/Gemini/Copilot/Cursor adapters
|
|
11220
14786
|
fz project check Fail when truth sources or generated adapters drift
|
|
@@ -11252,6 +14818,8 @@ function usage() {
|
|
|
11252
14818
|
--apply Write the unit rather than printing it (root)
|
|
11253
14819
|
--enrol Bind this machine with a one-time token prompted
|
|
11254
14820
|
securely by systemd (tenant-owned compute)
|
|
14821
|
+
--bootstrap-config <p> Private JSON coordinates for unattended bootstrap;
|
|
14822
|
+
secret values remain in separate owner-only files
|
|
11255
14823
|
--root <path> Project root for project/deploy commands
|
|
11256
14824
|
--name <name> Project or deploy name during init
|
|
11257
14825
|
--purpose <text> Product outcome during project init
|
|
@@ -11330,6 +14898,9 @@ async function runCli() {
|
|
|
11330
14898
|
case "agent":
|
|
11331
14899
|
code = await cmdAgent(options, args);
|
|
11332
14900
|
break;
|
|
14901
|
+
case "bootstrap":
|
|
14902
|
+
code = await cmdBootstrap(options, args);
|
|
14903
|
+
break;
|
|
11333
14904
|
case "project":
|
|
11334
14905
|
code = cmdProject(options, args);
|
|
11335
14906
|
break;
|
|
@@ -11359,7 +14930,7 @@ async function runCli() {
|
|
|
11359
14930
|
usage();
|
|
11360
14931
|
code = 1;
|
|
11361
14932
|
}
|
|
11362
|
-
if (args.length > 0 && code === 0 && !["agent", "project", "deploy", "api", "ui", "routes"].includes(command)) {
|
|
14933
|
+
if (args.length > 0 && code === 0 && !["agent", "bootstrap", "project", "deploy", "api", "ui", "routes"].includes(command)) {
|
|
11363
14934
|
out.warn(`Ignored: ${args.join(" ")}`);
|
|
11364
14935
|
}
|
|
11365
14936
|
process.exit(code);
|