@forgezero/agent 0.1.40 → 0.1.42
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 +573 -424
- package/dist/agent-heartbeat.js +6 -3
- package/dist/agent-update-helper.js +5 -2
- package/dist/agent-update.js +5 -2
- package/dist/bootstrap.d.ts +17 -8
- package/dist/bootstrap.js +1504 -512
- package/dist/cli/agent-install.d.ts +6 -5
- package/dist/cli/cloudflare-bootstrap.d.ts +12 -1
- package/dist/cli/maintenance.d.ts +23 -0
- package/dist/cli/run.d.ts +3 -1
- package/dist/cli/session-store.d.ts +5 -0
- package/dist/cloudflare-bootstrap.d.ts +73 -35
- package/dist/cloudflare-bootstrap.js +587 -90
- package/dist/cloudflare-edge.d.ts +64 -12
- package/dist/cloudflare-edge.js +103 -8
- package/dist/community-rehearsal-host.d.ts +51 -0
- package/dist/community-rehearsal-host.js +272 -0
- package/dist/credential-schema.d.ts +54 -0
- package/dist/credential-schema.js +47 -0
- package/dist/definition.d.ts +31 -5
- package/dist/definition.js +271 -44
- package/dist/deploy-file.js +294 -68
- package/dist/deployment-runner.js +18 -5
- package/dist/deployment.d.ts +13 -1
- package/dist/fz-agent.js +9162 -7564
- package/dist/fz-git-ssh.js +122 -0
- package/dist/fz.js +5679 -5077
- package/dist/git-ssh.d.ts +5 -0
- package/dist/guest-enrolment.d.ts +2 -0
- package/dist/guest-enrolment.js +1 -0
- package/dist/host-maintenance.d.ts +39 -0
- package/dist/host-maintenance.js +135 -0
- package/dist/index.d.ts +4 -2
- package/dist/mesh-connector.d.ts +16 -0
- package/dist/mesh-connector.js +46 -0
- package/dist/metal-bootstrap.js +145 -7
- package/dist/metal-helper-socket.js +61 -31
- package/dist/metal-provision.d.ts +2 -2
- package/dist/metal-provision.js +62 -32
- package/dist/operator-bootstrap.d.ts +90 -0
- package/dist/operator-bootstrap.js +5704 -0
- package/dist/otel-collector.d.ts +18 -0
- package/dist/pipeline.d.ts +3 -2
- package/dist/pipeline.js +1 -1
- package/dist/platform-bootstrap-runtime.d.ts +39 -21
- package/dist/platform-bootstrap-runtime.js +182 -59
- package/dist/platform-fleet-verification.d.ts +19 -0
- package/dist/platform-fleet-verification.js +3873 -0
- package/dist/platform-genesis-config.d.ts +7 -0
- package/dist/platform-genesis.d.ts +17 -0
- package/dist/provision.d.ts +76 -3
- package/dist/provision.js +1061 -229
- package/dist/recovery-host.d.ts +7 -0
- package/dist/recovery-host.js +124 -0
- package/dist/service-supervisor.d.ts +42 -0
- package/dist/software-helper.d.ts +4 -0
- package/dist/software-helper.js +865 -63
- package/dist/software.d.ts +14 -3
- package/dist/software.js +163 -37
- package/dist/ssh-bootstrap.d.ts +97 -0
- package/dist/supervised-app.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/package.json +175 -164
- package/schema/{deploy-v2.json → deploy-v3.json} +53 -6
package/dist/bootstrap.js
CHANGED
|
@@ -94,6 +94,39 @@ async function removeCloudflarePrivateDatabaseRoute(config, fetcher = fetch) {
|
|
|
94
94
|
network: privateDatabaseHostRoute(config.privateAddress)
|
|
95
95
|
}, fetcher);
|
|
96
96
|
}
|
|
97
|
+
var privateNetworkCidr = (value) => {
|
|
98
|
+
const [address, prefixText, ...extra] = value.trim().toLowerCase().split("/");
|
|
99
|
+
const family = isIP(address ?? "");
|
|
100
|
+
const prefix = Number(prefixText);
|
|
101
|
+
if (extra.length || !family || !Number.isInteger(prefix) || prefix < 0 || prefix > (family === 4 ? 32 : 128) || !isPrivateDatabaseAddress(address)) {
|
|
102
|
+
throw new Error("Cloudflare WARP include must be an explicit private IPv4 or IPv6 CIDR");
|
|
103
|
+
}
|
|
104
|
+
return `${address}/${prefix}`;
|
|
105
|
+
};
|
|
106
|
+
async function ensureCloudflareWarpNetworkIncludes(config, fetcher = fetch) {
|
|
107
|
+
if (!/^[A-Za-z0-9-]{1,64}$/.test(config.policyId))
|
|
108
|
+
throw new Error("Cloudflare WARP policy id is invalid");
|
|
109
|
+
if (!Array.isArray(config.networks) || config.networks.length < 1 || config.networks.length > 256) {
|
|
110
|
+
throw new Error("Cloudflare WARP networks must contain 1-256 explicit CIDRs");
|
|
111
|
+
}
|
|
112
|
+
const networks = config.networks.map(privateNetworkCidr);
|
|
113
|
+
if (new Set(networks).size !== networks.length)
|
|
114
|
+
throw new Error("Cloudflare WARP networks must be unique");
|
|
115
|
+
const path = `/accounts/${config.accountId}/devices/policy/${config.policyId}/include`;
|
|
116
|
+
const entries = await cf(config, path, {}, fetcher);
|
|
117
|
+
const present = new Set(entries.flatMap(({ address }) => address ? [address.toLowerCase()] : []));
|
|
118
|
+
const additions = networks.filter((network) => !present.has(network)).map((address) => ({
|
|
119
|
+
address,
|
|
120
|
+
description: `${config.descriptionPrefix}:${address}`.slice(0, 100)
|
|
121
|
+
}));
|
|
122
|
+
if (!additions.length)
|
|
123
|
+
return { entries, created: 0 };
|
|
124
|
+
const updated = await cf(config, path, {
|
|
125
|
+
method: "PUT",
|
|
126
|
+
body: JSON.stringify([...entries, ...additions])
|
|
127
|
+
}, fetcher);
|
|
128
|
+
return { entries: updated, created: additions.length };
|
|
129
|
+
}
|
|
97
130
|
async function ensureCloudflareWarpDatabaseInclude(config, fetcher = fetch) {
|
|
98
131
|
if (config.policyId && !/^[A-Za-z0-9-]{1,64}$/.test(config.policyId))
|
|
99
132
|
throw new Error("Cloudflare WARP policy id is invalid");
|
|
@@ -127,13 +160,11 @@ async function removeCloudflareWarpDatabaseInclude(config, fetcher = fetch) {
|
|
|
127
160
|
return { entries: updated, removed: true };
|
|
128
161
|
}
|
|
129
162
|
async function configureCloudflareEdge(config, fetcher = fetch) {
|
|
130
|
-
const tunnelAuth = { apiToken: config.tunnelApiToken?.trim() || config.apiToken };
|
|
131
|
-
const dnsAuth = { apiToken: config.dnsApiToken?.trim() || config.apiToken };
|
|
132
163
|
const tunnelPath = `/accounts/${config.accountId}/cfd_tunnel/${config.tunnelId}/configurations`;
|
|
133
164
|
const dnsPath = `/zones/${config.zoneId}/dns_records`;
|
|
134
165
|
const [current, records] = await Promise.all([
|
|
135
|
-
cf(
|
|
136
|
-
cf(
|
|
166
|
+
cf(config, tunnelPath, {}, fetcher),
|
|
167
|
+
cf(config, `${dnsPath}?name=${encodeURIComponent(config.hostname)}&per_page=1000`, {}, fetcher)
|
|
137
168
|
]);
|
|
138
169
|
if (records.length > 1) {
|
|
139
170
|
throw new Error(`Cloudflare DNS record for ${config.hostname} is ambiguous`);
|
|
@@ -151,7 +182,7 @@ async function configureCloudflareEdge(config, fetcher = fetch) {
|
|
|
151
182
|
...catchAll.length > 0 ? catchAll : [{ service: "http_status:404" }]
|
|
152
183
|
];
|
|
153
184
|
if (JSON.stringify(existing) !== JSON.stringify(desiredIngress)) {
|
|
154
|
-
await cf(
|
|
185
|
+
await cf(config, tunnelPath, {
|
|
155
186
|
method: "PUT",
|
|
156
187
|
body: JSON.stringify({ config: { ingress: desiredIngress } })
|
|
157
188
|
}, fetcher);
|
|
@@ -165,12 +196,72 @@ async function configureCloudflareEdge(config, fetcher = fetch) {
|
|
|
165
196
|
};
|
|
166
197
|
const dnsAlreadyCorrect = existingRecord?.type === record.type && existingRecord.name?.toLowerCase() === record.name.toLowerCase() && existingRecord.content?.toLowerCase() === record.content.toLowerCase() && existingRecord.proxied === true && existingRecord.ttl === 1;
|
|
167
198
|
if (!dnsAlreadyCorrect) {
|
|
168
|
-
await cf(
|
|
169
|
-
method: existingRecord ? "
|
|
170
|
-
body: JSON.stringify(
|
|
199
|
+
await cf(config, existingRecord ? `${dnsPath}/${encodeURIComponent(existingRecord.id)}` : dnsPath, {
|
|
200
|
+
method: existingRecord ? "PATCH" : "POST",
|
|
201
|
+
body: JSON.stringify(existingRecord ? {
|
|
202
|
+
...record,
|
|
203
|
+
...existingRecord.comment === undefined ? {} : { comment: existingRecord.comment },
|
|
204
|
+
...existingRecord.tags === undefined ? {} : { tags: existingRecord.tags },
|
|
205
|
+
...existingRecord.settings === undefined ? {} : { settings: existingRecord.settings }
|
|
206
|
+
} : record)
|
|
171
207
|
}, fetcher);
|
|
172
208
|
}
|
|
173
209
|
}
|
|
210
|
+
async function verifyCloudflareWorkerDurableObjects(config, fetcher = fetch) {
|
|
211
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/.test(config.scriptName)) {
|
|
212
|
+
throw new Error("Cloudflare Worker script name is invalid");
|
|
213
|
+
}
|
|
214
|
+
const namespaces = await cf(config, `/accounts/${config.accountId}/workers/durable_objects/namespaces?per_page=1000`, {}, fetcher);
|
|
215
|
+
const owned = namespaces.filter(({ script }) => script === config.scriptName);
|
|
216
|
+
if (!owned.length)
|
|
217
|
+
throw new Error(`Cloudflare Worker ${config.scriptName} has no Durable Object namespace`);
|
|
218
|
+
return owned;
|
|
219
|
+
}
|
|
220
|
+
async function configureCloudflareRealtimeSecrets(config, fetcher = fetch) {
|
|
221
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/.test(config.scriptName) || ![config.publishSecret, config.ticketSecret].every((value) => /^[A-Za-z0-9_-]{64,128}$/.test(value)) || config.publishSecret === config.ticketSecret) {
|
|
222
|
+
throw new Error("Cloudflare realtime secret coordinates are invalid");
|
|
223
|
+
}
|
|
224
|
+
await cf(config, `/accounts/${config.accountId}/workers/scripts/${encodeURIComponent(config.scriptName)}/secrets-bulk`, {
|
|
225
|
+
method: "PATCH",
|
|
226
|
+
body: JSON.stringify({
|
|
227
|
+
secrets: {
|
|
228
|
+
REALTIME_PUBLISH_SECRET: {
|
|
229
|
+
name: "REALTIME_PUBLISH_SECRET",
|
|
230
|
+
text: config.publishSecret,
|
|
231
|
+
type: "secret_text"
|
|
232
|
+
},
|
|
233
|
+
REALTIME_TICKET_SECRET: {
|
|
234
|
+
name: "REALTIME_TICKET_SECRET",
|
|
235
|
+
text: config.ticketSecret,
|
|
236
|
+
type: "secret_text"
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
}, fetcher);
|
|
241
|
+
}
|
|
242
|
+
async function ensureCloudflareMeshConnector(config, fetcher = fetch) {
|
|
243
|
+
const name = config.name.trim();
|
|
244
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(name)) {
|
|
245
|
+
throw new Error("Cloudflare Mesh connector name is invalid");
|
|
246
|
+
}
|
|
247
|
+
const path = `/accounts/${config.accountId}/warp_connector`;
|
|
248
|
+
const connectors = await cf(config, `${path}?is_deleted=false&name=${encodeURIComponent(name)}&per_page=1000`, {}, fetcher);
|
|
249
|
+
const matches = connectors.filter((connector2) => connector2.name === name && !connector2.deleted_at);
|
|
250
|
+
if (matches.length > 1)
|
|
251
|
+
throw new Error(`Cloudflare Mesh connector ${name} is ambiguous`);
|
|
252
|
+
const created = !matches[0];
|
|
253
|
+
const connector = matches[0] ?? await cf(config, path, {
|
|
254
|
+
method: "POST",
|
|
255
|
+
body: JSON.stringify({ name, ha: config.highAvailability })
|
|
256
|
+
}, fetcher);
|
|
257
|
+
if (!connector.id)
|
|
258
|
+
throw new Error("Cloudflare returned an invalid Mesh connector");
|
|
259
|
+
const connectorToken = await cf(config, `${path}/${encodeURIComponent(connector.id)}/token`, {}, fetcher);
|
|
260
|
+
if (!connectorToken || connectorToken.length > 16384) {
|
|
261
|
+
throw new Error("Cloudflare returned an invalid Mesh connector token");
|
|
262
|
+
}
|
|
263
|
+
return { connector, connectorToken, created };
|
|
264
|
+
}
|
|
174
265
|
async function ensureCloudflareTunnel(config, fetcher = fetch) {
|
|
175
266
|
const name = config.name.trim();
|
|
176
267
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(name)) {
|
|
@@ -195,13 +286,15 @@ async function ensureCloudflareTunnel(config, fetcher = fetch) {
|
|
|
195
286
|
|
|
196
287
|
// src/cloudflare-bootstrap.ts
|
|
197
288
|
import { constants } from "fs";
|
|
198
|
-
import { randomUUID } from "crypto";
|
|
199
|
-
import { chmod, lstat, mkdir, open, rename, stat, unlink } from "fs/promises";
|
|
289
|
+
import { randomBytes, randomUUID } from "crypto";
|
|
290
|
+
import { chmod, lstat, mkdir, open, readdir, rename, rmdir, stat, unlink } from "fs/promises";
|
|
200
291
|
import { dirname, join, resolve } from "path";
|
|
292
|
+
import { isIP as isIP2 } from "net";
|
|
201
293
|
var TOKEN = /^[A-Za-z0-9._-]{40,80}$/;
|
|
202
294
|
var CONNECTOR_TOKEN = /^[A-Za-z0-9._-]{40,16384}$/;
|
|
203
295
|
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
204
296
|
var HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
297
|
+
var REALTIME_SECRET = /^[A-Za-z0-9_-]{64,128}$/;
|
|
205
298
|
var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
206
299
|
async function assertOwnerOnlyHandle(path, handle, maximumBytes) {
|
|
207
300
|
const metadata = await handle.stat();
|
|
@@ -242,33 +335,23 @@ async function readOwnerApiToken(path) {
|
|
|
242
335
|
return token;
|
|
243
336
|
}
|
|
244
337
|
async function readCloudflareBootstrapTokens(files) {
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
dnsApiToken: supplied.dnsApiToken ?? supplied.managementApiToken
|
|
261
|
-
} : {},
|
|
262
|
-
...supplied.kvApiToken || supplied.runtimeApiToken ? {
|
|
263
|
-
kvApiToken: supplied.kvApiToken ?? supplied.runtimeApiToken
|
|
264
|
-
} : {}
|
|
265
|
-
};
|
|
266
|
-
for (const key of ["tunnelApiToken", "dnsApiToken", "kvApiToken"]) {
|
|
267
|
-
if (!tokens[key] && !tokens.apiToken) {
|
|
268
|
-
throw new Error(`Cloudflare ${key} file is required when apiTokenFile is omitted`);
|
|
269
|
-
}
|
|
338
|
+
const unsupported = Object.keys(files).filter((key) => ![
|
|
339
|
+
"tunnelTokenFile",
|
|
340
|
+
"apiTokenFile"
|
|
341
|
+
].includes(key));
|
|
342
|
+
if (unsupported.length)
|
|
343
|
+
throw new Error(`Cloudflare bootstrap token files contain unsupported field ${unsupported[0]}`);
|
|
344
|
+
if (!files.tunnelTokenFile?.trim() || !files.apiTokenFile?.trim()) {
|
|
345
|
+
throw new Error("Cloudflare bootstrap requires exactly tunnelTokenFile and apiTokenFile");
|
|
346
|
+
}
|
|
347
|
+
const [tunnelToken, apiToken] = await Promise.all([
|
|
348
|
+
readOwnerApiToken(files.tunnelTokenFile),
|
|
349
|
+
readOwnerApiToken(files.apiTokenFile)
|
|
350
|
+
]);
|
|
351
|
+
if (tunnelToken === apiToken) {
|
|
352
|
+
throw new Error("CF_TUNNEL_TOKEN and CF_API_TOKEN must be distinct least-privilege tokens");
|
|
270
353
|
}
|
|
271
|
-
return
|
|
354
|
+
return { tunnelToken, apiToken };
|
|
272
355
|
}
|
|
273
356
|
var validateId = (value, label) => {
|
|
274
357
|
const normalized = value.trim().toLowerCase();
|
|
@@ -294,28 +377,89 @@ var normalizeService = (value) => {
|
|
|
294
377
|
}
|
|
295
378
|
return service.toString().replace(/\/$/, "");
|
|
296
379
|
};
|
|
380
|
+
var privateMeshCidr = (value) => {
|
|
381
|
+
const [address, prefixText, ...extra] = value.trim().toLowerCase().split("/");
|
|
382
|
+
const family = isIP2(address ?? "");
|
|
383
|
+
const prefix = Number(prefixText);
|
|
384
|
+
const v4 = family === 4 ? address.split(".").map(Number) : undefined;
|
|
385
|
+
const privateAddress = family === 4 ? Boolean(v4 && (v4[0] === 10 || v4[0] === 172 && v4[1] >= 16 && v4[1] <= 31 || v4[0] === 192 && v4[1] === 168)) : family === 6 && /^f[cd][0-9a-f]:/i.test(address);
|
|
386
|
+
if (extra.length || !family || !privateAddress || !Number.isInteger(prefix) || prefix < 0 || prefix > (family === 4 ? 32 : 128)) {
|
|
387
|
+
throw new Error("Cloudflare Mesh route must be an explicit private IPv4 or IPv6 CIDR");
|
|
388
|
+
}
|
|
389
|
+
return `${address}/${prefix}`;
|
|
390
|
+
};
|
|
297
391
|
function validateCloudflareBootstrapCoordinates(input) {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
392
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
393
|
+
throw new Error("Cloudflare bootstrap coordinates must be an object");
|
|
394
|
+
}
|
|
395
|
+
const unsupported = Object.keys(input).filter((key) => ![
|
|
396
|
+
"accountId",
|
|
397
|
+
"zoneId",
|
|
398
|
+
"kvNamespaceId",
|
|
399
|
+
"meshDevicePolicyId",
|
|
400
|
+
"realtime",
|
|
401
|
+
"nodes"
|
|
402
|
+
].includes(key));
|
|
403
|
+
if (unsupported.length)
|
|
404
|
+
throw new Error(`Cloudflare bootstrap coordinates contain unsupported field ${unsupported[0]}`);
|
|
405
|
+
const nodeInputs = input.nodes;
|
|
406
|
+
if (!Array.isArray(nodeInputs))
|
|
407
|
+
throw new Error("Cloudflare bootstrap requires an explicit nodes array");
|
|
304
408
|
if (nodeInputs.length < 1 || nodeInputs.length > 32) {
|
|
305
409
|
throw new Error("Cloudflare bootstrap requires between 1 and 32 explicit nodes");
|
|
306
410
|
}
|
|
307
411
|
const nodes = nodeInputs.map((node) => {
|
|
412
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
|
413
|
+
throw new Error("Cloudflare bootstrap node must be an object");
|
|
414
|
+
}
|
|
415
|
+
const unknownNode = Object.keys(node).filter((key) => ![
|
|
416
|
+
"nodeName",
|
|
417
|
+
"hostname",
|
|
418
|
+
"service",
|
|
419
|
+
"tunnelName",
|
|
420
|
+
"mesh"
|
|
421
|
+
].includes(key));
|
|
422
|
+
if (unknownNode.length)
|
|
423
|
+
throw new Error(`Cloudflare bootstrap node contains unsupported field ${unknownNode[0]}`);
|
|
308
424
|
const nodeName = node.nodeName.trim().toLowerCase();
|
|
309
425
|
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(nodeName))
|
|
310
426
|
throw new Error("Cloudflare node name is invalid");
|
|
311
427
|
const hostname = node.hostname.trim().toLowerCase();
|
|
312
428
|
if (!HOSTNAME.test(hostname))
|
|
313
429
|
throw new Error("Cloudflare public node hostname is invalid");
|
|
430
|
+
let mesh;
|
|
431
|
+
if (node.mesh !== undefined) {
|
|
432
|
+
if (!node.mesh || typeof node.mesh !== "object" || Array.isArray(node.mesh)) {
|
|
433
|
+
throw new Error("Cloudflare Mesh coordinates must be an object");
|
|
434
|
+
}
|
|
435
|
+
const unknownMesh = Object.keys(node.mesh).filter((key) => ![
|
|
436
|
+
"connectorName",
|
|
437
|
+
"routes",
|
|
438
|
+
"highAvailability"
|
|
439
|
+
].includes(key));
|
|
440
|
+
if (unknownMesh.length)
|
|
441
|
+
throw new Error(`Cloudflare Mesh contains unsupported field ${unknownMesh[0]}`);
|
|
442
|
+
if (!Array.isArray(node.mesh.routes) || node.mesh.routes.length < 1 || node.mesh.routes.length > 64) {
|
|
443
|
+
throw new Error("Cloudflare Mesh node requires 1-64 private routes");
|
|
444
|
+
}
|
|
445
|
+
const routes = node.mesh.routes.map(privateMeshCidr);
|
|
446
|
+
if (new Set(routes).size !== routes.length)
|
|
447
|
+
throw new Error("Cloudflare Mesh routes must be unique per node");
|
|
448
|
+
if (node.mesh.highAvailability !== undefined && typeof node.mesh.highAvailability !== "boolean") {
|
|
449
|
+
throw new Error("Cloudflare Mesh highAvailability must be a boolean");
|
|
450
|
+
}
|
|
451
|
+
mesh = {
|
|
452
|
+
connectorName: validateName(node.mesh.connectorName, "Cloudflare Mesh connector name"),
|
|
453
|
+
routes,
|
|
454
|
+
highAvailability: node.mesh.highAvailability === true
|
|
455
|
+
};
|
|
456
|
+
}
|
|
314
457
|
return {
|
|
315
458
|
nodeName,
|
|
316
459
|
hostname,
|
|
317
460
|
service: normalizeService(node.service),
|
|
318
|
-
tunnelName: validateName(node.tunnelName, "Cloudflare Tunnel name")
|
|
461
|
+
tunnelName: validateName(node.tunnelName, "Cloudflare Tunnel name"),
|
|
462
|
+
...mesh ? { mesh } : {}
|
|
319
463
|
};
|
|
320
464
|
});
|
|
321
465
|
for (const [label, values] of [
|
|
@@ -326,14 +470,51 @@ function validateCloudflareBootstrapCoordinates(input) {
|
|
|
326
470
|
if (new Set(values).size !== values.length)
|
|
327
471
|
throw new Error(`Cloudflare fleet ${label} must be unique`);
|
|
328
472
|
}
|
|
329
|
-
const
|
|
473
|
+
const meshNodes = nodes.filter(({ mesh }) => mesh);
|
|
474
|
+
if (meshNodes.length && !input.meshDevicePolicyId?.trim()) {
|
|
475
|
+
throw new Error("Cloudflare Mesh requires an explicit dedicated device policy id");
|
|
476
|
+
}
|
|
477
|
+
if (!meshNodes.length && input.meshDevicePolicyId !== undefined) {
|
|
478
|
+
throw new Error("Cloudflare Mesh device policy is not allowed without Mesh nodes");
|
|
479
|
+
}
|
|
480
|
+
const connectorNames = meshNodes.map(({ mesh }) => mesh.connectorName);
|
|
481
|
+
if (new Set(connectorNames).size !== connectorNames.length) {
|
|
482
|
+
throw new Error("Cloudflare Mesh connector names must be unique");
|
|
483
|
+
}
|
|
484
|
+
const routeOwners = new Map;
|
|
485
|
+
for (const node of meshNodes)
|
|
486
|
+
for (const route of node.mesh.routes) {
|
|
487
|
+
const owner = routeOwners.get(route);
|
|
488
|
+
if (owner)
|
|
489
|
+
throw new Error(`Cloudflare Mesh route ${route} is declared by both ${owner} and ${node.nodeName}`);
|
|
490
|
+
routeOwners.set(route, node.nodeName);
|
|
491
|
+
}
|
|
492
|
+
let realtime;
|
|
493
|
+
if (input.realtime !== undefined) {
|
|
494
|
+
if (!input.realtime || typeof input.realtime !== "object" || Array.isArray(input.realtime) || Object.keys(input.realtime).some((key) => !["workerScriptName", "endpoint", "producer"].includes(key))) {
|
|
495
|
+
throw new Error("Cloudflare realtime coordinates are malformed");
|
|
496
|
+
}
|
|
497
|
+
const workerScriptName = input.realtime.workerScriptName.trim();
|
|
498
|
+
const producer = input.realtime.producer.trim();
|
|
499
|
+
let endpoint2;
|
|
500
|
+
try {
|
|
501
|
+
endpoint2 = new URL(input.realtime.endpoint);
|
|
502
|
+
} catch {
|
|
503
|
+
throw new Error("Cloudflare realtime endpoint is malformed");
|
|
504
|
+
}
|
|
505
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/.test(workerScriptName) || !/^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,127}$/.test(producer) || endpoint2.protocol !== "https:" || endpoint2.username || endpoint2.password || endpoint2.pathname !== "/" || endpoint2.search || endpoint2.hash) {
|
|
506
|
+
throw new Error("Cloudflare realtime Worker, endpoint, or producer is invalid");
|
|
507
|
+
}
|
|
508
|
+
realtime = { workerScriptName, endpoint: endpoint2.origin, producer };
|
|
509
|
+
}
|
|
330
510
|
return {
|
|
331
511
|
accountId: validateId(input.accountId, "Cloudflare account id"),
|
|
332
512
|
zoneId: validateId(input.zoneId, "Cloudflare zone id"),
|
|
333
|
-
hostname: first.hostname,
|
|
334
|
-
service: first.service,
|
|
335
|
-
tunnelName: first.tunnelName,
|
|
336
513
|
kvNamespaceId: validateId(input.kvNamespaceId, "Cloudflare KV namespace id"),
|
|
514
|
+
...input.meshDevicePolicyId ? {
|
|
515
|
+
meshDevicePolicyId: validateName(input.meshDevicePolicyId, "Cloudflare Mesh device policy id")
|
|
516
|
+
} : {},
|
|
517
|
+
...realtime ? { realtime } : {},
|
|
337
518
|
nodes
|
|
338
519
|
};
|
|
339
520
|
}
|
|
@@ -346,18 +527,50 @@ function planCloudflareBootstrap(input, outputPath) {
|
|
|
346
527
|
outputFile: resolve(outputPath),
|
|
347
528
|
coordinates,
|
|
348
529
|
operations: [
|
|
530
|
+
"prove CF_API_TOKEN can write, read and remove one namespaced nonce in the existing KV namespace",
|
|
531
|
+
...coordinates.realtime ? [
|
|
532
|
+
"prove the existing Worker owns a Durable Object namespace and install its publish/ticket secrets with CF_API_TOKEN"
|
|
533
|
+
] : [],
|
|
349
534
|
"create or reuse one remotely-managed Tunnel per node and checkpoint every connector token",
|
|
535
|
+
...coordinates.nodes.some(({ mesh }) => mesh) ? [
|
|
536
|
+
"create or reuse one Mesh/WARP Connector per declared private-network node and checkpoint its registration token",
|
|
537
|
+
"reconcile every unique private CIDR to its Mesh connector and include all declared CIDRs in the dedicated Mesh device profile"
|
|
538
|
+
] : [],
|
|
350
539
|
"preflight each exact DNS hostname, refuse ambiguous or incompatible records, and update its existing CNAME or create it only when absent",
|
|
351
540
|
"reconcile each Tunnel public-hostname ingress rule to the declared loopback API service",
|
|
352
|
-
"write one node-specific handoff containing the connector token and owner-supplied KV
|
|
541
|
+
"write one node-specific handoff containing the connector token and owner-supplied KV/Worker runtime token"
|
|
353
542
|
],
|
|
354
543
|
secrets: [
|
|
355
544
|
"API tokens are read only from owner-only files and are never placed in argv or stdout",
|
|
356
|
-
"
|
|
357
|
-
"the normal API process receives only
|
|
545
|
+
"CF_TUNNEL_TOKEN is never persisted; cloudflared, Mesh, CF_API_TOKEN and generated realtime capabilities are atomically checkpointed with mode 0600",
|
|
546
|
+
"the normal API process receives only CF_API_TOKEN and never Tunnel, DNS or Mesh management authority"
|
|
358
547
|
]
|
|
359
548
|
};
|
|
360
549
|
}
|
|
550
|
+
async function preflightCloudflareKvRuntime(coordinates, apiToken, fetcher) {
|
|
551
|
+
const nonce = randomUUID();
|
|
552
|
+
const key = `forgezero/bootstrap-preflight/${nonce}`;
|
|
553
|
+
const url = `https://api.cloudflare.com/client/v4/accounts/${coordinates.accountId}` + `/storage/kv/namespaces/${coordinates.kvNamespaceId}/values/${encodeURIComponent(key)}`;
|
|
554
|
+
const headers = { authorization: `Bearer ${apiToken}`, "content-type": "text/plain" };
|
|
555
|
+
let written = false;
|
|
556
|
+
try {
|
|
557
|
+
const put = await fetcher(url, { method: "PUT", headers, body: nonce });
|
|
558
|
+
if (!put.ok)
|
|
559
|
+
throw new Error(`Cloudflare KV runtime preflight write returned HTTP ${put.status}`);
|
|
560
|
+
written = true;
|
|
561
|
+
const get = await fetcher(url, { method: "GET", headers: { authorization: `Bearer ${apiToken}` } });
|
|
562
|
+
if (!get.ok)
|
|
563
|
+
throw new Error(`Cloudflare KV runtime preflight read returned HTTP ${get.status}`);
|
|
564
|
+
if (await get.text() !== nonce)
|
|
565
|
+
throw new Error("Cloudflare KV runtime preflight read returned unexpected data");
|
|
566
|
+
} finally {
|
|
567
|
+
if (written) {
|
|
568
|
+
const removed = await fetcher(url, { method: "DELETE", headers: { authorization: `Bearer ${apiToken}` } });
|
|
569
|
+
if (!removed.ok)
|
|
570
|
+
throw new Error(`Cloudflare KV runtime preflight cleanup returned HTTP ${removed.status}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
361
574
|
async function readExistingOutput(path) {
|
|
362
575
|
try {
|
|
363
576
|
await lstat(path);
|
|
@@ -374,10 +587,84 @@ async function readExistingOutput(path) {
|
|
|
374
587
|
throw new Error(`${resolve(path)} is not valid bootstrap JSON`);
|
|
375
588
|
throw cause;
|
|
376
589
|
}
|
|
590
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
591
|
+
throw new Error(`${resolve(path)} is not a ForgeZero Cloudflare bootstrap output`);
|
|
592
|
+
}
|
|
377
593
|
const output = parsed;
|
|
378
|
-
|
|
594
|
+
const unsupported = Object.keys(output).filter((key) => ![
|
|
595
|
+
"format",
|
|
596
|
+
"kind",
|
|
597
|
+
"phase",
|
|
598
|
+
"updatedAt",
|
|
599
|
+
"coordinates",
|
|
600
|
+
"resources",
|
|
601
|
+
"created"
|
|
602
|
+
].includes(key));
|
|
603
|
+
if (unsupported.length)
|
|
604
|
+
throw new Error(`${resolve(path)} contains unsupported field ${unsupported[0]}`);
|
|
605
|
+
if (output.format !== 1 || output.kind !== "forgezero-cloudflare-bootstrap" || !["edge-resources-provisioned", "complete"].includes(String(output.phase)) || typeof output.updatedAt !== "string" || Number.isNaN(Date.parse(output.updatedAt)) || !output.resources || typeof output.resources !== "object" || Array.isArray(output.resources)) {
|
|
379
606
|
throw new Error(`${resolve(path)} is not a ForgeZero Cloudflare bootstrap output`);
|
|
380
607
|
}
|
|
608
|
+
const resources = output.resources;
|
|
609
|
+
const unsupportedResource = Object.keys(resources).filter((key) => ![
|
|
610
|
+
"kvNamespaceId",
|
|
611
|
+
"apiToken",
|
|
612
|
+
"realtime",
|
|
613
|
+
"nodes"
|
|
614
|
+
].includes(key));
|
|
615
|
+
if (unsupportedResource.length) {
|
|
616
|
+
throw new Error(`${resolve(path)} resources contain unsupported field ${unsupportedResource[0]}`);
|
|
617
|
+
}
|
|
618
|
+
const coordinates = validateCloudflareBootstrapCoordinates(output.coordinates);
|
|
619
|
+
if (resources.kvNamespaceId !== coordinates.kvNamespaceId || !TOKEN.test(String(resources.apiToken ?? "")) || !Array.isArray(resources.nodes) || resources.nodes.length > coordinates.nodes.length) {
|
|
620
|
+
throw new Error(`${resolve(path)} has malformed Cloudflare bootstrap resources`);
|
|
621
|
+
}
|
|
622
|
+
if (coordinates.realtime) {
|
|
623
|
+
const realtime = resources.realtime;
|
|
624
|
+
if (!realtime || !REALTIME_SECRET.test(String(realtime.publishSecret ?? "")) || !REALTIME_SECRET.test(String(realtime.ticketSecret ?? "")) || realtime.publishSecret === realtime.ticketSecret) {
|
|
625
|
+
throw new Error(`${resolve(path)} has malformed Cloudflare realtime resources`);
|
|
626
|
+
}
|
|
627
|
+
} else if (resources.realtime !== undefined) {
|
|
628
|
+
throw new Error(`${resolve(path)} contains undeclared Cloudflare realtime resources`);
|
|
629
|
+
}
|
|
630
|
+
const seen = new Set;
|
|
631
|
+
for (const item of resources.nodes) {
|
|
632
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
633
|
+
throw new Error(`${resolve(path)} has a malformed Cloudflare node resource`);
|
|
634
|
+
}
|
|
635
|
+
const node = item;
|
|
636
|
+
const unknownNode = Object.keys(node).filter((key) => ![
|
|
637
|
+
"nodeName",
|
|
638
|
+
"hostname",
|
|
639
|
+
"service",
|
|
640
|
+
"tunnelName",
|
|
641
|
+
"tunnelId",
|
|
642
|
+
"connectorToken",
|
|
643
|
+
"mesh"
|
|
644
|
+
].includes(key));
|
|
645
|
+
const expected = coordinates.nodes.find((candidate) => candidate.nodeName === node.nodeName);
|
|
646
|
+
if (unknownNode.length || !expected || seen.has(expected.nodeName) || node.hostname !== expected.hostname || node.service !== expected.service || node.tunnelName !== expected.tunnelName || !UUID.test(String(node.tunnelId ?? "")) || !CONNECTOR_TOKEN.test(String(node.connectorToken ?? ""))) {
|
|
647
|
+
throw new Error(`${resolve(path)} has a malformed or unbound Cloudflare node resource`);
|
|
648
|
+
}
|
|
649
|
+
if (expected.mesh) {
|
|
650
|
+
const mesh = node.mesh;
|
|
651
|
+
if (!mesh || mesh.connectorName !== expected.mesh.connectorName || JSON.stringify(mesh.routes) !== JSON.stringify(expected.mesh.routes) || mesh.highAvailability !== expected.mesh.highAvailability || !UUID.test(String(mesh.connectorId ?? "")) || !CONNECTOR_TOKEN.test(String(mesh.connectorToken ?? ""))) {
|
|
652
|
+
throw new Error(`${resolve(path)} has a malformed or unbound Cloudflare Mesh resource`);
|
|
653
|
+
}
|
|
654
|
+
} else if (node.mesh !== undefined) {
|
|
655
|
+
throw new Error(`${resolve(path)} contains an undeclared Cloudflare Mesh resource`);
|
|
656
|
+
}
|
|
657
|
+
seen.add(expected.nodeName);
|
|
658
|
+
}
|
|
659
|
+
if (output.phase === "complete" && resources.nodes.length !== coordinates.nodes.length) {
|
|
660
|
+
throw new Error(`${resolve(path)} completed output does not cover the declared node fleet`);
|
|
661
|
+
}
|
|
662
|
+
if (output.created !== undefined) {
|
|
663
|
+
if (!output.created || typeof output.created !== "object" || Array.isArray(output.created) || Object.keys(output.created).some((key) => key !== "nodes") || !Array.isArray(output.created.nodes) || output.created.nodes.some((node) => !node || typeof node !== "object" || Array.isArray(node) || Object.keys(node).some((key) => !["nodeName", "tunnel", "mesh"].includes(key)) || typeof node.nodeName !== "string" || typeof node.tunnel !== "boolean" || typeof node.mesh !== "boolean")) {
|
|
664
|
+
throw new Error(`${resolve(path)} has malformed Cloudflare creation evidence`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
output.coordinates = coordinates;
|
|
381
668
|
return output;
|
|
382
669
|
}
|
|
383
670
|
function cloudflareHostHandoffPath(checkpointPath, nodeName) {
|
|
@@ -410,7 +697,12 @@ async function readCloudflareConnectorHandoff(checkpointPath, nodeName) {
|
|
|
410
697
|
hostname: resource.hostname,
|
|
411
698
|
service: resource.service,
|
|
412
699
|
tunnelId: resource.tunnelId,
|
|
413
|
-
connectorToken: resource.connectorToken
|
|
700
|
+
connectorToken: resource.connectorToken,
|
|
701
|
+
...resource.mesh ? { mesh: {
|
|
702
|
+
connectorId: resource.mesh.connectorId,
|
|
703
|
+
connectorToken: resource.mesh.connectorToken,
|
|
704
|
+
routes: resource.mesh.routes
|
|
705
|
+
} } : {}
|
|
414
706
|
};
|
|
415
707
|
}
|
|
416
708
|
async function readCloudflareHostHandoff(handoffPath, nodeName) {
|
|
@@ -436,9 +728,9 @@ async function readCloudflareHostHandoff(handoffPath, nodeName) {
|
|
|
436
728
|
"accountId",
|
|
437
729
|
"zoneId",
|
|
438
730
|
"kvNamespaceId",
|
|
439
|
-
"
|
|
440
|
-
"
|
|
441
|
-
"
|
|
731
|
+
"apiToken",
|
|
732
|
+
"mesh",
|
|
733
|
+
"realtime"
|
|
442
734
|
].includes(key));
|
|
443
735
|
if (unknown.length)
|
|
444
736
|
throw new Error(`Cloudflare host handoff contains unsupported field ${unknown[0]}`);
|
|
@@ -447,17 +739,53 @@ async function readCloudflareHostHandoff(handoffPath, nodeName) {
|
|
|
447
739
|
try {
|
|
448
740
|
service = normalizeService(output.service ?? "");
|
|
449
741
|
} catch {}
|
|
450
|
-
if (output.format !== 1 || output.kind !== "forgezero-cloudflare-host-handoff" || output.nodeName !== normalizedNodeName || !HOSTNAME.test(output.hostname ?? "") || !service || !UUID.test(output.tunnelId ?? "") || !CONNECTOR_TOKEN.test(output.connectorToken ?? "") || !TOKEN.test(output.
|
|
742
|
+
if (output.format !== 1 || output.kind !== "forgezero-cloudflare-host-handoff" || output.nodeName !== normalizedNodeName || !HOSTNAME.test(output.hostname ?? "") || !service || !UUID.test(output.tunnelId ?? "") || !CONNECTOR_TOKEN.test(output.connectorToken ?? "") || !TOKEN.test(output.apiToken ?? "") || !/^[a-f0-9]{32}$/i.test(output.accountId ?? "") || !/^[a-f0-9]{32}$/i.test(output.zoneId ?? "") || !/^[a-f0-9]{32}$/i.test(output.kvNamespaceId ?? "")) {
|
|
451
743
|
throw new Error("Cloudflare host handoff is malformed or belongs to another node");
|
|
452
744
|
}
|
|
453
|
-
if (output.
|
|
454
|
-
|
|
745
|
+
if (output.mesh !== undefined) {
|
|
746
|
+
if (!output.mesh || typeof output.mesh !== "object" || !UUID.test(output.mesh.connectorId) || !CONNECTOR_TOKEN.test(output.mesh.connectorToken) || !Array.isArray(output.mesh.routes) || output.mesh.routes.length < 1 || output.mesh.routes.length > 64 || output.mesh.routes.some((route) => {
|
|
747
|
+
try {
|
|
748
|
+
return privateMeshCidr(route) !== route;
|
|
749
|
+
} catch {
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
})) {
|
|
753
|
+
throw new Error("Cloudflare host handoff contains malformed Mesh coordinates");
|
|
754
|
+
}
|
|
455
755
|
}
|
|
456
|
-
if (
|
|
457
|
-
|
|
756
|
+
if (output.realtime !== undefined) {
|
|
757
|
+
let realtime;
|
|
758
|
+
try {
|
|
759
|
+
const { workerScriptName, endpoint: endpoint2, producer } = output.realtime;
|
|
760
|
+
realtime = validateCloudflareBootstrapCoordinates({
|
|
761
|
+
accountId: output.accountId,
|
|
762
|
+
zoneId: output.zoneId,
|
|
763
|
+
kvNamespaceId: output.kvNamespaceId,
|
|
764
|
+
realtime: { workerScriptName, endpoint: endpoint2, producer },
|
|
765
|
+
nodes: [{
|
|
766
|
+
nodeName: output.nodeName,
|
|
767
|
+
hostname: output.hostname,
|
|
768
|
+
service: output.service,
|
|
769
|
+
tunnelName: "handoff"
|
|
770
|
+
}]
|
|
771
|
+
}).realtime;
|
|
772
|
+
} catch {
|
|
773
|
+
throw new Error("Cloudflare host handoff contains malformed realtime coordinates");
|
|
774
|
+
}
|
|
775
|
+
if (!REALTIME_SECRET.test(output.realtime.publishSecret) || !REALTIME_SECRET.test(output.realtime.ticketSecret) || output.realtime.publishSecret === output.realtime.ticketSecret || realtime.workerScriptName !== output.realtime.workerScriptName || realtime.endpoint !== output.realtime.endpoint || realtime.producer !== output.realtime.producer) {
|
|
776
|
+
throw new Error("Cloudflare host handoff contains malformed realtime credentials");
|
|
777
|
+
}
|
|
458
778
|
}
|
|
459
779
|
const { format: _format, kind: _kind, ...handoff } = output;
|
|
460
|
-
return
|
|
780
|
+
return {
|
|
781
|
+
...handoff,
|
|
782
|
+
nodeName: normalizedNodeName,
|
|
783
|
+
hostname: handoff.hostname.toLowerCase(),
|
|
784
|
+
service,
|
|
785
|
+
accountId: handoff.accountId.toLowerCase(),
|
|
786
|
+
zoneId: handoff.zoneId.toLowerCase(),
|
|
787
|
+
kvNamespaceId: handoff.kvNamespaceId.toLowerCase()
|
|
788
|
+
};
|
|
461
789
|
}
|
|
462
790
|
async function prepareOwnerOutputDirectory(absolutePath) {
|
|
463
791
|
const directory = dirname(absolutePath);
|
|
@@ -513,18 +841,22 @@ async function writeCloudflareHostHandoffs(checkpointPath, output) {
|
|
|
513
841
|
accountId: output.coordinates.accountId,
|
|
514
842
|
zoneId: output.coordinates.zoneId,
|
|
515
843
|
kvNamespaceId: output.resources.kvNamespaceId,
|
|
516
|
-
|
|
844
|
+
apiToken: output.resources.apiToken,
|
|
845
|
+
...output.coordinates.realtime && output.resources.realtime ? { realtime: {
|
|
846
|
+
...output.coordinates.realtime,
|
|
847
|
+
...output.resources.realtime
|
|
848
|
+
} } : {},
|
|
849
|
+
...node.mesh ? { mesh: {
|
|
850
|
+
connectorId: node.mesh.connectorId,
|
|
851
|
+
connectorToken: node.mesh.connectorToken,
|
|
852
|
+
routes: node.mesh.routes
|
|
853
|
+
} } : {}
|
|
517
854
|
};
|
|
518
855
|
await writeOwnerJson(cloudflareHostHandoffPath(checkpointPath, node.nodeName), handoff);
|
|
519
856
|
}
|
|
520
857
|
}
|
|
521
|
-
var tokenFor = (tokens, key) => {
|
|
522
|
-
const token = tokens[key]?.trim() || tokens.apiToken?.trim();
|
|
523
|
-
if (!token || !TOKEN.test(token))
|
|
524
|
-
throw new Error(`Cloudflare ${key} is not configured`);
|
|
525
|
-
return token;
|
|
526
|
-
};
|
|
527
858
|
var sameCoordinates = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
859
|
+
var newRealtimeSecret = () => randomBytes(48).toString("base64url");
|
|
528
860
|
async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fetch) {
|
|
529
861
|
const coordinates = validateCloudflareBootstrapCoordinates(input);
|
|
530
862
|
const absoluteOutput = resolve(outputPath);
|
|
@@ -533,7 +865,46 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
533
865
|
throw new Error("bootstrap output belongs to different Cloudflare coordinates; choose a different output file");
|
|
534
866
|
}
|
|
535
867
|
await prepareOwnerOutputDirectory(absoluteOutput);
|
|
536
|
-
const
|
|
868
|
+
const tunnelToken = tokens.tunnelToken.trim();
|
|
869
|
+
const apiToken = tokens.apiToken.trim();
|
|
870
|
+
if (!TOKEN.test(tunnelToken) || !TOKEN.test(apiToken)) {
|
|
871
|
+
throw new Error("Cloudflare bootstrap tokens are malformed");
|
|
872
|
+
}
|
|
873
|
+
if (tunnelToken === apiToken) {
|
|
874
|
+
throw new Error("CF_TUNNEL_TOKEN and CF_API_TOKEN must be distinct least-privilege capabilities");
|
|
875
|
+
}
|
|
876
|
+
await preflightCloudflareKvRuntime(coordinates, apiToken, fetcher);
|
|
877
|
+
const realtime = coordinates.realtime ? existing?.resources.realtime ?? {
|
|
878
|
+
publishSecret: newRealtimeSecret(),
|
|
879
|
+
ticketSecret: newRealtimeSecret()
|
|
880
|
+
} : undefined;
|
|
881
|
+
if (coordinates.realtime && realtime) {
|
|
882
|
+
await verifyCloudflareWorkerDurableObjects({
|
|
883
|
+
accountId: coordinates.accountId,
|
|
884
|
+
scriptName: coordinates.realtime.workerScriptName,
|
|
885
|
+
apiToken
|
|
886
|
+
}, fetcher);
|
|
887
|
+
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
888
|
+
format: 1,
|
|
889
|
+
kind: "forgezero-cloudflare-bootstrap",
|
|
890
|
+
phase: "edge-resources-provisioned",
|
|
891
|
+
updatedAt: new Date().toISOString(),
|
|
892
|
+
coordinates,
|
|
893
|
+
resources: {
|
|
894
|
+
kvNamespaceId: coordinates.kvNamespaceId,
|
|
895
|
+
apiToken,
|
|
896
|
+
realtime,
|
|
897
|
+
nodes: existing?.resources.nodes ?? []
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
await configureCloudflareRealtimeSecrets({
|
|
901
|
+
accountId: coordinates.accountId,
|
|
902
|
+
scriptName: coordinates.realtime.workerScriptName,
|
|
903
|
+
publishSecret: realtime.publishSecret,
|
|
904
|
+
ticketSecret: realtime.ticketSecret,
|
|
905
|
+
apiToken
|
|
906
|
+
}, fetcher);
|
|
907
|
+
}
|
|
537
908
|
const nodeResources = [];
|
|
538
909
|
const createdNodes = [];
|
|
539
910
|
for (const node of coordinates.nodes) {
|
|
@@ -549,14 +920,33 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
549
920
|
const tunnel = await ensureCloudflareTunnel({
|
|
550
921
|
accountId: coordinates.accountId,
|
|
551
922
|
name: node.tunnelName,
|
|
552
|
-
apiToken:
|
|
923
|
+
apiToken: tunnelToken
|
|
553
924
|
}, fetcher);
|
|
554
925
|
created = tunnel.created;
|
|
555
|
-
|
|
926
|
+
let mesh;
|
|
927
|
+
if (node.mesh) {
|
|
928
|
+
const ensured = await ensureCloudflareMeshConnector({
|
|
929
|
+
accountId: coordinates.accountId,
|
|
930
|
+
name: node.mesh.connectorName,
|
|
931
|
+
highAvailability: node.mesh.highAvailability === true,
|
|
932
|
+
apiToken: tunnelToken
|
|
933
|
+
}, fetcher);
|
|
934
|
+
mesh = {
|
|
935
|
+
...node.mesh,
|
|
936
|
+
connectorId: ensured.connector.id,
|
|
937
|
+
connectorToken: ensured.connectorToken
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
const { mesh: _declaredMesh, ...publicNode } = node;
|
|
941
|
+
resource = {
|
|
942
|
+
...publicNode,
|
|
943
|
+
tunnelId: tunnel.tunnel.id,
|
|
944
|
+
connectorToken: tunnel.connectorToken,
|
|
945
|
+
...mesh ? { mesh } : {}
|
|
946
|
+
};
|
|
556
947
|
}
|
|
557
948
|
nodeResources.push(resource);
|
|
558
|
-
createdNodes.push({ nodeName: node.nodeName, tunnel: created });
|
|
559
|
-
const first2 = nodeResources[0];
|
|
949
|
+
createdNodes.push({ nodeName: node.nodeName, tunnel: created, mesh: Boolean(resource.mesh) });
|
|
560
950
|
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
561
951
|
format: 1,
|
|
562
952
|
kind: "forgezero-cloudflare-bootstrap",
|
|
@@ -564,16 +954,34 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
564
954
|
updatedAt: new Date().toISOString(),
|
|
565
955
|
coordinates,
|
|
566
956
|
resources: {
|
|
567
|
-
tunnelId: first2.tunnelId,
|
|
568
957
|
kvNamespaceId: coordinates.kvNamespaceId,
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
connectorToken: first2.connectorToken,
|
|
572
|
-
kvRuntimeToken,
|
|
958
|
+
apiToken,
|
|
959
|
+
...realtime ? { realtime } : {},
|
|
573
960
|
nodes: [...nodeResources]
|
|
574
961
|
}
|
|
575
962
|
});
|
|
576
963
|
}
|
|
964
|
+
const meshResources = nodeResources.filter((node) => node.mesh);
|
|
965
|
+
for (const node of meshResources) {
|
|
966
|
+
for (const network of node.mesh.routes) {
|
|
967
|
+
await ensureCloudflarePrivateRoute({
|
|
968
|
+
accountId: coordinates.accountId,
|
|
969
|
+
tunnelId: node.mesh.connectorId,
|
|
970
|
+
network,
|
|
971
|
+
comment: `ForgeZero Mesh ${node.nodeName}`,
|
|
972
|
+
apiToken: tunnelToken
|
|
973
|
+
}, fetcher);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
if (meshResources.length) {
|
|
977
|
+
await ensureCloudflareWarpNetworkIncludes({
|
|
978
|
+
accountId: coordinates.accountId,
|
|
979
|
+
policyId: coordinates.meshDevicePolicyId,
|
|
980
|
+
networks: meshResources.flatMap((node) => node.mesh.routes),
|
|
981
|
+
descriptionPrefix: "ForgeZero Mesh",
|
|
982
|
+
apiToken: tunnelToken
|
|
983
|
+
}, fetcher);
|
|
984
|
+
}
|
|
577
985
|
for (const node of nodeResources) {
|
|
578
986
|
await configureCloudflareEdge({
|
|
579
987
|
accountId: coordinates.accountId,
|
|
@@ -581,12 +989,9 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
581
989
|
tunnelId: node.tunnelId,
|
|
582
990
|
hostname: node.hostname,
|
|
583
991
|
service: node.service,
|
|
584
|
-
apiToken:
|
|
585
|
-
tunnelApiToken: tokenFor(tokens, "tunnelApiToken"),
|
|
586
|
-
dnsApiToken: tokenFor(tokens, "dnsApiToken")
|
|
992
|
+
apiToken: tunnelToken
|
|
587
993
|
}, fetcher);
|
|
588
994
|
}
|
|
589
|
-
const first = nodeResources[0];
|
|
590
995
|
const output = {
|
|
591
996
|
format: 1,
|
|
592
997
|
kind: "forgezero-cloudflare-bootstrap",
|
|
@@ -594,16 +999,12 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
594
999
|
updatedAt: new Date().toISOString(),
|
|
595
1000
|
coordinates,
|
|
596
1001
|
resources: {
|
|
597
|
-
tunnelId: first.tunnelId,
|
|
598
1002
|
kvNamespaceId: coordinates.kvNamespaceId,
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
connectorToken: first.connectorToken,
|
|
602
|
-
kvRuntimeToken,
|
|
1003
|
+
apiToken,
|
|
1004
|
+
...realtime ? { realtime } : {},
|
|
603
1005
|
nodes: nodeResources
|
|
604
1006
|
},
|
|
605
1007
|
created: {
|
|
606
|
-
tunnel: createdNodes.some(({ tunnel }) => tunnel),
|
|
607
1008
|
nodes: createdNodes
|
|
608
1009
|
}
|
|
609
1010
|
};
|
|
@@ -622,8 +1023,8 @@ async function runAttendedCloudflareBootstrap(request, dependencies = {}) {
|
|
|
622
1023
|
nodes: plan.coordinates.nodes.map(({ nodeName, hostname }) => ({ nodeName, hostname }))
|
|
623
1024
|
};
|
|
624
1025
|
}
|
|
625
|
-
if (!request.tokenFiles
|
|
626
|
-
throw new Error("Cloudflare apply requires owner-only
|
|
1026
|
+
if (!request.tokenFiles) {
|
|
1027
|
+
throw new Error("Cloudflare apply requires exactly two owner-only token file paths");
|
|
627
1028
|
}
|
|
628
1029
|
const tokens = await readCloudflareBootstrapTokens(request.tokenFiles);
|
|
629
1030
|
const output = await applyCloudflareBootstrap(plan.coordinates, tokens, plan.outputFile, dependencies.fetcher ?? fetch);
|
|
@@ -680,20 +1081,114 @@ async function verifyCloudflareBootstrapAcceptance(checkpointPath, fetcher = fet
|
|
|
680
1081
|
nodes
|
|
681
1082
|
};
|
|
682
1083
|
}
|
|
1084
|
+
async function readCloudflareBootstrapAcceptanceEvidence(acceptancePath, expectedNodes) {
|
|
1085
|
+
let parsed;
|
|
1086
|
+
try {
|
|
1087
|
+
parsed = JSON.parse(await readOwnerOnlyFile(acceptancePath, 65536));
|
|
1088
|
+
} catch (cause) {
|
|
1089
|
+
if (cause instanceof SyntaxError)
|
|
1090
|
+
throw new Error("Cloudflare acceptance evidence is not valid JSON");
|
|
1091
|
+
throw cause;
|
|
1092
|
+
}
|
|
1093
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1094
|
+
throw new Error("Cloudflare acceptance evidence is malformed");
|
|
1095
|
+
}
|
|
1096
|
+
const evidence = parsed;
|
|
1097
|
+
if (Object.keys(evidence).some((key) => ![
|
|
1098
|
+
"format",
|
|
1099
|
+
"kind",
|
|
1100
|
+
"checkpointFile",
|
|
1101
|
+
"verifiedAt",
|
|
1102
|
+
"nodes"
|
|
1103
|
+
].includes(key)) || evidence.format !== 1 || evidence.kind !== "forgezero-cloudflare-bootstrap-acceptance" || typeof evidence.checkpointFile !== "string" || !evidence.checkpointFile || typeof evidence.verifiedAt !== "string" || Number.isNaN(Date.parse(evidence.verifiedAt)) || !Array.isArray(evidence.nodes) || evidence.nodes.length < 1 || evidence.nodes.length > 32) {
|
|
1104
|
+
throw new Error("Cloudflare acceptance evidence is malformed");
|
|
1105
|
+
}
|
|
1106
|
+
const nodes = evidence.nodes.map((item) => {
|
|
1107
|
+
if (!item || typeof item !== "object" || Array.isArray(item) || Object.keys(item).some((key) => !["nodeName", "hostname", "status"].includes(key))) {
|
|
1108
|
+
throw new Error("Cloudflare acceptance evidence contains a malformed node");
|
|
1109
|
+
}
|
|
1110
|
+
const node = item;
|
|
1111
|
+
const nodeName = node.nodeName?.trim().toLowerCase();
|
|
1112
|
+
const hostname = node.hostname?.trim().toLowerCase();
|
|
1113
|
+
if (!nodeName || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(nodeName) || !hostname || !HOSTNAME.test(hostname) || !Number.isInteger(node.status) || node.status < 200 || node.status > 299) {
|
|
1114
|
+
throw new Error("Cloudflare acceptance evidence contains a malformed node");
|
|
1115
|
+
}
|
|
1116
|
+
return { nodeName, hostname, status: node.status };
|
|
1117
|
+
});
|
|
1118
|
+
if (new Set(nodes.map(({ nodeName }) => nodeName)).size !== nodes.length || new Set(nodes.map(({ hostname }) => hostname)).size !== nodes.length) {
|
|
1119
|
+
throw new Error("Cloudflare acceptance evidence contains duplicate nodes");
|
|
1120
|
+
}
|
|
1121
|
+
if (expectedNodes) {
|
|
1122
|
+
const expected = expectedNodes.map(({ nodeName, hostname }) => ({
|
|
1123
|
+
nodeName: nodeName.trim().toLowerCase(),
|
|
1124
|
+
hostname: hostname.trim().toLowerCase()
|
|
1125
|
+
})).sort((left, right) => left.nodeName.localeCompare(right.nodeName));
|
|
1126
|
+
const observed = nodes.map(({ nodeName, hostname }) => ({ nodeName, hostname })).sort((left, right) => left.nodeName.localeCompare(right.nodeName));
|
|
1127
|
+
if (JSON.stringify(observed) !== JSON.stringify(expected)) {
|
|
1128
|
+
throw new Error("Cloudflare acceptance evidence does not cover the declared platform fleet");
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
return {
|
|
1132
|
+
format: 1,
|
|
1133
|
+
kind: "forgezero-cloudflare-bootstrap-acceptance",
|
|
1134
|
+
checkpointFile: evidence.checkpointFile,
|
|
1135
|
+
verifiedAt: evidence.verifiedAt,
|
|
1136
|
+
nodes
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
async function removeCloudflareBootstrapSecrets(checkpointPath, output) {
|
|
1140
|
+
const directory = `${resolve(checkpointPath)}.hosts`;
|
|
1141
|
+
let entries;
|
|
1142
|
+
try {
|
|
1143
|
+
const metadata = await lstat(directory);
|
|
1144
|
+
const uid = ownerUid();
|
|
1145
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink() || uid !== undefined && metadata.uid !== uid || (metadata.mode & 63) !== 0) {
|
|
1146
|
+
throw new Error(`Cloudflare host handoff directory ${directory} is not owner-only`);
|
|
1147
|
+
}
|
|
1148
|
+
entries = await readdir(directory);
|
|
1149
|
+
} catch (cause) {
|
|
1150
|
+
if (cause.code !== "ENOENT")
|
|
1151
|
+
throw cause;
|
|
1152
|
+
}
|
|
1153
|
+
if (entries) {
|
|
1154
|
+
const expected = output.coordinates.nodes.map(({ nodeName }) => `${nodeName}.json`).sort();
|
|
1155
|
+
if (JSON.stringify([...entries].sort()) !== JSON.stringify(expected)) {
|
|
1156
|
+
throw new Error("Cloudflare host handoff directory contains unexpected files; refusing secret cleanup");
|
|
1157
|
+
}
|
|
1158
|
+
for (const name of expected)
|
|
1159
|
+
await unlink(join(directory, name));
|
|
1160
|
+
await rmdir(directory);
|
|
1161
|
+
}
|
|
1162
|
+
await unlink(resolve(checkpointPath));
|
|
1163
|
+
}
|
|
1164
|
+
async function finalizeCloudflareBootstrapAcceptance(request, fetcher = fetch) {
|
|
1165
|
+
const checkpointPath = resolve(request.checkpointPath);
|
|
1166
|
+
const acceptancePath = resolve(request.acceptancePath);
|
|
1167
|
+
if (acceptancePath === checkpointPath || acceptancePath.startsWith(`${checkpointPath}.hosts/`)) {
|
|
1168
|
+
throw new Error("Cloudflare acceptance evidence must be outside the secret checkpoint and handoff directory");
|
|
1169
|
+
}
|
|
1170
|
+
const output = await readExistingOutput(checkpointPath);
|
|
1171
|
+
if (!output || output.phase !== "complete")
|
|
1172
|
+
throw new Error("Cloudflare finalization requires a completed owner checkpoint");
|
|
1173
|
+
const evidence = await verifyCloudflareBootstrapAcceptance(checkpointPath, fetcher);
|
|
1174
|
+
await writeOwnerJson(acceptancePath, evidence);
|
|
1175
|
+
await removeCloudflareBootstrapSecrets(checkpointPath, output);
|
|
1176
|
+
return evidence;
|
|
1177
|
+
}
|
|
683
1178
|
|
|
684
1179
|
// src/bootstrap.ts
|
|
685
|
-
import { createHash, createHmac, randomBytes } from "crypto";
|
|
1180
|
+
import { createHash, createHmac, randomBytes as randomBytes3 } from "crypto";
|
|
686
1181
|
import {
|
|
687
|
-
chmodSync,
|
|
688
|
-
existsSync,
|
|
689
|
-
lstatSync,
|
|
690
|
-
mkdirSync,
|
|
691
|
-
readFileSync,
|
|
692
|
-
renameSync,
|
|
693
|
-
rmSync,
|
|
694
|
-
writeFileSync
|
|
1182
|
+
chmodSync as chmodSync2,
|
|
1183
|
+
existsSync as existsSync3,
|
|
1184
|
+
lstatSync as lstatSync2,
|
|
1185
|
+
mkdirSync as mkdirSync3,
|
|
1186
|
+
readFileSync as readFileSync3,
|
|
1187
|
+
renameSync as renameSync3,
|
|
1188
|
+
rmSync as rmSync3,
|
|
1189
|
+
writeFileSync as writeFileSync3
|
|
695
1190
|
} from "fs";
|
|
696
|
-
import { dirname as
|
|
1191
|
+
import { dirname as dirname4 } from "path";
|
|
697
1192
|
import { fileURLToPath } from "url";
|
|
698
1193
|
|
|
699
1194
|
// src/agent-update-helper.ts
|
|
@@ -712,53 +1207,79 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
|
|
|
712
1207
|
var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
|
|
713
1208
|
|
|
714
1209
|
// src/version.ts
|
|
715
|
-
var VERSION = "0.1.
|
|
1210
|
+
var VERSION = "0.1.42";
|
|
716
1211
|
|
|
717
1212
|
// src/software.ts
|
|
718
|
-
var
|
|
719
|
-
var
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
1213
|
+
var PINNED_BUN_VERSION = "1.3.14";
|
|
1214
|
+
var BUN_RELEASE_SHA256 = "951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f";
|
|
1215
|
+
|
|
1216
|
+
// src/service-supervisor.ts
|
|
1217
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
1218
|
+
import { dirname as dirname2, join as join2, resolve as resolve2, sep } from "path";
|
|
1219
|
+
|
|
1220
|
+
// src/definition.ts
|
|
1221
|
+
var RESERVED_STEP_ENV = new Set([
|
|
1222
|
+
"PATH",
|
|
1223
|
+
"HOME",
|
|
1224
|
+
"SHELL",
|
|
1225
|
+
"PWD",
|
|
1226
|
+
"BUN_INSTALL",
|
|
1227
|
+
"NODE_OPTIONS",
|
|
1228
|
+
"LD_PRELOAD",
|
|
1229
|
+
"LD_LIBRARY_PATH",
|
|
1230
|
+
"GIT_SSH",
|
|
1231
|
+
"GIT_SSH_COMMAND"
|
|
1232
|
+
]);
|
|
1233
|
+
|
|
1234
|
+
// src/service-supervisor.ts
|
|
1235
|
+
var defaultHost = {
|
|
1236
|
+
write(path, content, mode) {
|
|
1237
|
+
mkdirSync(dirname2(path), { recursive: true, mode: 493 });
|
|
1238
|
+
const next = `${path}.next`;
|
|
1239
|
+
writeFileSync(next, content, { mode });
|
|
1240
|
+
renameSync(next, path);
|
|
736
1241
|
},
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1242
|
+
read: (path) => readFileSync(path, "utf8"),
|
|
1243
|
+
exists: existsSync,
|
|
1244
|
+
list: (path) => existsSync(path) ? readdirSync(path) : [],
|
|
1245
|
+
realpath: realpathSync,
|
|
1246
|
+
mkdir: (path, mode) => mkdirSync(path, { recursive: true, mode }),
|
|
1247
|
+
remove: (path) => rmSync(path, { force: true }),
|
|
1248
|
+
async exec(argv) {
|
|
1249
|
+
const child = Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe", env: {
|
|
1250
|
+
PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
1251
|
+
LANG: "C",
|
|
1252
|
+
LC_ALL: "C"
|
|
1253
|
+
} });
|
|
1254
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1255
|
+
new Response(child.stdout).text(),
|
|
1256
|
+
new Response(child.stderr).text(),
|
|
1257
|
+
child.exited
|
|
1258
|
+
]);
|
|
1259
|
+
return { exitCode, output: `${stdout}${stderr}` };
|
|
741
1260
|
},
|
|
742
|
-
{
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1261
|
+
async health(port, path) {
|
|
1262
|
+
try {
|
|
1263
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
1264
|
+
signal: AbortSignal.timeout(2000),
|
|
1265
|
+
redirect: "manual"
|
|
1266
|
+
});
|
|
1267
|
+
return response.status >= 200 && response.status < 300;
|
|
1268
|
+
} catch {
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
746
1271
|
},
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y openssh-client"
|
|
751
|
-
}
|
|
752
|
-
];
|
|
1272
|
+
sleep: (ms) => Bun.sleep(ms),
|
|
1273
|
+
now: Date.now
|
|
1274
|
+
};
|
|
753
1275
|
|
|
754
1276
|
// src/software-helper.ts
|
|
755
1277
|
var DEFAULT_SOFTWARE_HELPER_SOCKET = "/run/forgezero-software/helper.sock";
|
|
756
1278
|
var SOFTWARE_HELPER_GROUP = "forgezero-software";
|
|
757
1279
|
var SOFTWARE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-software-helper.service";
|
|
758
|
-
var MAX_REQUEST_BYTES2 =
|
|
1280
|
+
var MAX_REQUEST_BYTES2 = 128 * 1024;
|
|
759
1281
|
|
|
760
1282
|
// src/egress-policy.ts
|
|
761
|
-
var AGENT_EGRESS_TABLE = "forgezero_agent_egress";
|
|
762
1283
|
var SYSTEMD_RESOLVED_ADDRESS = "127.0.0.53";
|
|
763
1284
|
var BLOCKED_IPV4 = [
|
|
764
1285
|
"0.0.0.0/8",
|
|
@@ -818,7 +1339,7 @@ function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
|
|
|
818
1339
|
}
|
|
819
1340
|
|
|
820
1341
|
// src/provision.ts
|
|
821
|
-
import { isIP as
|
|
1342
|
+
import { isIP as isIP3 } from "net";
|
|
822
1343
|
function atLeast(version, floor) {
|
|
823
1344
|
const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
|
|
824
1345
|
const got = parse(version);
|
|
@@ -837,22 +1358,26 @@ function atLeast(version, floor) {
|
|
|
837
1358
|
}
|
|
838
1359
|
var CAPABILITY_CHECKS = {
|
|
839
1360
|
snpGuest: {
|
|
840
|
-
command: "
|
|
1361
|
+
command: "fz host check-device /dev/sev-guest",
|
|
1362
|
+
operation: { kind: "path-exists", path: "/dev/sev-guest", nodeType: "file" },
|
|
841
1363
|
satisfied: (stdout) => stdout.trim() === "yes",
|
|
842
1364
|
remedy: "Not a confidential guest. The agent will run in `enrolled` mode, which is still stronger than an API key in the application."
|
|
843
1365
|
},
|
|
844
1366
|
systemd: {
|
|
845
|
-
command: "
|
|
1367
|
+
command: "fz host check-directory /run/systemd/system",
|
|
1368
|
+
operation: { kind: "path-exists", path: "/run/systemd/system", nodeType: "directory" },
|
|
846
1369
|
satisfied: (stdout) => stdout.trim() === "yes",
|
|
847
1370
|
remedy: "systemd is what supervises the agent. On a non-systemd host, run `fz-agent` under whatever supervises services there."
|
|
848
1371
|
},
|
|
849
1372
|
bun: {
|
|
850
|
-
command: "
|
|
1373
|
+
command: "fz host check-version bun",
|
|
1374
|
+
operation: { kind: "version", argv: ["/usr/local/bin/bun", "--version"] },
|
|
851
1375
|
satisfied: (stdout) => atLeast(stdout, "1.1.0"),
|
|
852
|
-
remedy: "Install
|
|
1376
|
+
remedy: "Install the pinned Bun release with `fz bootstrap`."
|
|
853
1377
|
},
|
|
854
1378
|
python: {
|
|
855
|
-
command: "
|
|
1379
|
+
command: "fz host check-version python3",
|
|
1380
|
+
operation: { kind: "version", argv: ["/usr/bin/python3", "--version"] },
|
|
856
1381
|
satisfied: (stdout, exitCode) => exitCode === 0 && /^Python 3\./.test(stdout.trim()),
|
|
857
1382
|
remedy: "Install Python 3. It supplies the standard-library ioctl boundary for SNP reports."
|
|
858
1383
|
}
|
|
@@ -860,6 +1385,7 @@ var CAPABILITY_CHECKS = {
|
|
|
860
1385
|
var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
|
|
861
1386
|
var reasonFor = (mode) => mode === "attested" ? "SEV-SNP guest device present, so the agent can prove what it is running and the platform can refuse it if the measurement is wrong." : "No SEV-SNP guest device. The agent authenticates with its enrolment token and hybrid Ed25519 + ML-DSA signature \u2014 weaker than attestation, stronger than an API key in the application.";
|
|
862
1387
|
var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
1388
|
+
var APPLICATION_RUNTIME_USER = "forgezero-app";
|
|
863
1389
|
var DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
864
1390
|
var VAULT_GROUP = "forgezero-vault";
|
|
865
1391
|
var LIFECYCLE_GROUP = "forgezero-lifecycle";
|
|
@@ -889,11 +1415,7 @@ function agentEgressUnit(options) {
|
|
|
889
1415
|
systemdAgentEgressDirectives(runnerLoopbackPorts);
|
|
890
1416
|
const users = [user, ...deploymentEnabled ? [DEPLOYMENT_RUNNER_USER] : []];
|
|
891
1417
|
const runnerGrant = deploymentEnabled ? ` --loopback-user=${DEPLOYMENT_RUNNER_USER}` + runnerLoopbackPorts.map((port) => ` --loopback-tcp-port=${port}`).join("") + runnerPublicTcpPorts.map((port) => ` --public-tcp-port=${port}`).join("") : "";
|
|
892
|
-
const
|
|
893
|
-
...runnerLoopbackPorts.length > 0 ? [`loopback=.*:${runnerLoopbackPorts.join(",")}`] : [],
|
|
894
|
-
`public-tcp=${runnerPublicTcpPorts.join(",")}`
|
|
895
|
-
].map((pattern) => `ExecStartPost=/bin/sh -c '/usr/sbin/nft --numeric list table inet ${AGENT_EGRESS_TABLE} | /usr/bin/grep -q "${pattern}"'`).join(`
|
|
896
|
-
`) : "";
|
|
1418
|
+
const policyProof = `ExecStartPost=${bin} egress-policy-check ${users.map((name) => `--user=${name}`).join(" ")}${runnerGrant}`;
|
|
897
1419
|
return `[Unit]
|
|
898
1420
|
Description=ForgeZero Agent host egress policy
|
|
899
1421
|
Documentation=https://www.forgezero.net/docs/agent
|
|
@@ -907,7 +1429,7 @@ NotifyAccess=all
|
|
|
907
1429
|
User=root
|
|
908
1430
|
Group=root
|
|
909
1431
|
ExecStart=${bin} egress-policy ${users.map((name) => `--user=${name}`).join(" ")}${runnerGrant}
|
|
910
|
-
${
|
|
1432
|
+
${policyProof}
|
|
911
1433
|
Restart=on-failure
|
|
912
1434
|
RestartSec=2
|
|
913
1435
|
LimitCORE=0
|
|
@@ -931,6 +1453,7 @@ WantedBy=multi-user.target
|
|
|
931
1453
|
}
|
|
932
1454
|
function softwareHelperUnit(options) {
|
|
933
1455
|
const bin = options.binPath ?? "fz-agent";
|
|
1456
|
+
const root = options.deployRoot ?? "/opt/forgezero";
|
|
934
1457
|
return `[Unit]
|
|
935
1458
|
Description=ForgeZero declarative software strategy helper
|
|
936
1459
|
Documentation=https://www.forgezero.net/docs/agent
|
|
@@ -942,6 +1465,7 @@ Type=simple
|
|
|
942
1465
|
User=root
|
|
943
1466
|
Group=${SOFTWARE_HELPER_GROUP}
|
|
944
1467
|
Environment=FZ_SOFTWARE_HELPER_SOCKET=${DEFAULT_SOFTWARE_HELPER_SOCKET}
|
|
1468
|
+
Environment=FZ_DEPLOY_ROOT=${root}
|
|
945
1469
|
ExecStart=${bin} software-helper
|
|
946
1470
|
Restart=always
|
|
947
1471
|
RestartSec=2
|
|
@@ -1057,10 +1581,6 @@ var systemdPath = (value, label) => {
|
|
|
1057
1581
|
throw new Error(`invalid ${label} path`);
|
|
1058
1582
|
return value;
|
|
1059
1583
|
};
|
|
1060
|
-
var awaitSocketCommand = (path) => {
|
|
1061
|
-
const socket = systemdPath(path, "readiness socket");
|
|
1062
|
-
return `for attempt in $(seq 1 100); do test -S ${socket} && exit 0; sleep 0.1; done; exit 1`;
|
|
1063
|
-
};
|
|
1064
1584
|
var validNodeHostname = (value) => !value || value.length <= 253 && value === value.toLowerCase() && value.split(".").length >= 3 && value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
|
|
1065
1585
|
function warpConfigUnit(options) {
|
|
1066
1586
|
if (!options.warpOrganization || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.warpOrganization)) {
|
|
@@ -1161,6 +1681,8 @@ function agentEnrolmentUnit(options) {
|
|
|
1161
1681
|
const hostname = options.nodeHostname ? `Environment=FZ_NODE_HOSTNAME=${options.nodeHostname}
|
|
1162
1682
|
` : "";
|
|
1163
1683
|
const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
|
|
1684
|
+
` : "";
|
|
1685
|
+
const bootstrapSshPublicKey = options.bootstrapSshPublicKeyPath ? `Environment=FZ_BOOTSTRAP_SSH_PUBLIC_KEY_FILE=${options.bootstrapSshPublicKeyPath}
|
|
1164
1686
|
` : "";
|
|
1165
1687
|
const networkAttachment = [
|
|
1166
1688
|
options.cloudflareAccountId ? `Environment=FZ_CF_ACCOUNT_ID=${options.cloudflareAccountId}
|
|
@@ -1195,7 +1717,7 @@ Environment=FZ_SEED_CREDENTIAL=agent-seed
|
|
|
1195
1717
|
Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
|
|
1196
1718
|
Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
|
|
1197
1719
|
Environment=FZ_API=${options.apiUrl}
|
|
1198
|
-
${label}${hostname}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
|
|
1720
|
+
${label}${hostname}${gitPublicKey}${bootstrapSshPublicKey}${networkAttachment}ExecStart=${bin} enrol
|
|
1199
1721
|
# A '+' fixed command runs as root solely to remove the host-bound one-time
|
|
1200
1722
|
# ciphertext. Tenant code and the agent never receive a privilege boundary.
|
|
1201
1723
|
ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
|
|
@@ -1275,7 +1797,7 @@ function agentUnit(options) {
|
|
|
1275
1797
|
} catch {
|
|
1276
1798
|
throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL");
|
|
1277
1799
|
}
|
|
1278
|
-
if (endpoint2.protocol !== "https:" || endpoint2.username || endpoint2.password || endpoint2.search || endpoint2.hash ||
|
|
1800
|
+
if (endpoint2.protocol !== "https:" || endpoint2.username || endpoint2.password || endpoint2.search || endpoint2.hash || isIP3(endpoint2.hostname) !== 0 || !endpoint2.hostname.includes(".") || endpoint2.hostname === "localhost" || endpoint2.hostname.endsWith(".local"))
|
|
1279
1801
|
throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL without credentials, query or fragment");
|
|
1280
1802
|
telemetryEndpoint = endpoint2.toString().replace(/\/$/, "");
|
|
1281
1803
|
}
|
|
@@ -1289,9 +1811,14 @@ function agentUnit(options) {
|
|
|
1289
1811
|
if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
|
|
1290
1812
|
throw new Error("migration pull and lifecycle profile must be supplied together");
|
|
1291
1813
|
}
|
|
1292
|
-
const bootstrapEnabled = Boolean(options.pullBootstrap && options.bootstrapSshCredentialPath && options.bootstrapTargetTelemetryEndpoint);
|
|
1293
|
-
if ([
|
|
1294
|
-
|
|
1814
|
+
const bootstrapEnabled = Boolean(options.pullBootstrap && options.bootstrapSshCredentialPath && options.bootstrapSshPublicKeyPath && options.bootstrapTargetTelemetryEndpoint);
|
|
1815
|
+
if ([
|
|
1816
|
+
options.pullBootstrap,
|
|
1817
|
+
options.bootstrapSshCredentialPath,
|
|
1818
|
+
options.bootstrapSshPublicKeyPath,
|
|
1819
|
+
options.bootstrapTargetTelemetryEndpoint
|
|
1820
|
+
].some(Boolean) && !bootstrapEnabled) {
|
|
1821
|
+
throw new Error("bootstrap pull, SSH credential, public key and target telemetry endpoint must be supplied together");
|
|
1295
1822
|
}
|
|
1296
1823
|
const lifecycleHelperSocketPath = lifecycleEnabled ? systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket") : undefined;
|
|
1297
1824
|
const bootstrapSshCredentialPath = bootstrapEnabled ? systemdPath(options.bootstrapSshCredentialPath, "bootstrap SSH credential") : undefined;
|
|
@@ -1362,6 +1889,7 @@ function agentUnit(options) {
|
|
|
1362
1889
|
options.pullMigrations ? "FZ_MIGRATION_PULL=true" : null,
|
|
1363
1890
|
options.pullBootstrap ? "FZ_BOOTSTRAP_PULL=true" : null,
|
|
1364
1891
|
options.pullBootstrap ? "FZ_BOOTSTRAP_SSH_KEY_CREDENTIAL=bootstrap-ssh-key" : null,
|
|
1892
|
+
options.pullBootstrap && options.bootstrapSshPublicKeyPath ? `FZ_BOOTSTRAP_SSH_PUBLIC_KEY_FILE=${options.bootstrapSshPublicKeyPath}` : null,
|
|
1365
1893
|
options.pullBootstrap ? `FZ_BOOTSTRAP_TARGET_OTLP_ENDPOINT=${options.bootstrapTargetTelemetryEndpoint}` : null,
|
|
1366
1894
|
`FZ_AGENT_UPDATE_SOCKET=${DEFAULT_AGENT_UPDATE_SOCKET}`,
|
|
1367
1895
|
options.pullMigrations ? `FZ_LIFECYCLE_HELPER_SOCKET=${lifecycleHelperSocketPath}` : null
|
|
@@ -1467,6 +1995,55 @@ ${deploymentWrites}
|
|
|
1467
1995
|
WantedBy=multi-user.target
|
|
1468
1996
|
`;
|
|
1469
1997
|
}
|
|
1998
|
+
var renderOperation = (operation) => {
|
|
1999
|
+
if (operation.kind === "commands")
|
|
2000
|
+
return operation.commands.map(({ argv }) => argv.join(" ")).join(`
|
|
2001
|
+
`);
|
|
2002
|
+
if (operation.kind === "directories")
|
|
2003
|
+
return operation.directories.map((directory) => [
|
|
2004
|
+
"/usr/bin/install",
|
|
2005
|
+
"-d",
|
|
2006
|
+
...directory.owner ? ["-o", directory.owner] : [],
|
|
2007
|
+
...directory.group ? ["-g", directory.group] : [],
|
|
2008
|
+
"-m",
|
|
2009
|
+
directory.mode.toString(8).padStart(4, "0"),
|
|
2010
|
+
directory.path
|
|
2011
|
+
].join(" ")).join(`
|
|
2012
|
+
`);
|
|
2013
|
+
if (operation.kind === "install-runtime")
|
|
2014
|
+
return `/usr/bin/install -m 0755 ${operation.source} /opt/forgezero/agent/versions/${operation.version}/dist/fz-agent.js`;
|
|
2015
|
+
if (operation.kind === "ensure-seed")
|
|
2016
|
+
return `/usr/bin/systemd-creds encrypt --name=agent-seed - ${operation.credential}`;
|
|
2017
|
+
if (operation.kind === "ensure-git-identity")
|
|
2018
|
+
return `/usr/bin/ssh-keygen -t ed25519
|
|
2019
|
+
/usr/bin/systemd-creds encrypt --name=git-deploy-key <private> ${operation.credential}
|
|
2020
|
+
fz host write-public-key ${operation.publicKey}`;
|
|
2021
|
+
if (operation.kind === "ensure-bootstrap-ssh-identity")
|
|
2022
|
+
return `/usr/bin/ssh-keygen -t ed25519
|
|
2023
|
+
/usr/bin/systemd-creds encrypt --name=bootstrap-ssh-key <private> ${operation.credential}
|
|
2024
|
+
fz host write-public-key ${operation.publicKey}`;
|
|
2025
|
+
if (operation.kind === "ensure-enrolment")
|
|
2026
|
+
return `/usr/bin/systemd-creds encrypt --name=enrol-token ${operation.source} ${operation.credential}
|
|
2027
|
+
/usr/bin/rm -f ${operation.source}`;
|
|
2028
|
+
if (operation.kind === "wait-socket")
|
|
2029
|
+
return `fz host wait-socket ${operation.path}`;
|
|
2030
|
+
if (operation.kind === "verify-file")
|
|
2031
|
+
return `fz host verify-file ${operation.path}`;
|
|
2032
|
+
if (operation.kind === "verify-egress")
|
|
2033
|
+
return `/usr/sbin/nft --numeric list table inet forgezero_agent_egress
|
|
2034
|
+
fz-agent egress-policy-check`;
|
|
2035
|
+
if (operation.kind === "verify-resolved-stub")
|
|
2036
|
+
return "fz host verify-resolved-stub /run/systemd/resolve/stub-resolv.conf";
|
|
2037
|
+
if (operation.kind === "install-warp")
|
|
2038
|
+
return "/usr/bin/apt-get install -y cloudflare-warp";
|
|
2039
|
+
return "/usr/bin/warp-cli --accept-tos status";
|
|
2040
|
+
};
|
|
2041
|
+
var step = (label, operation, optional = false) => ({
|
|
2042
|
+
label,
|
|
2043
|
+
operation,
|
|
2044
|
+
optional: optional || undefined,
|
|
2045
|
+
command: renderOperation(operation)
|
|
2046
|
+
});
|
|
1470
2047
|
var UNIT_PATH = "/etc/systemd/system/forgezero-agent.service";
|
|
1471
2048
|
function planProvision(options) {
|
|
1472
2049
|
const mode = options.mode;
|
|
@@ -1481,9 +2058,15 @@ function planProvision(options) {
|
|
|
1481
2058
|
if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
|
|
1482
2059
|
throw new Error("migration pull and lifecycle profile must be supplied together");
|
|
1483
2060
|
}
|
|
1484
|
-
const bootstrapEnabled = Boolean(options.pullBootstrap && options.bootstrapSshCredentialPath && options.bootstrapTargetTelemetryEndpoint);
|
|
1485
|
-
if ([
|
|
1486
|
-
|
|
2061
|
+
const bootstrapEnabled = Boolean(options.pullBootstrap && options.bootstrapSshCredentialPath && options.bootstrapSshPublicKeyPath && options.bootstrapTargetTelemetryEndpoint);
|
|
2062
|
+
if ([
|
|
2063
|
+
options.pullBootstrap,
|
|
2064
|
+
options.bootstrapSshCredentialPath,
|
|
2065
|
+
options.bootstrapSshPublicKeyPath,
|
|
2066
|
+
options.bootstrapSshSourcePath,
|
|
2067
|
+
options.bootstrapTargetTelemetryEndpoint
|
|
2068
|
+
].some(Boolean) && !bootstrapEnabled) {
|
|
2069
|
+
throw new Error("bootstrap pull, SSH credential, public key and target telemetry endpoint must be supplied together");
|
|
1487
2070
|
}
|
|
1488
2071
|
const warpValues = [
|
|
1489
2072
|
options.warpOrganization,
|
|
@@ -1511,8 +2094,29 @@ function planProvision(options) {
|
|
|
1511
2094
|
const lifecycleProfilePath = lifecycleEnabled ? systemdPath(options.lifecycleProfilePath, "lifecycle profile") : undefined;
|
|
1512
2095
|
const lifecycleHelperSocketPath = lifecycleEnabled ? systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket") : undefined;
|
|
1513
2096
|
const bootstrapSshCredentialPath = bootstrapEnabled ? systemdPath(options.bootstrapSshCredentialPath, "bootstrap SSH credential") : undefined;
|
|
2097
|
+
const bootstrapSshPublicKeyPath = bootstrapEnabled ? systemdPath(options.bootstrapSshPublicKeyPath, "bootstrap SSH public key") : undefined;
|
|
2098
|
+
const bootstrapSshSourcePath = options.bootstrapSshSourcePath ? systemdPath(options.bootstrapSshSourcePath, "bootstrap SSH private-key source") : undefined;
|
|
2099
|
+
const bootstrapSshPublicKeyDir = bootstrapSshPublicKeyPath?.replace(/\/[^/]+$/, "");
|
|
1514
2100
|
const warpClientIdCredentialPath = warpEnabled ? systemdPath(options.warpClientIdCredentialPath, "WARP client-id credential") : undefined;
|
|
1515
2101
|
const warpClientSecretCredentialPath = warpEnabled ? systemdPath(options.warpClientSecretCredentialPath, "WARP client-secret credential") : undefined;
|
|
2102
|
+
const enabledUnits = [
|
|
2103
|
+
"forgezero-agent.socket",
|
|
2104
|
+
"forgezero-agent-update-helper.service",
|
|
2105
|
+
...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
|
|
2106
|
+
...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
|
|
2107
|
+
...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
|
|
2108
|
+
...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
|
|
2109
|
+
...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : [],
|
|
2110
|
+
"forgezero-agent.service"
|
|
2111
|
+
];
|
|
2112
|
+
const restartedUnits = [
|
|
2113
|
+
"forgezero-agent-update-helper.service",
|
|
2114
|
+
...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
|
|
2115
|
+
...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
|
|
2116
|
+
...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
|
|
2117
|
+
...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
|
|
2118
|
+
...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : []
|
|
2119
|
+
];
|
|
1516
2120
|
return {
|
|
1517
2121
|
mode,
|
|
1518
2122
|
reason: reasonFor(mode),
|
|
@@ -1557,157 +2161,117 @@ function planProvision(options) {
|
|
|
1557
2161
|
socketPath: options.socketPath,
|
|
1558
2162
|
user,
|
|
1559
2163
|
steps: [
|
|
1560
|
-
...options.enforceEgress ? [{
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
{
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
},
|
|
1568
|
-
{
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
}] : [],
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
{
|
|
1589
|
-
label: "service account",
|
|
1590
|
-
command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
|
|
1591
|
-
},
|
|
1592
|
-
{
|
|
1593
|
-
label: "bind service account to vault group",
|
|
1594
|
-
command: `usermod -g ${VAULT_GROUP} ${user}`
|
|
1595
|
-
},
|
|
1596
|
-
{
|
|
1597
|
-
label: "grant verified Agent update access",
|
|
1598
|
-
command: `usermod -a -G ${AGENT_UPDATE_GROUP} ${user}`
|
|
1599
|
-
},
|
|
1600
|
-
...lifecycleEnabled ? [{
|
|
1601
|
-
label: "grant lifecycle helper socket access",
|
|
1602
|
-
command: `usermod -a -G ${LIFECYCLE_GROUP} ${user}`
|
|
1603
|
-
}] : [],
|
|
1604
|
-
...deploymentEnabled ? [{
|
|
1605
|
-
label: "credential-free deployment account",
|
|
1606
|
-
command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP},${SOFTWARE_HELPER_GROUP} ${user}`
|
|
1607
|
-
}] : [],
|
|
1608
|
-
{
|
|
1609
|
-
label: "credential directory",
|
|
1610
|
-
command: `install -d -o root -g root -m 0700 ${credentialDir}`
|
|
1611
|
-
},
|
|
1612
|
-
{
|
|
1613
|
-
label: "Agent state directory",
|
|
1614
|
-
command: "install -d -o root -g root -m 0750 /var/lib/forgezero"
|
|
1615
|
-
},
|
|
1616
|
-
{
|
|
1617
|
-
label: "encrypted node identity",
|
|
1618
|
-
command: `test -s ${seedCredentialPath} || { ` + `openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\\n' | ` + `systemd-creds encrypt --name=agent-seed - ${seedCredentialPath}; ` + `chmod 0400 ${seedCredentialPath}; }`
|
|
1619
|
-
},
|
|
2164
|
+
...options.enforceEgress ? [step("Ubuntu Agent egress prerequisites", { kind: "commands", commands: [
|
|
2165
|
+
{ argv: ["/usr/bin/apt-get", "update", "-qq"] },
|
|
2166
|
+
{ argv: ["/usr/bin/apt-get", "install", "-y", "nftables"] },
|
|
2167
|
+
{ argv: ["/usr/bin/systemctl", "enable", "--now", "systemd-resolved.service"] }
|
|
2168
|
+
] }), step("prove systemd-resolved stub ownership", { kind: "verify-resolved-stub" })] : [],
|
|
2169
|
+
step("vault socket access group", { kind: "commands", commands: [{ argv: ["/usr/sbin/groupadd", "--system", VAULT_GROUP], acceptedExitCodes: [0, 9] }] }),
|
|
2170
|
+
step("Agent update helper access group", { kind: "commands", commands: [{ argv: ["/usr/sbin/groupadd", "--system", AGENT_UPDATE_GROUP], acceptedExitCodes: [0, 9] }] }),
|
|
2171
|
+
...sourceBinPath && binPath ? [step("root-owned agent runtime", { kind: "install-runtime", source: sourceBinPath, binary: binPath, version: VERSION })] : [],
|
|
2172
|
+
...warpEnabled ? [step("Cloudflare One client for Ubuntu 26.04", { kind: "install-warp" })] : [],
|
|
2173
|
+
...deploymentEnabled ? [step("deployment isolation group", { kind: "commands", commands: [
|
|
2174
|
+
{ argv: ["/usr/sbin/groupadd", "--system", DEPLOYMENT_GROUP], acceptedExitCodes: [0, 9] },
|
|
2175
|
+
{ argv: ["/usr/sbin/groupadd", "--system", SOFTWARE_HELPER_GROUP], acceptedExitCodes: [0, 9] }
|
|
2176
|
+
] })] : [],
|
|
2177
|
+
...lifecycleEnabled ? [step("lifecycle helper access group", { kind: "commands", commands: [{ argv: ["/usr/sbin/groupadd", "--system", LIFECYCLE_GROUP], acceptedExitCodes: [0, 9] }] })] : [],
|
|
2178
|
+
step("service account", { kind: "commands", commands: [{ argv: ["/usr/sbin/useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", user], acceptedExitCodes: [0, 9] }] }),
|
|
2179
|
+
step("bind service account to vault group", { kind: "commands", commands: [{ argv: ["/usr/sbin/usermod", "-g", VAULT_GROUP, user] }] }),
|
|
2180
|
+
step("grant verified Agent update access", { kind: "commands", commands: [{ argv: ["/usr/sbin/usermod", "-a", "-G", AGENT_UPDATE_GROUP, user] }] }),
|
|
2181
|
+
...lifecycleEnabled ? [step("grant lifecycle helper socket access", { kind: "commands", commands: [{ argv: ["/usr/sbin/usermod", "-a", "-G", LIFECYCLE_GROUP, user] }] })] : [],
|
|
2182
|
+
...deploymentEnabled ? [step("credential-free deployment account", { kind: "commands", commands: [
|
|
2183
|
+
{ argv: ["/usr/sbin/useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", "--gid", DEPLOYMENT_GROUP, DEPLOYMENT_RUNNER_USER], acceptedExitCodes: [0, 9] },
|
|
2184
|
+
{ argv: ["/usr/sbin/useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", "--gid", VAULT_GROUP, APPLICATION_RUNTIME_USER], acceptedExitCodes: [0, 9] },
|
|
2185
|
+
{ argv: ["/usr/sbin/usermod", "-a", "-G", DEPLOYMENT_GROUP, APPLICATION_RUNTIME_USER] },
|
|
2186
|
+
{ argv: ["/usr/sbin/usermod", "-a", "-G", `${DEPLOYMENT_GROUP},${SOFTWARE_HELPER_GROUP}`, user] }
|
|
2187
|
+
] })] : [],
|
|
2188
|
+
step("credential and state directories", { kind: "directories", directories: [
|
|
2189
|
+
{ path: credentialDir, mode: 448, owner: "root", group: "root" },
|
|
2190
|
+
{ path: "/var/lib/forgezero", mode: 488, owner: "root", group: "root" }
|
|
2191
|
+
] }),
|
|
2192
|
+
step("encrypted node identity", { kind: "ensure-seed", credential: seedCredentialPath }),
|
|
1620
2193
|
...options.generateGitIdentity && gitCredentialPath && gitPublicKeyPath ? [
|
|
1621
|
-
{
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
{
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
2194
|
+
step("Git deploy identity directory", { kind: "directories", directories: [{ path: gitPublicKeyDir, mode: 493, owner: "root", group: "root" }] }),
|
|
2195
|
+
step("unique encrypted Git deploy identity", { kind: "ensure-git-identity", credential: gitCredentialPath, publicKey: gitPublicKeyPath })
|
|
2196
|
+
] : [],
|
|
2197
|
+
...bootstrapEnabled ? [
|
|
2198
|
+
step("bootstrap SSH public identity directory", { kind: "directories", directories: [
|
|
2199
|
+
{ path: bootstrapSshPublicKeyDir, mode: 493, owner: "root", group: "root" }
|
|
2200
|
+
] }),
|
|
2201
|
+
step("unique encrypted bootstrap SSH identity", {
|
|
2202
|
+
kind: "ensure-bootstrap-ssh-identity",
|
|
2203
|
+
credential: bootstrapSshCredentialPath,
|
|
2204
|
+
publicKey: bootstrapSshPublicKeyPath,
|
|
2205
|
+
...bootstrapSshSourcePath ? { source: bootstrapSshSourcePath } : {}
|
|
2206
|
+
})
|
|
1629
2207
|
] : [],
|
|
1630
2208
|
...enrolmentEnabled ? [
|
|
1631
|
-
{
|
|
1632
|
-
|
|
1633
|
-
command: `install -d -o ${user} -g ${user} -m 0700 ${enrolStateDir}`
|
|
1634
|
-
},
|
|
1635
|
-
{
|
|
1636
|
-
label: "encrypted one-time enrolment capability",
|
|
1637
|
-
command: `test -s ${enrolStatePath} || test -s ${enrolTokenCredentialPath} || { test -r ${enrolTokenSourcePath}; ` + `systemd-creds encrypt --name=enrol-token ${enrolTokenSourcePath} ${enrolTokenCredentialPath}; ` + `chmod 0400 ${enrolTokenCredentialPath}; rm -f ${enrolTokenSourcePath}; }`
|
|
1638
|
-
}
|
|
2209
|
+
step("enrolment state directory", { kind: "directories", directories: [{ path: enrolStateDir, mode: 448, owner: user, group: user }] }),
|
|
2210
|
+
step("encrypted one-time enrolment capability", { kind: "ensure-enrolment", state: enrolStatePath, source: enrolTokenSourcePath, credential: enrolTokenCredentialPath })
|
|
1639
2211
|
] : [],
|
|
1640
|
-
...deploymentEnabled ? [{
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
},
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
}] : []
|
|
1677
|
-
{ label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
|
|
1678
|
-
{ label: "prove the public Vault socket exists", command: awaitSocketCommand(options.socketPath) },
|
|
1679
|
-
{ label: "prove the Agent Vault backend exists", command: awaitSocketCommand(agentBackendSocketPath(options.socketPath)) },
|
|
1680
|
-
{ label: "prove the Agent update helper exists", command: awaitSocketCommand(DEFAULT_AGENT_UPDATE_SOCKET) },
|
|
1681
|
-
...deploymentEnabled ? [{
|
|
1682
|
-
label: "prove the deployment runner socket exists",
|
|
1683
|
-
command: awaitSocketCommand(DEPLOYMENT_RUNNER_SOCKET)
|
|
1684
|
-
}, {
|
|
1685
|
-
label: "prove the software strategy helper socket exists",
|
|
1686
|
-
command: awaitSocketCommand(DEFAULT_SOFTWARE_HELPER_SOCKET)
|
|
1687
|
-
}] : [],
|
|
1688
|
-
...lifecycleEnabled ? [{
|
|
1689
|
-
label: "prove the lifecycle helper socket exists",
|
|
1690
|
-
command: awaitSocketCommand(lifecycleHelperSocketPath)
|
|
1691
|
-
}] : [],
|
|
1692
|
-
...warpEnabled ? [{
|
|
1693
|
-
label: "prove Cloudflare WARP is connected",
|
|
1694
|
-
command: `warp-cli --accept-tos status | grep -Eiq '(^|[[:space:]])Connected([[:space:]]|$)'`
|
|
1695
|
-
}] : [],
|
|
1696
|
-
...options.repository ? [{
|
|
1697
|
-
label: "prove the deployment control socket exists",
|
|
1698
|
-
command: awaitSocketCommand(options.controlSocketPath ?? "/run/forgezero/control.sock")
|
|
1699
|
-
}] : []
|
|
2212
|
+
...deploymentEnabled ? [step("deployment directories", { kind: "directories", directories: [
|
|
2213
|
+
{ path: deployRoot, mode: 493, owner: "root", group: "root" },
|
|
2214
|
+
{ path: `${deployRoot}/releases`, mode: 2040, owner: "root", group: DEPLOYMENT_GROUP },
|
|
2215
|
+
{ path: `${deployRoot}/cache`, mode: 488, owner: user, group: user },
|
|
2216
|
+
{ path: `${deployRoot}/capacity`, mode: 448, owner: user, group: user },
|
|
2217
|
+
{ path: `${deployRoot}/agent-home`, mode: 448, owner: user, group: user },
|
|
2218
|
+
{ path: `${deployRoot}/runner-home`, mode: 448, owner: DEPLOYMENT_RUNNER_USER, group: DEPLOYMENT_GROUP },
|
|
2219
|
+
{ path: `${deployRoot}/runner-home/cache`, mode: 448, owner: DEPLOYMENT_RUNNER_USER, group: DEPLOYMENT_GROUP },
|
|
2220
|
+
{ path: `${deployRoot}/app-home`, mode: 448, owner: APPLICATION_RUNTIME_USER, group: VAULT_GROUP }
|
|
2221
|
+
] })] : [],
|
|
2222
|
+
step("reload units", { kind: "commands", commands: [{ argv: ["/usr/bin/systemctl", "daemon-reload"] }] }),
|
|
2223
|
+
...deploymentEnabled ? [step("remove unsupported deployment socket activation", { kind: "commands", commands: [
|
|
2224
|
+
{ argv: ["/usr/bin/systemctl", "disable", "--now", "forgezero-deploy-runner.socket"], acceptedExitCodes: [0, 1, 5] },
|
|
2225
|
+
{ argv: ["/usr/bin/rm", "-f", "/etc/systemd/system/forgezero-deploy-runner.socket"] },
|
|
2226
|
+
{ argv: ["/usr/bin/systemctl", "daemon-reload"] }
|
|
2227
|
+
] })] : [],
|
|
2228
|
+
step("enable and converge services", { kind: "commands", commands: [
|
|
2229
|
+
{ argv: ["/usr/bin/systemctl", "enable", ...enabledUnits] },
|
|
2230
|
+
{ argv: ["/usr/bin/systemctl", "reset-failed", "forgezero-agent.service"], acceptedExitCodes: [0, 1] },
|
|
2231
|
+
...restartedUnits.length ? [{ argv: ["/usr/bin/systemctl", "restart", ...restartedUnits] }] : [],
|
|
2232
|
+
{ argv: ["/usr/bin/systemctl", "restart", "forgezero-agent.socket"] },
|
|
2233
|
+
{ argv: ["/usr/bin/systemctl", "reset-failed", "forgezero-agent.service"], acceptedExitCodes: [0, 1] },
|
|
2234
|
+
{ argv: ["/usr/bin/systemctl", "restart", "forgezero-agent.service"] }
|
|
2235
|
+
] }),
|
|
2236
|
+
...enrolmentEnabled ? [step("prove the compute binding is durable", { kind: "verify-file", path: enrolStatePath })] : [],
|
|
2237
|
+
...options.enforceEgress ? [step("prove the Agent egress policy is active", { kind: "verify-egress", runnerPublicTcpPorts, runnerLoopbackPorts })] : [],
|
|
2238
|
+
step("prove it is running", { kind: "commands", commands: [{ argv: ["/usr/bin/systemctl", "is-active", "forgezero-agent.service"] }] }),
|
|
2239
|
+
step("prove the public Vault socket exists", { kind: "wait-socket", path: options.socketPath, attempts: 100, intervalMs: 100 }),
|
|
2240
|
+
step("prove the Agent Vault backend exists", { kind: "wait-socket", path: agentBackendSocketPath(options.socketPath), attempts: 100, intervalMs: 100 }),
|
|
2241
|
+
step("prove the Agent update helper exists", { kind: "wait-socket", path: DEFAULT_AGENT_UPDATE_SOCKET, attempts: 100, intervalMs: 100 }),
|
|
2242
|
+
...deploymentEnabled ? [
|
|
2243
|
+
step("prove the deployment runner socket exists", { kind: "wait-socket", path: DEPLOYMENT_RUNNER_SOCKET, attempts: 100, intervalMs: 100 }),
|
|
2244
|
+
step("prove the software strategy helper socket exists", { kind: "wait-socket", path: DEFAULT_SOFTWARE_HELPER_SOCKET, attempts: 100, intervalMs: 100 })
|
|
2245
|
+
] : [],
|
|
2246
|
+
...lifecycleEnabled ? [step("prove the lifecycle helper socket exists", { kind: "wait-socket", path: lifecycleHelperSocketPath, attempts: 100, intervalMs: 100 })] : [],
|
|
2247
|
+
...warpEnabled ? [step("prove Cloudflare WARP is connected", { kind: "verify-warp" })] : [],
|
|
2248
|
+
...options.repository ? [step("prove the deployment control socket exists", { kind: "wait-socket", path: options.controlSocketPath ?? "/run/forgezero/control.sock", attempts: 100, intervalMs: 100 })] : []
|
|
1700
2249
|
]
|
|
1701
2250
|
};
|
|
1702
2251
|
}
|
|
1703
2252
|
|
|
1704
2253
|
// src/cli/agent-install.ts
|
|
2254
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
2255
|
+
import {
|
|
2256
|
+
chmodSync,
|
|
2257
|
+
copyFileSync,
|
|
2258
|
+
existsSync as existsSync2,
|
|
2259
|
+
lstatSync,
|
|
2260
|
+
mkdirSync as mkdirSync2,
|
|
2261
|
+
readFileSync as readFileSync2,
|
|
2262
|
+
realpathSync as realpathSync2,
|
|
2263
|
+
renameSync as renameSync2,
|
|
2264
|
+
rmSync as rmSync2,
|
|
2265
|
+
symlinkSync,
|
|
2266
|
+
writeFileSync as writeFileSync2
|
|
2267
|
+
} from "fs";
|
|
2268
|
+
import { dirname as dirname3 } from "path";
|
|
1705
2269
|
async function readCapabilities(run) {
|
|
1706
2270
|
const answers = {};
|
|
1707
2271
|
const checks = Object.entries(CAPABILITY_CHECKS);
|
|
1708
2272
|
for (const [id, check] of checks) {
|
|
1709
2273
|
try {
|
|
1710
|
-
const result = await run(check.
|
|
2274
|
+
const result = await run(check.operation);
|
|
1711
2275
|
answers[id] = check.satisfied(result.stdout, result.exitCode);
|
|
1712
2276
|
} catch {
|
|
1713
2277
|
answers[id] = false;
|
|
@@ -1715,10 +2279,253 @@ async function readCapabilities(run) {
|
|
|
1715
2279
|
}
|
|
1716
2280
|
return answers;
|
|
1717
2281
|
}
|
|
1718
|
-
async
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1721
|
-
|
|
2282
|
+
var fixed = async (argv, stdin) => {
|
|
2283
|
+
const child = Bun.spawn([...argv], {
|
|
2284
|
+
stdin: stdin === undefined ? "ignore" : "pipe",
|
|
2285
|
+
stdout: "pipe",
|
|
2286
|
+
stderr: "pipe",
|
|
2287
|
+
env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", LANG: "C", LC_ALL: "C", DEBIAN_FRONTEND: "noninteractive" }
|
|
2288
|
+
});
|
|
2289
|
+
if (stdin !== undefined && child.stdin && typeof child.stdin !== "number") {
|
|
2290
|
+
child.stdin.write(stdin);
|
|
2291
|
+
child.stdin.end();
|
|
2292
|
+
}
|
|
2293
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
2294
|
+
new Response(child.stdout).text(),
|
|
2295
|
+
new Response(child.stderr).text(),
|
|
2296
|
+
child.exited
|
|
2297
|
+
]);
|
|
2298
|
+
return { stdout: `${stdout}${stderr}`, exitCode };
|
|
2299
|
+
};
|
|
2300
|
+
var runProvisionOperation = async (operation) => {
|
|
2301
|
+
if (operation.kind === "commands") {
|
|
2302
|
+
let output = "";
|
|
2303
|
+
for (const command of operation.commands) {
|
|
2304
|
+
const result = await fixed(command.argv);
|
|
2305
|
+
output += result.stdout;
|
|
2306
|
+
if (!(command.acceptedExitCodes ?? [0]).includes(result.exitCode))
|
|
2307
|
+
return { stdout: output, exitCode: result.exitCode };
|
|
2308
|
+
}
|
|
2309
|
+
return { stdout: output, exitCode: 0 };
|
|
2310
|
+
}
|
|
2311
|
+
if (operation.kind === "directories") {
|
|
2312
|
+
for (const directory of operation.directories) {
|
|
2313
|
+
const argv = ["/usr/bin/install", "-d", "-m", directory.mode.toString(8).padStart(4, "0")];
|
|
2314
|
+
if (directory.owner)
|
|
2315
|
+
argv.push("-o", directory.owner);
|
|
2316
|
+
if (directory.group)
|
|
2317
|
+
argv.push("-g", directory.group);
|
|
2318
|
+
argv.push(directory.path);
|
|
2319
|
+
const result = await fixed(argv);
|
|
2320
|
+
if (result.exitCode !== 0)
|
|
2321
|
+
return result;
|
|
2322
|
+
}
|
|
2323
|
+
return { stdout: "", exitCode: 0 };
|
|
2324
|
+
}
|
|
2325
|
+
if (operation.kind === "install-runtime") {
|
|
2326
|
+
const release = `/opt/forgezero/agent/versions/${operation.version}`;
|
|
2327
|
+
mkdirSync2(`${release}/dist`, { recursive: true, mode: 493 });
|
|
2328
|
+
mkdirSync2(dirname3(operation.binary), { recursive: true, mode: 493 });
|
|
2329
|
+
copyFileSync(operation.source, `${release}/dist/fz-agent.js`);
|
|
2330
|
+
chmodSync(`${release}/dist/fz-agent.js`, 493);
|
|
2331
|
+
const gitSshSource = `${dirname3(operation.source)}/fz-git-ssh.js`;
|
|
2332
|
+
if (!existsSync2(gitSshSource))
|
|
2333
|
+
return { stdout: "packaged fz-git-ssh.js is missing", exitCode: 1 };
|
|
2334
|
+
copyFileSync(gitSshSource, `${release}/dist/fz-git-ssh.js`);
|
|
2335
|
+
chmodSync(`${release}/dist/fz-git-ssh.js`, 493);
|
|
2336
|
+
const pending = "/opt/forgezero/agent/current.next";
|
|
2337
|
+
rmSync2(pending, { force: true });
|
|
2338
|
+
symlinkSync(`versions/${operation.version}`, pending);
|
|
2339
|
+
renameSync2(pending, "/opt/forgezero/agent/current");
|
|
2340
|
+
rmSync2(operation.binary, { force: true });
|
|
2341
|
+
symlinkSync("/opt/forgezero/agent/current/dist/fz-agent.js", operation.binary);
|
|
2342
|
+
const gitSshBinary = "/usr/local/lib/forgezero/agent/fz-git-ssh";
|
|
2343
|
+
rmSync2(gitSshBinary, { force: true });
|
|
2344
|
+
symlinkSync("/opt/forgezero/agent/current/dist/fz-git-ssh.js", gitSshBinary);
|
|
2345
|
+
return { stdout: "", exitCode: 0 };
|
|
2346
|
+
}
|
|
2347
|
+
if (operation.kind === "ensure-seed") {
|
|
2348
|
+
if (existsSync2(operation.credential) && lstatSync(operation.credential).size > 0)
|
|
2349
|
+
return { stdout: "", exitCode: 0 };
|
|
2350
|
+
const seed = randomBytes2(32).toString("base64url");
|
|
2351
|
+
const result = await fixed(["/usr/bin/systemd-creds", "encrypt", "--name=agent-seed", "-", operation.credential], seed);
|
|
2352
|
+
if (result.exitCode === 0)
|
|
2353
|
+
chmodSync(operation.credential, 256);
|
|
2354
|
+
return result;
|
|
2355
|
+
}
|
|
2356
|
+
if (operation.kind === "ensure-git-identity") {
|
|
2357
|
+
const key = "/run/forgezero-git-deploy-key";
|
|
2358
|
+
const publicKey = `${key}.pub`;
|
|
2359
|
+
try {
|
|
2360
|
+
if (!existsSync2(operation.credential) || lstatSync(operation.credential).size < 1) {
|
|
2361
|
+
rmSync2(key, { force: true });
|
|
2362
|
+
rmSync2(publicKey, { force: true });
|
|
2363
|
+
let result = await fixed(["/usr/bin/ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "forgezero-compute", "-f", key]);
|
|
2364
|
+
if (result.exitCode !== 0)
|
|
2365
|
+
return result;
|
|
2366
|
+
result = await fixed(["/usr/bin/systemd-creds", "encrypt", "--name=git-deploy-key", key, operation.credential]);
|
|
2367
|
+
if (result.exitCode !== 0)
|
|
2368
|
+
return result;
|
|
2369
|
+
chmodSync(operation.credential, 256);
|
|
2370
|
+
}
|
|
2371
|
+
if (!existsSync2(operation.publicKey) || lstatSync(operation.publicKey).size < 1) {
|
|
2372
|
+
if (!existsSync2(key)) {
|
|
2373
|
+
const decrypted = await fixed(["/usr/bin/systemd-creds", "decrypt", "--name=git-deploy-key", operation.credential, key]);
|
|
2374
|
+
if (decrypted.exitCode !== 0)
|
|
2375
|
+
return decrypted;
|
|
2376
|
+
}
|
|
2377
|
+
const derived = await fixed(["/usr/bin/ssh-keygen", "-y", "-f", key]);
|
|
2378
|
+
if (derived.exitCode !== 0)
|
|
2379
|
+
return derived;
|
|
2380
|
+
writeFileSync2(operation.publicKey, `${derived.stdout.trim()} forgezero-compute
|
|
2381
|
+
`, { mode: 292 });
|
|
2382
|
+
}
|
|
2383
|
+
return { stdout: "", exitCode: 0 };
|
|
2384
|
+
} finally {
|
|
2385
|
+
rmSync2(key, { force: true });
|
|
2386
|
+
rmSync2(publicKey, { force: true });
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
if (operation.kind === "ensure-bootstrap-ssh-identity") {
|
|
2390
|
+
const key = "/run/forgezero-bootstrap-ssh-key";
|
|
2391
|
+
const generatedPublicKey = `${key}.pub`;
|
|
2392
|
+
try {
|
|
2393
|
+
if (!existsSync2(operation.credential) || lstatSync(operation.credential).size < 1) {
|
|
2394
|
+
rmSync2(key, { force: true });
|
|
2395
|
+
rmSync2(generatedPublicKey, { force: true });
|
|
2396
|
+
let result;
|
|
2397
|
+
if (operation.source) {
|
|
2398
|
+
const source = existsSync2(operation.source) ? lstatSync(operation.source) : undefined;
|
|
2399
|
+
if (!source?.isFile() || source.isSymbolicLink() || source.uid !== 0 || source.nlink !== 1 || (source.mode & 63) !== 0 || source.size < 32 || source.size > 16 * 1024) {
|
|
2400
|
+
return { stdout: "bootstrap SSH private-key source is missing or unsafe", exitCode: 1 };
|
|
2401
|
+
}
|
|
2402
|
+
copyFileSync(operation.source, key);
|
|
2403
|
+
chmodSync(key, 384);
|
|
2404
|
+
} else {
|
|
2405
|
+
result = await fixed(["/usr/bin/ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "forgezero-bootstrap-runner", "-f", key]);
|
|
2406
|
+
if (result.exitCode !== 0)
|
|
2407
|
+
return result;
|
|
2408
|
+
}
|
|
2409
|
+
result = await fixed(["/usr/bin/ssh-keygen", "-y", "-f", key]);
|
|
2410
|
+
if (result.exitCode !== 0 || !/^ssh-ed25519 [A-Za-z0-9+/]+={0,3}\s*$/.test(result.stdout)) {
|
|
2411
|
+
return { stdout: "bootstrap SSH private key is not a valid Ed25519 OpenSSH key", exitCode: 1 };
|
|
2412
|
+
}
|
|
2413
|
+
result = await fixed(["/usr/bin/systemd-creds", "encrypt", "--name=bootstrap-ssh-key", key, operation.credential]);
|
|
2414
|
+
if (result.exitCode !== 0)
|
|
2415
|
+
return result;
|
|
2416
|
+
chmodSync(operation.credential, 256);
|
|
2417
|
+
}
|
|
2418
|
+
if (!existsSync2(operation.publicKey) || lstatSync(operation.publicKey).size < 1) {
|
|
2419
|
+
if (!existsSync2(key)) {
|
|
2420
|
+
const decrypted = await fixed(["/usr/bin/systemd-creds", "decrypt", "--name=bootstrap-ssh-key", operation.credential, key]);
|
|
2421
|
+
if (decrypted.exitCode !== 0)
|
|
2422
|
+
return decrypted;
|
|
2423
|
+
chmodSync(key, 384);
|
|
2424
|
+
}
|
|
2425
|
+
const derived = await fixed(["/usr/bin/ssh-keygen", "-y", "-f", key]);
|
|
2426
|
+
if (derived.exitCode !== 0 || !/^ssh-ed25519 [A-Za-z0-9+/]+={0,3}\s*$/.test(derived.stdout)) {
|
|
2427
|
+
return { stdout: "bootstrap SSH public key derivation failed", exitCode: 1 };
|
|
2428
|
+
}
|
|
2429
|
+
mkdirSync2(dirname3(operation.publicKey), { recursive: true, mode: 493 });
|
|
2430
|
+
writeFileSync2(operation.publicKey, `${derived.stdout.trim()} forgezero-bootstrap-runner
|
|
2431
|
+
`, { mode: 292 });
|
|
2432
|
+
chmodSync(operation.publicKey, 292);
|
|
2433
|
+
}
|
|
2434
|
+
if (operation.source)
|
|
2435
|
+
rmSync2(operation.source, { force: true });
|
|
2436
|
+
return { stdout: "", exitCode: 0 };
|
|
2437
|
+
} finally {
|
|
2438
|
+
rmSync2(key, { force: true });
|
|
2439
|
+
rmSync2(generatedPublicKey, { force: true });
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
if (operation.kind === "ensure-enrolment") {
|
|
2443
|
+
if (existsSync2(operation.state) && lstatSync(operation.state).size > 0 || existsSync2(operation.credential) && lstatSync(operation.credential).size > 0)
|
|
2444
|
+
return { stdout: "", exitCode: 0 };
|
|
2445
|
+
if (!existsSync2(operation.source))
|
|
2446
|
+
return { stdout: "enrolment source is missing", exitCode: 1 };
|
|
2447
|
+
const result = await fixed(["/usr/bin/systemd-creds", "encrypt", "--name=enrol-token", operation.source, operation.credential]);
|
|
2448
|
+
if (result.exitCode === 0) {
|
|
2449
|
+
chmodSync(operation.credential, 256);
|
|
2450
|
+
rmSync2(operation.source, { force: true });
|
|
2451
|
+
}
|
|
2452
|
+
return result;
|
|
2453
|
+
}
|
|
2454
|
+
if (operation.kind === "wait-socket") {
|
|
2455
|
+
for (let attempt = 0;attempt < operation.attempts; attempt += 1) {
|
|
2456
|
+
try {
|
|
2457
|
+
if (lstatSync(operation.path).isSocket())
|
|
2458
|
+
return { stdout: "", exitCode: 0 };
|
|
2459
|
+
} catch {}
|
|
2460
|
+
await Bun.sleep(operation.intervalMs);
|
|
2461
|
+
}
|
|
2462
|
+
return { stdout: `socket did not become ready: ${operation.path}`, exitCode: 1 };
|
|
2463
|
+
}
|
|
2464
|
+
if (operation.kind === "verify-file")
|
|
2465
|
+
return existsSync2(operation.path) && lstatSync(operation.path).size > 0 ? { stdout: "", exitCode: 0 } : { stdout: "required file is empty", exitCode: 1 };
|
|
2466
|
+
if (operation.kind === "verify-egress") {
|
|
2467
|
+
const active = await fixed(["/usr/bin/systemctl", "is-active", "forgezero-agent-egress.service"]);
|
|
2468
|
+
if (active.exitCode !== 0)
|
|
2469
|
+
return active;
|
|
2470
|
+
const policy = await fixed(["/usr/sbin/nft", "--numeric", "list", "table", "inet", "forgezero_agent_egress"]);
|
|
2471
|
+
const required = [
|
|
2472
|
+
"forgezero-agent-egress-v1",
|
|
2473
|
+
`public-tcp=${operation.runnerPublicTcpPorts.join(",")}`,
|
|
2474
|
+
...operation.runnerLoopbackPorts.length ? [`:${operation.runnerLoopbackPorts.join(",")}`] : []
|
|
2475
|
+
];
|
|
2476
|
+
return policy.exitCode === 0 && required.every((part) => policy.stdout.includes(part)) ? { stdout: policy.stdout, exitCode: 0 } : { stdout: policy.stdout, exitCode: 1 };
|
|
2477
|
+
}
|
|
2478
|
+
if (operation.kind === "verify-resolved-stub") {
|
|
2479
|
+
try {
|
|
2480
|
+
const expected = "/run/systemd/resolve/stub-resolv.conf";
|
|
2481
|
+
return realpathSync2("/etc/resolv.conf") === expected && realpathSync2(expected) === expected ? { stdout: expected, exitCode: 0 } : { stdout: "resolver stub mismatch", exitCode: 1 };
|
|
2482
|
+
} catch {
|
|
2483
|
+
return { stdout: "resolver stub missing", exitCode: 1 };
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
if (operation.kind === "install-warp") {
|
|
2487
|
+
const os = readFileSync2("/etc/os-release", "utf8");
|
|
2488
|
+
if (!/^ID=ubuntu$/m.test(os) || !/^VERSION_ID="?26\.04"?$/m.test(os))
|
|
2489
|
+
return { stdout: "unsupported WARP host OS", exitCode: 1 };
|
|
2490
|
+
const response = await fetch("https://pkg.cloudflareclient.com/pubkey.gpg", { signal: AbortSignal.timeout(30000) });
|
|
2491
|
+
if (!response.ok)
|
|
2492
|
+
return { stdout: `WARP key HTTP ${response.status}`, exitCode: 1 };
|
|
2493
|
+
mkdirSync2("/usr/share/keyrings", { recursive: true, mode: 493 });
|
|
2494
|
+
mkdirSync2("/etc/apt/sources.list.d", { recursive: true, mode: 493 });
|
|
2495
|
+
mkdirSync2("/etc/systemd/system/warp-svc.service.d", { recursive: true, mode: 493 });
|
|
2496
|
+
const key = "/run/cloudflare-warp-key.gpg";
|
|
2497
|
+
writeFileSync2(key, new Uint8Array(await response.arrayBuffer()), { mode: 384 });
|
|
2498
|
+
let result = await fixed(["/usr/bin/gpg", "--batch", "--yes", "--dearmor", "-o", "/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg", key]);
|
|
2499
|
+
rmSync2(key, { force: true });
|
|
2500
|
+
if (result.exitCode !== 0)
|
|
2501
|
+
return result;
|
|
2502
|
+
const codename = os.match(/^VERSION_CODENAME=(.+)$/m)?.[1]?.replace(/^"|"$/g, "");
|
|
2503
|
+
if (!codename)
|
|
2504
|
+
return { stdout: "Ubuntu codename missing", exitCode: 1 };
|
|
2505
|
+
writeFileSync2("/etc/apt/sources.list.d/cloudflare-client.list", `deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ ${codename} main
|
|
2506
|
+
`, { mode: 420 });
|
|
2507
|
+
result = await fixed(["/usr/bin/apt-get", "update", "-qq"]);
|
|
2508
|
+
return result.exitCode === 0 ? fixed(["/usr/bin/apt-get", "install", "-y", "cloudflare-warp"]) : result;
|
|
2509
|
+
}
|
|
2510
|
+
const status = await fixed(["/usr/bin/warp-cli", "--accept-tos", "status"]);
|
|
2511
|
+
return status.exitCode === 0 && /(^|\s)Connected(\s|$)/i.test(status.stdout) ? status : { ...status, exitCode: 1 };
|
|
2512
|
+
};
|
|
2513
|
+
async function localRunner(operation) {
|
|
2514
|
+
if (!["path-exists", "version"].includes(operation.kind))
|
|
2515
|
+
return runProvisionOperation(operation);
|
|
2516
|
+
const capability = operation;
|
|
2517
|
+
if (capability.kind === "version")
|
|
2518
|
+
return fixed(capability.argv);
|
|
2519
|
+
try {
|
|
2520
|
+
const metadata = lstatSync(capability.path);
|
|
2521
|
+
const present = capability.nodeType === "directory" ? metadata.isDirectory() : true;
|
|
2522
|
+
return { stdout: present ? `yes
|
|
2523
|
+
` : `no
|
|
2524
|
+
`, exitCode: present ? 0 : 1 };
|
|
2525
|
+
} catch {
|
|
2526
|
+
return { stdout: `no
|
|
2527
|
+
`, exitCode: 1 };
|
|
2528
|
+
}
|
|
1722
2529
|
}
|
|
1723
2530
|
function planInstall(options) {
|
|
1724
2531
|
const { capabilities, ...unit } = options;
|
|
@@ -1726,11 +2533,11 @@ function planInstall(options) {
|
|
|
1726
2533
|
}
|
|
1727
2534
|
async function applyPlan(plan, run) {
|
|
1728
2535
|
const transcript = [];
|
|
1729
|
-
for (const
|
|
1730
|
-
const result = await run(
|
|
1731
|
-
transcript.push({ label:
|
|
1732
|
-
if (result.exitCode !== 0 && !
|
|
1733
|
-
throw new Error(`${
|
|
2536
|
+
for (const step2 of plan.steps) {
|
|
2537
|
+
const result = await run(step2.operation);
|
|
2538
|
+
transcript.push({ label: step2.label, command: step2.command, exitCode: result.exitCode });
|
|
2539
|
+
if (result.exitCode !== 0 && !step2.optional) {
|
|
2540
|
+
throw new Error(`${step2.label} failed (exit ${result.exitCode}): ${step2.command}`);
|
|
1734
2541
|
}
|
|
1735
2542
|
}
|
|
1736
2543
|
return transcript;
|
|
@@ -1773,7 +2580,7 @@ var systemdValue = (name, raw) => {
|
|
|
1773
2580
|
const value = raw;
|
|
1774
2581
|
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("$", "\\$")}"`;
|
|
1775
2582
|
};
|
|
1776
|
-
function validatePlatformSharedEnvironment(input
|
|
2583
|
+
function validatePlatformSharedEnvironment(input) {
|
|
1777
2584
|
if (input.softwareProfile === "platform-api" !== (input.databaseRole === "none")) {
|
|
1778
2585
|
throw new Error("platform-api requires database role none; platform-db-api requires master or joiner.");
|
|
1779
2586
|
}
|
|
@@ -1783,14 +2590,26 @@ function validatePlatformSharedEnvironment(input, options = {}) {
|
|
|
1783
2590
|
const coordinators = input.databaseCoordinators.map(privateCoordinator);
|
|
1784
2591
|
if (new Set(coordinators).size !== coordinators.length)
|
|
1785
2592
|
throw new Error("databaseCoordinators must be unique.");
|
|
1786
|
-
if (
|
|
1787
|
-
throw new Error("
|
|
2593
|
+
if (input.databaseNetworkMode !== "private-lan") {
|
|
2594
|
+
throw new Error("Attended platform bootstrap supports only private-lan database networking.");
|
|
1788
2595
|
}
|
|
1789
2596
|
boundedInteger("databaseReplicationFactor", input.databaseReplicationFactor, 1, 16);
|
|
1790
2597
|
boundedInteger("databaseWriteConcern", input.databaseWriteConcern, 1, 16);
|
|
1791
2598
|
if (input.databaseWriteConcern > input.databaseReplicationFactor) {
|
|
1792
2599
|
throw new Error("databaseWriteConcern cannot exceed databaseReplicationFactor.");
|
|
1793
2600
|
}
|
|
2601
|
+
if (input.email?.provider === "smtp") {
|
|
2602
|
+
safeAtom("email.host", input.email.host);
|
|
2603
|
+
boundedInteger("email.port", input.email.port, 1, 65535);
|
|
2604
|
+
safeAtom("email.user", input.email.user);
|
|
2605
|
+
safeAtom("email.from", input.email.from);
|
|
2606
|
+
} else if (input.email?.provider === "jetemail") {
|
|
2607
|
+
safeAtom("email.from", input.email.from);
|
|
2608
|
+
if (typeof input.email.eu !== "boolean")
|
|
2609
|
+
throw new Error("JetEmail eu must be boolean.");
|
|
2610
|
+
} else if (input.email !== undefined) {
|
|
2611
|
+
throw new Error("Bootstrap email provider must be smtp or jetemail.");
|
|
2612
|
+
}
|
|
1794
2613
|
boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
|
|
1795
2614
|
boundedInteger("seedSyncMembers", input.seedSyncMembers, 1, 64);
|
|
1796
2615
|
boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
|
|
@@ -1823,13 +2642,6 @@ function validatePlatformSharedEnvironment(input, options = {}) {
|
|
|
1823
2642
|
if (url.username || url.password || url.hash)
|
|
1824
2643
|
throw new Error("Seed peers cannot contain credentials or fragments.");
|
|
1825
2644
|
}
|
|
1826
|
-
if (input.smtp) {
|
|
1827
|
-
safeAtom("smtp.host", input.smtp.host);
|
|
1828
|
-
boundedInteger("smtp.port", input.smtp.port, 1, 65535);
|
|
1829
|
-
safeAtom("smtp.from", input.smtp.from);
|
|
1830
|
-
if (input.smtp.user)
|
|
1831
|
-
safeAtom("smtp.user", input.smtp.user);
|
|
1832
|
-
}
|
|
1833
2645
|
if (input.backup) {
|
|
1834
2646
|
httpsOrigin("backup.endpoint", input.backup.endpoint);
|
|
1835
2647
|
for (const [name, value] of Object.entries(input.backup))
|
|
@@ -1842,14 +2654,12 @@ function validatePlatformSharedEnvironment(input, options = {}) {
|
|
|
1842
2654
|
const service = new URL(input.cloudflare.tunnelService);
|
|
1843
2655
|
if (service.protocol !== "http:" || !["127.0.0.1", "localhost", "::1"].includes(service.hostname) || service.username || service.password || service.search || service.hash)
|
|
1844
2656
|
throw new Error("Cloudflare Tunnel service must be loopback HTTP.");
|
|
1845
|
-
if (input.cloudflare.warp) {
|
|
1846
|
-
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)) {
|
|
1847
|
-
throw new Error("Cloudflare WARP organization, VNET or device profile is malformed.");
|
|
1848
|
-
}
|
|
1849
|
-
}
|
|
1850
2657
|
}
|
|
1851
|
-
if (
|
|
1852
|
-
|
|
2658
|
+
if (input.realtime) {
|
|
2659
|
+
input.realtime.endpoint = httpsOrigin("realtime.endpoint", input.realtime.endpoint);
|
|
2660
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,62}$/.test(input.realtime.workerScriptName) || !/^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,127}$/.test(input.realtime.producer)) {
|
|
2661
|
+
throw new Error("realtime.producer is invalid.");
|
|
2662
|
+
}
|
|
1853
2663
|
}
|
|
1854
2664
|
return {
|
|
1855
2665
|
...input,
|
|
@@ -1904,10 +2714,12 @@ function renderPlatformSharedEnvironment(input) {
|
|
|
1904
2714
|
FZ_PROFILE: value.deployProfile,
|
|
1905
2715
|
FZ_REPO: value.repository,
|
|
1906
2716
|
FZ_BRANCH: value.branch,
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2717
|
+
FZ_EMAIL_PROVIDER: value.email?.provider ?? "",
|
|
2718
|
+
FZ_SMTP_HOST: value.email?.provider === "smtp" ? value.email.host : "",
|
|
2719
|
+
FZ_SMTP_PORT: value.email?.provider === "smtp" ? String(value.email.port) : "",
|
|
2720
|
+
FZ_SMTP_USER: value.email?.provider === "smtp" ? value.email.user : "",
|
|
2721
|
+
FZ_EMAIL_FROM: value.email?.from ?? "",
|
|
2722
|
+
FZ_JETEMAIL_EU: value.email?.provider === "jetemail" ? String(value.email.eu) : "",
|
|
1911
2723
|
BACKUP_S3_ENDPOINT: value.backup?.endpoint ?? "",
|
|
1912
2724
|
BACKUP_S3_REGION: value.backup?.region ?? "",
|
|
1913
2725
|
BACKUP_S3_BUCKET: value.backup?.bucket ?? "",
|
|
@@ -1917,9 +2729,9 @@ function renderPlatformSharedEnvironment(input) {
|
|
|
1917
2729
|
FZ_CF_KV_NAMESPACE_ID: value.cloudflare?.kvNamespaceId ?? "",
|
|
1918
2730
|
FZ_CF_TUNNEL_ID: value.cloudflare?.tunnelId ?? "",
|
|
1919
2731
|
FZ_CF_TUNNEL_SERVICE: value.cloudflare?.tunnelService ?? "",
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
2732
|
+
FZ_REALTIME_WORKER_SCRIPT: value.realtime?.workerScriptName ?? "",
|
|
2733
|
+
FZ_REALTIME_ENDPOINT: value.realtime?.endpoint ?? "",
|
|
2734
|
+
FZ_REALTIME_PRODUCER: value.realtime?.producer ?? ""
|
|
1923
2735
|
};
|
|
1924
2736
|
return `# Generated by fz bootstrap platform. Non-secret coordinates only.
|
|
1925
2737
|
` + Object.entries(entries).map(([key, entry]) => `${key}=${systemdValue(key, entry)}`).join(`
|
|
@@ -1928,9 +2740,11 @@ function renderPlatformSharedEnvironment(input) {
|
|
|
1928
2740
|
}
|
|
1929
2741
|
function platformApiCredentialSpecs(options) {
|
|
1930
2742
|
const optional = [
|
|
1931
|
-
["bootstrap-smtp-password", options.smtp],
|
|
1932
|
-
["
|
|
1933
|
-
["
|
|
2743
|
+
["bootstrap-smtp-password", options.emailProvider === "smtp"],
|
|
2744
|
+
["bootstrap-jetemail-api-key", options.emailProvider === "jetemail"],
|
|
2745
|
+
["CF_API_TOKEN", options.cloudflareKv],
|
|
2746
|
+
["REALTIME_PUBLISH_SECRET", options.realtime],
|
|
2747
|
+
["REALTIME_TICKET_SECRET", options.realtime]
|
|
1934
2748
|
];
|
|
1935
2749
|
return [
|
|
1936
2750
|
{ name: "arangodb-jwt", encryptedPath: "/etc/forgezero/creds/arangodb-jwt.cred", required: true },
|
|
@@ -2054,40 +2868,12 @@ function renderPlatformActivationFiles(input) {
|
|
|
2054
2868
|
].join(`
|
|
2055
2869
|
`) + `
|
|
2056
2870
|
`;
|
|
2057
|
-
const helper =
|
|
2058
|
-
set -Eeuo pipefail
|
|
2059
|
-
source /etc/forgezero/deploy.env
|
|
2060
|
-
[[ $# == 1 ]] || { echo "usage: forgezero-activate <release>" >&2; exit 2; }
|
|
2061
|
-
release="$(realpath -e "$1")"; releases="$(realpath -e "$FZ_DIR/releases")"; slots="$FZ_DIR/slots"
|
|
2062
|
-
install -d -o root -g root -m 0755 "$slots"
|
|
2063
|
-
case "$release/" in "$releases"/*/) ;; *) echo "release is outside $releases" >&2; exit 2 ;; esac
|
|
2064
|
-
[[ -f "$release/.fz/deploy.json" && -s "$release/src/index.ts" && -s "$release/bun.lock" ]] || { echo "release is incomplete" >&2; exit 2; }
|
|
2065
|
-
slot_file="$FZ_DIR/.forge-slot"; previous_slot="$(cat "$slot_file" 2>/dev/null || true)"
|
|
2066
|
-
if [[ "$previous_slot" == blue ]]; then target=green; port="$FZ_GREEN_PORT"; else target=blue; port="$FZ_BLUE_PORT"; fi
|
|
2067
|
-
target_link="$slots/$target"; previous_target_link="$(readlink -f "$target_link" 2>/dev/null || true)"
|
|
2068
|
-
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 {} +
|
|
2069
|
-
ln -sfn "$release" "$target_link"; systemctl restart "forgezero@\${target}.service"
|
|
2070
|
-
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
|
|
2071
|
-
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
|
|
2072
|
-
upstream=/etc/nginx/conf.d/forgezero-upstream.conf; backup="$(mktemp -p /run forgezero-upstream.XXXXXX)"; [[ -f "$upstream" ]] && cp "$upstream" "$backup" || : >"$backup"
|
|
2073
|
-
printf 'upstream forgezero { server 127.0.0.1:%s; }\\n' "$port" >"$upstream"
|
|
2074
|
-
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
|
|
2075
|
-
rm -f "$backup"; printf '%s\\n' "$target" >"$slot_file"
|
|
2076
|
-
# New nginx workers select the new slot after reload. Keep the old slot alive
|
|
2077
|
-
# while old workers drain in-flight requests and upgraded connections.
|
|
2078
|
-
if [[ -n "$previous_slot" && "$previous_slot" != "$target" ]]; then
|
|
2079
|
-
sleep_seconds="$(( (FZ_DRAIN_DEADLINE_MS + 999) / 1000 ))"
|
|
2080
|
-
sleep "$sleep_seconds"
|
|
2081
|
-
systemctl stop "forgezero@\${previous_slot}.service" || true
|
|
2082
|
-
fi
|
|
2083
|
-
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-)
|
|
2084
|
-
for path in "\${old[@]}"; do [[ "$path" == "$release" ]] || rm -rf -- "$path"; done
|
|
2085
|
-
printf 'promoted %s on %s\\n' "$release" "$target"
|
|
2871
|
+
const helper = `${JSON.stringify({ ...input, drainDeadlineMs }, null, 2)}
|
|
2086
2872
|
`;
|
|
2087
2873
|
return {
|
|
2088
2874
|
environment,
|
|
2089
2875
|
helper,
|
|
2090
|
-
sudoers: `forgezero-runner ALL=(root) NOPASSWD: /usr/local/
|
|
2876
|
+
sudoers: `forgezero-runner ALL=(root) NOPASSWD: /usr/local/lib/forgezero/agent/fz-agent platform-activate --config=/etc/forgezero/deploy-activation.json *
|
|
2091
2877
|
`
|
|
2092
2878
|
};
|
|
2093
2879
|
}
|
|
@@ -2128,12 +2914,143 @@ function planLocalOtlpProof(endpoint2, collectorUnit) {
|
|
|
2128
2914
|
};
|
|
2129
2915
|
}
|
|
2130
2916
|
|
|
2917
|
+
// src/otel-collector.ts
|
|
2918
|
+
var FORGEZERO_OTEL_COLLECTOR_UNIT = "forgezero-otel-collector.service";
|
|
2919
|
+
var FORGEZERO_OTEL_COLLECTOR_VERSION = "0.157.0";
|
|
2920
|
+
var FORGEZERO_OTEL_COLLECTOR_SHA256 = "2937cf24892af55b143c072fddece17862239cf78280620029276493eb81beae";
|
|
2921
|
+
var ARCHIVE = "/run/forgezero-otelcol.tar.gz";
|
|
2922
|
+
var EXTRACT = "/run/forgezero-otelcol-release";
|
|
2923
|
+
var BINARY = "/usr/local/lib/forgezero/otelcol/otelcol";
|
|
2924
|
+
var CONFIG = "/etc/forgezero/otelcol.yaml";
|
|
2925
|
+
var UNIT = `/etc/systemd/system/${FORGEZERO_OTEL_COLLECTOR_UNIT}`;
|
|
2926
|
+
var checked = async (host, argv) => {
|
|
2927
|
+
const result = await host.exec(argv);
|
|
2928
|
+
const output = `${result.output ?? result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
2929
|
+
if (result.exitCode !== 0)
|
|
2930
|
+
throw new Error(`OTLP collector operation failed: ${argv.join(" ")}${output.trim() ? `: ${output.trim()}` : ""}`);
|
|
2931
|
+
return output.trim();
|
|
2932
|
+
};
|
|
2933
|
+
var destination = (value) => {
|
|
2934
|
+
let url;
|
|
2935
|
+
try {
|
|
2936
|
+
url = new URL(value);
|
|
2937
|
+
} catch {
|
|
2938
|
+
throw new Error("OTLP collector export endpoint must be an absolute HTTPS URL");
|
|
2939
|
+
}
|
|
2940
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.port && url.port !== "443") {
|
|
2941
|
+
throw new Error("OTLP collector export endpoint must be credential-free HTTPS");
|
|
2942
|
+
}
|
|
2943
|
+
return url.toString().replace(/\/$/, "");
|
|
2944
|
+
};
|
|
2945
|
+
function renderForgeZeroOtelCollector(exportEndpoint) {
|
|
2946
|
+
const endpoint2 = destination(exportEndpoint);
|
|
2947
|
+
return {
|
|
2948
|
+
config: `receivers:
|
|
2949
|
+
otlp:
|
|
2950
|
+
protocols:
|
|
2951
|
+
http:
|
|
2952
|
+
endpoint: 127.0.0.1:4318
|
|
2953
|
+
exporters:
|
|
2954
|
+
otlphttp:
|
|
2955
|
+
endpoint: ${JSON.stringify(endpoint2)}
|
|
2956
|
+
service:
|
|
2957
|
+
telemetry:
|
|
2958
|
+
metrics:
|
|
2959
|
+
address: 127.0.0.1:8888
|
|
2960
|
+
pipelines:
|
|
2961
|
+
traces:
|
|
2962
|
+
receivers: [otlp]
|
|
2963
|
+
exporters: [otlphttp]
|
|
2964
|
+
metrics:
|
|
2965
|
+
receivers: [otlp]
|
|
2966
|
+
exporters: [otlphttp]
|
|
2967
|
+
logs:
|
|
2968
|
+
receivers: [otlp]
|
|
2969
|
+
exporters: [otlphttp]
|
|
2970
|
+
`,
|
|
2971
|
+
unit: `[Unit]
|
|
2972
|
+
Description=ForgeZero independently supervised OpenTelemetry Collector
|
|
2973
|
+
After=network-online.target
|
|
2974
|
+
Wants=network-online.target
|
|
2975
|
+
|
|
2976
|
+
[Service]
|
|
2977
|
+
Type=simple
|
|
2978
|
+
User=forgezero-otel
|
|
2979
|
+
Group=forgezero-otel
|
|
2980
|
+
ExecStart=${BINARY} --config=${CONFIG}
|
|
2981
|
+
Restart=always
|
|
2982
|
+
RestartSec=2
|
|
2983
|
+
LimitCORE=0
|
|
2984
|
+
NoNewPrivileges=true
|
|
2985
|
+
PrivateTmp=true
|
|
2986
|
+
ProtectSystem=strict
|
|
2987
|
+
ProtectHome=true
|
|
2988
|
+
ProtectKernelTunables=true
|
|
2989
|
+
ProtectKernelModules=true
|
|
2990
|
+
ProtectControlGroups=true
|
|
2991
|
+
RestrictSUIDSGID=true
|
|
2992
|
+
RestrictRealtime=true
|
|
2993
|
+
LockPersonality=true
|
|
2994
|
+
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
|
2995
|
+
|
|
2996
|
+
[Install]
|
|
2997
|
+
WantedBy=multi-user.target
|
|
2998
|
+
`
|
|
2999
|
+
};
|
|
3000
|
+
}
|
|
3001
|
+
async function ensureForgeZeroOtelCollector(host, exportEndpoint) {
|
|
3002
|
+
const rendered = renderForgeZeroOtelCollector(exportEndpoint);
|
|
3003
|
+
const observed = await host.exec([BINARY, "--version"]);
|
|
3004
|
+
const versionOutput = `${observed.output ?? observed.stdout ?? ""}${observed.stderr ?? ""}`;
|
|
3005
|
+
if (observed.exitCode !== 0 || !versionOutput.includes(`otelcol version ${FORGEZERO_OTEL_COLLECTOR_VERSION}`)) {
|
|
3006
|
+
await checked(host, [
|
|
3007
|
+
"/usr/bin/curl",
|
|
3008
|
+
"--fail",
|
|
3009
|
+
"--silent",
|
|
3010
|
+
"--show-error",
|
|
3011
|
+
"--location",
|
|
3012
|
+
"--proto",
|
|
3013
|
+
"=https",
|
|
3014
|
+
"--output",
|
|
3015
|
+
ARCHIVE,
|
|
3016
|
+
`https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v${FORGEZERO_OTEL_COLLECTOR_VERSION}/otelcol_${FORGEZERO_OTEL_COLLECTOR_VERSION}_linux_amd64.tar.gz`
|
|
3017
|
+
]);
|
|
3018
|
+
const digest = (await checked(host, ["/usr/bin/sha256sum", ARCHIVE])).split(/\s+/)[0];
|
|
3019
|
+
if (digest !== FORGEZERO_OTEL_COLLECTOR_SHA256)
|
|
3020
|
+
throw new Error("OTLP collector archive checksum mismatch");
|
|
3021
|
+
await checked(host, ["/usr/bin/install", "-d", "-m", "0755", "/usr/local/lib/forgezero/otelcol", EXTRACT]);
|
|
3022
|
+
await checked(host, ["/usr/bin/tar", "-xzf", ARCHIVE, "-C", EXTRACT, "otelcol"]);
|
|
3023
|
+
await checked(host, ["/usr/bin/install", "-m", "0755", `${EXTRACT}/otelcol`, BINARY]);
|
|
3024
|
+
await checked(host, ["/usr/bin/rm", "-f", ARCHIVE, `${EXTRACT}/otelcol`]);
|
|
3025
|
+
}
|
|
3026
|
+
if ((await host.exec(["/usr/bin/getent", "group", "forgezero-otel"])).exitCode !== 0) {
|
|
3027
|
+
await checked(host, ["/usr/sbin/groupadd", "--system", "forgezero-otel"]);
|
|
3028
|
+
}
|
|
3029
|
+
if ((await host.exec(["/usr/bin/id", "forgezero-otel"])).exitCode !== 0) {
|
|
3030
|
+
await checked(host, [
|
|
3031
|
+
"/usr/sbin/useradd",
|
|
3032
|
+
"--system",
|
|
3033
|
+
"--no-create-home",
|
|
3034
|
+
"--shell",
|
|
3035
|
+
"/usr/sbin/nologin",
|
|
3036
|
+
"--gid",
|
|
3037
|
+
"forgezero-otel",
|
|
3038
|
+
"forgezero-otel"
|
|
3039
|
+
]);
|
|
3040
|
+
}
|
|
3041
|
+
host.write(CONFIG, rendered.config, 420);
|
|
3042
|
+
host.write(UNIT, rendered.unit, 420);
|
|
3043
|
+
await checked(host, ["/usr/bin/systemctl", "daemon-reload"]);
|
|
3044
|
+
await checked(host, ["/usr/bin/systemctl", "enable", "--now", FORGEZERO_OTEL_COLLECTOR_UNIT]);
|
|
3045
|
+
}
|
|
3046
|
+
|
|
2131
3047
|
// src/bootstrap.ts
|
|
2132
3048
|
import { DEFAULT_SOCKET as DEFAULT_SOCKET2 } from "@forgezero/vault";
|
|
2133
3049
|
var PLATFORM_BOOTSTRAP_PROFILES = [
|
|
2134
3050
|
"platform-db-api",
|
|
2135
3051
|
"platform-api"
|
|
2136
3052
|
];
|
|
3053
|
+
var platformBootstrapRunner = (config) => config.kind === "platform" && config.database.role === "master";
|
|
2137
3054
|
function resolveInstalledBootstrapKind(states) {
|
|
2138
3055
|
if (states.compute && states.metal) {
|
|
2139
3056
|
throw new Error("host has both metal and compute bootstrap state; refusing an ambiguous operation");
|
|
@@ -2146,12 +3063,15 @@ var INTENT_PATH = "/var/lib/forgezero/bootstrap.intent.json";
|
|
|
2146
3063
|
var CREDS = "/etc/forgezero/creds";
|
|
2147
3064
|
var JWT_CREDENTIAL = `${CREDS}/arangodb-jwt.cred`;
|
|
2148
3065
|
var ENROL_CREDENTIAL = `${CREDS}/enrol-token.cred`;
|
|
2149
|
-
var TUNNEL_CREDENTIAL = `${CREDS}/
|
|
3066
|
+
var TUNNEL_CREDENTIAL = `${CREDS}/CF_TUNNEL_CONNECTOR_TOKEN.cred`;
|
|
3067
|
+
var CF_API_CREDENTIAL = `${CREDS}/CF_API_TOKEN.cred`;
|
|
3068
|
+
var WARP_CONNECTOR_CREDENTIAL = `${CREDS}/CF_WARP_CONNECTOR_TOKEN.cred`;
|
|
3069
|
+
var REALTIME_PUBLISH_CREDENTIAL = `${CREDS}/REALTIME_PUBLISH_SECRET.cred`;
|
|
3070
|
+
var REALTIME_TICKET_CREDENTIAL = `${CREDS}/REALTIME_TICKET_SECRET.cred`;
|
|
2150
3071
|
var SEED_CREDENTIAL = `${CREDS}/seed-sync-root.cred`;
|
|
2151
3072
|
var BACKUP_RECOVERY_CREDENTIAL = `${CREDS}/backup-recovery-root.cred`;
|
|
2152
|
-
var WARP_CLIENT_ID_CREDENTIAL = `${CREDS}/warp-auth-client-id.cred`;
|
|
2153
|
-
var WARP_CLIENT_SECRET_CREDENTIAL = `${CREDS}/warp-auth-client-secret.cred`;
|
|
2154
3073
|
var BOOTSTRAP_SSH_CREDENTIAL = `${CREDS}/bootstrap-ssh-key.cred`;
|
|
3074
|
+
var BOOTSTRAP_SSH_PUBLIC_KEY = "/etc/forgezero/bootstrap/runner.pub";
|
|
2155
3075
|
var GIT_PUBLIC_KEY = "/etc/forgezero/git/deploy.pub";
|
|
2156
3076
|
var PLATFORM_ENROL_SOURCE = "/run/forgezero-platform-enrol-token";
|
|
2157
3077
|
var DB_MODE_EVIDENCE = "/var/lib/forgezero-cluster/server-mode.json";
|
|
@@ -2219,19 +3139,22 @@ function validateBootstrapConfig(value) {
|
|
|
2219
3139
|
throw new Error(`deployment credential ${name} must map to an absolute encrypted .cred path`);
|
|
2220
3140
|
}
|
|
2221
3141
|
}
|
|
2222
|
-
if (value.
|
|
2223
|
-
throw new Error("
|
|
3142
|
+
if (Boolean(value.installCloudflared) !== Boolean(value.cloudflareHandoff) || value.cloudflareHandoff && (!value.cloudflareHandoff.handoffFile || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(value.cloudflareHandoff.nodeName))) {
|
|
3143
|
+
throw new Error("cloudflared requires a node-specific Cloudflare handoff and both must be supplied together");
|
|
2224
3144
|
}
|
|
2225
|
-
if (value.
|
|
3145
|
+
if (value.installWarp && !value.cloudflareHandoff) {
|
|
3146
|
+
throw new Error("Cloudflare Mesh/WARP requires a node-specific Cloudflare handoff");
|
|
3147
|
+
}
|
|
3148
|
+
if (value.kind === "enrolled-compute") {
|
|
2226
3149
|
if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(value.realm))
|
|
2227
3150
|
throw new Error("tenant realm is malformed");
|
|
2228
3151
|
if (!/^https:\/\//.test(value.apiUrl) && !/^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/.test(value.apiUrl)) {
|
|
2229
3152
|
throw new Error("tenant API must be public HTTPS or loopback HTTP");
|
|
2230
3153
|
}
|
|
2231
3154
|
if (!value.enrolTokenFile)
|
|
2232
|
-
throw new Error("
|
|
3155
|
+
throw new Error("enrolled-compute activation requires an enrolment-token file");
|
|
2233
3156
|
if (value.bootstrapRunner) {
|
|
2234
|
-
if (!value.bootstrapRunner.sshPrivateKeyFile.startsWith("/") || /[\r\n]/.test(value.bootstrapRunner.sshPrivateKeyFile)) {
|
|
3157
|
+
if (value.bootstrapRunner.sshPrivateKeyFile !== undefined && (!value.bootstrapRunner.sshPrivateKeyFile.startsWith("/") || /[\r\n]/.test(value.bootstrapRunner.sshPrivateKeyFile))) {
|
|
2235
3158
|
throw new Error("bootstrap runner SSH private-key file must be absolute");
|
|
2236
3159
|
}
|
|
2237
3160
|
let targetTelemetry;
|
|
@@ -2280,14 +3203,21 @@ function validateBootstrapConfig(value) {
|
|
|
2280
3203
|
throw new Error("database coordinators must contain 1-16 unique private origins");
|
|
2281
3204
|
}
|
|
2282
3205
|
value.database.coordinators = write;
|
|
2283
|
-
const runtime = validatePlatformSharedEnvironment(value.runtime.environment
|
|
2284
|
-
|
|
2285
|
-
|
|
3206
|
+
const runtime = validatePlatformSharedEnvironment(value.runtime.environment);
|
|
3207
|
+
if (runtime.otlpCollectorUnit !== FORGEZERO_OTEL_COLLECTOR_UNIT) {
|
|
3208
|
+
throw new Error(`platform bootstrap requires ${FORGEZERO_OTEL_COLLECTOR_UNIT}`);
|
|
3209
|
+
}
|
|
2286
3210
|
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(",")) {
|
|
2287
3211
|
throw new Error("platform runtime coordinates disagree with immutable bootstrap coordinates");
|
|
2288
3212
|
}
|
|
2289
3213
|
if (runtime.deployProfile !== value.environment)
|
|
2290
3214
|
throw new Error("runtime deployment profile disagrees with bootstrap environment");
|
|
3215
|
+
if (Boolean(runtime.email) !== Boolean(value.runtime.credentialFiles?.emailSecret)) {
|
|
3216
|
+
throw new Error("Bootstrap email configuration and its owner-only credential file must be supplied together");
|
|
3217
|
+
}
|
|
3218
|
+
if (value.runtime.credentialFiles?.emailSecret && (!value.runtime.credentialFiles.emailSecret.startsWith("/") || /[\r\n]/.test(value.runtime.credentialFiles.emailSecret))) {
|
|
3219
|
+
throw new Error("Bootstrap email credential file must be an absolute single-line path");
|
|
3220
|
+
}
|
|
2291
3221
|
if (value.database.address !== runtime.databaseAddress || value.database.master !== runtime.databaseMaster) {
|
|
2292
3222
|
throw new Error("runtime database topology disagrees with bootstrap topology");
|
|
2293
3223
|
}
|
|
@@ -2306,16 +3236,19 @@ function validateBootstrapConfig(value) {
|
|
|
2306
3236
|
}
|
|
2307
3237
|
function planBootstrap(input, initialized = false) {
|
|
2308
3238
|
const config = validateBootstrapConfig(structuredClone(input));
|
|
2309
|
-
const software = config.kind === "
|
|
3239
|
+
const software = config.kind === "enrolled-compute" ? [
|
|
2310
3240
|
...config.software ?? [],
|
|
2311
3241
|
...config.bootstrapRunner && !config.software?.some(({ id }) => id === "openssh-client") ? [{ id: "openssh-client", version: "ubuntu-26.04" }] : [],
|
|
2312
|
-
...config.installCloudflared ? [{ id: "cloudflared", version: "2026.7.3" }] : []
|
|
3242
|
+
...config.installCloudflared ? [{ id: "cloudflared", version: "2026.7.3" }] : [],
|
|
3243
|
+
...config.installWarp ? [{ id: "cloudflare-warp", version: "2026.6.822.0-min" }] : []
|
|
2313
3244
|
] : [
|
|
2314
3245
|
...config.firewall.enabled ? [{ id: "ufw", version: "ubuntu-26.04" }] : [],
|
|
2315
3246
|
{ id: "bun", version: "1.3.14" },
|
|
2316
3247
|
{ id: "nginx", version: "ubuntu-26.04" },
|
|
3248
|
+
...platformBootstrapRunner(config) ? [{ id: "openssh-client", version: "ubuntu-26.04" }] : [],
|
|
2317
3249
|
...config.profile === "platform-api" ? [] : [{ id: "arangodb", version: "3.11.14" }],
|
|
2318
|
-
...config.installCloudflared ? [{ id: "cloudflared", version: "2026.7.3" }] : []
|
|
3250
|
+
...config.installCloudflared ? [{ id: "cloudflared", version: "2026.7.3" }] : [],
|
|
3251
|
+
...config.installWarp ? [{ id: "cloudflare-warp", version: "2026.6.822.0-min" }] : []
|
|
2319
3252
|
];
|
|
2320
3253
|
return {
|
|
2321
3254
|
kind: config.kind,
|
|
@@ -2325,7 +3258,7 @@ function planBootstrap(input, initialized = false) {
|
|
|
2325
3258
|
statePath: STATE_PATH,
|
|
2326
3259
|
steps: [
|
|
2327
3260
|
{ id: "agent", label: "Install and verify the common Agent supervision boundary", mutation: true },
|
|
2328
|
-
{ id: "software", label: "Install exact Agent-owned software coordinates in order", mutation: true },
|
|
3261
|
+
{ id: "software", label: "Install exact Agent-owned software coordinates and the checksum-verified OTLP collector in order", mutation: true },
|
|
2329
3262
|
{ id: "credentials", label: "Seal bootstrap credentials with systemd-creds", mutation: true },
|
|
2330
3263
|
...config.kind === "platform" && config.database.role !== "none" ? [{ id: "database", label: "Provision Community 3.11.14 cluster and Coordinator mode", mutation: true }] : [],
|
|
2331
3264
|
...config.kind === "platform" ? [
|
|
@@ -2333,19 +3266,18 @@ function planBootstrap(input, initialized = false) {
|
|
|
2333
3266
|
{ id: "deploy", label: "Create the first invite when required and health-gate the initial deployment", mutation: true },
|
|
2334
3267
|
{ id: "enrol", label: "Consume the API-bound platform capability and enable signed control", mutation: true }
|
|
2335
3268
|
] : [],
|
|
2336
|
-
...config.installCloudflared ? [{ id: "
|
|
3269
|
+
...config.installCloudflared ? [{ id: "cloudflare-connectors", label: "Install cloudflared and optional Mesh/WARP connector from the attended handoff", mutation: true }] : [],
|
|
2337
3270
|
{ id: "state", label: "Persist immutable bootstrap profile and evidence coordinates", mutation: true },
|
|
2338
3271
|
{ id: "status", label: "Verify services and immutable profile", mutation: false }
|
|
2339
3272
|
]
|
|
2340
3273
|
};
|
|
2341
3274
|
}
|
|
2342
|
-
var
|
|
3275
|
+
var checked2 = async (host, argv, label, options) => {
|
|
2343
3276
|
const result = await host.exec(argv, options);
|
|
2344
3277
|
if (result.exitCode !== 0)
|
|
2345
3278
|
throw new Error(`${label} failed: ${result.output.trim()}`);
|
|
2346
3279
|
return result.output;
|
|
2347
3280
|
};
|
|
2348
|
-
var shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
2349
3281
|
var unitEscape = (value) => {
|
|
2350
3282
|
if (/[^A-Za-z0-9_./:@,+-]/.test(value))
|
|
2351
3283
|
throw new Error(`unsafe systemd coordinate: ${value}`);
|
|
@@ -2353,7 +3285,7 @@ var unitEscape = (value) => {
|
|
|
2353
3285
|
};
|
|
2354
3286
|
function databaseUnit(config) {
|
|
2355
3287
|
const db = config.database;
|
|
2356
|
-
const
|
|
3288
|
+
const join3 = db.role === "joiner" ? ` --starter.join=${unitEscape(db.master)}` : "";
|
|
2357
3289
|
const agency = db.agency === "none" ? " --cluster.start-agent=false --cluster.start-coordinator=true --cluster.start-dbserver=true" : "";
|
|
2358
3290
|
return `[Unit]
|
|
2359
3291
|
Description=ForgeZero ArangoDB Community 3.11.14 cluster (${db.role}; agency=${db.agency})
|
|
@@ -2365,7 +3297,7 @@ Type=simple
|
|
|
2365
3297
|
User=arangodb
|
|
2366
3298
|
Group=arangodb
|
|
2367
3299
|
LoadCredentialEncrypted=arangodb-jwt:${JWT_CREDENTIAL}
|
|
2368
|
-
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${
|
|
3300
|
+
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${join3}${agency}
|
|
2369
3301
|
Restart=always
|
|
2370
3302
|
RestartSec=5
|
|
2371
3303
|
UMask=0077
|
|
@@ -2420,8 +3352,8 @@ Wants=network-online.target
|
|
|
2420
3352
|
[Service]
|
|
2421
3353
|
Type=simple
|
|
2422
3354
|
DynamicUser=yes
|
|
2423
|
-
LoadCredentialEncrypted=
|
|
2424
|
-
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate --metrics ${CLOUDFLARED_METRICS_ADDRESS} run --token-file %d/
|
|
3355
|
+
LoadCredentialEncrypted=CF_TUNNEL_CONNECTOR_TOKEN:${TUNNEL_CREDENTIAL}
|
|
3356
|
+
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate --metrics ${CLOUDFLARED_METRICS_ADDRESS} run --token-file %d/CF_TUNNEL_CONNECTOR_TOKEN
|
|
2425
3357
|
Restart=always
|
|
2426
3358
|
RestartSec=5
|
|
2427
3359
|
NoNewPrivileges=true
|
|
@@ -2429,6 +3361,28 @@ PrivateTmp=true
|
|
|
2429
3361
|
ProtectSystem=strict
|
|
2430
3362
|
ProtectHome=true
|
|
2431
3363
|
|
|
3364
|
+
[Install]
|
|
3365
|
+
WantedBy=multi-user.target
|
|
3366
|
+
`;
|
|
3367
|
+
}
|
|
3368
|
+
function meshConnectorUnit() {
|
|
3369
|
+
return `[Unit]
|
|
3370
|
+
Description=ForgeZero Cloudflare Mesh/WARP connector registration
|
|
3371
|
+
Requires=warp-svc.service
|
|
3372
|
+
After=network-online.target warp-svc.service
|
|
3373
|
+
Wants=network-online.target
|
|
3374
|
+
|
|
3375
|
+
[Service]
|
|
3376
|
+
Type=oneshot
|
|
3377
|
+
RemainAfterExit=yes
|
|
3378
|
+
LoadCredentialEncrypted=CF_WARP_CONNECTOR_TOKEN:${WARP_CONNECTOR_CREDENTIAL}
|
|
3379
|
+
ExecStart=/usr/local/bin/fz-agent mesh-config
|
|
3380
|
+
NoNewPrivileges=true
|
|
3381
|
+
PrivateTmp=true
|
|
3382
|
+
ProtectSystem=strict
|
|
3383
|
+
ProtectHome=true
|
|
3384
|
+
ReadWritePaths=/var/lib/cloudflare-warp
|
|
3385
|
+
|
|
2432
3386
|
[Install]
|
|
2433
3387
|
WantedBy=multi-user.target
|
|
2434
3388
|
`;
|
|
@@ -2492,15 +3446,15 @@ var derive = (root, label) => {
|
|
|
2492
3446
|
throw new Error("cluster bootstrap code must contain exactly 64 hexadecimal characters");
|
|
2493
3447
|
return createHmac("sha256", Buffer.from(root, "hex")).update(label).digest("hex");
|
|
2494
3448
|
};
|
|
2495
|
-
async function seal(host, name,
|
|
2496
|
-
if (host.exists(
|
|
3449
|
+
async function seal(host, name, destination2, value) {
|
|
3450
|
+
if (host.exists(destination2))
|
|
2497
3451
|
return;
|
|
2498
|
-
const result = await host.exec(["systemd-creds", "encrypt", `--name=${name}`, "-",
|
|
3452
|
+
const result = await host.exec(["systemd-creds", "encrypt", `--name=${name}`, "-", destination2], { stdin: value });
|
|
2499
3453
|
if (result.exitCode !== 0)
|
|
2500
3454
|
throw new Error(`could not seal ${name}: ${result.output.trim()}`);
|
|
2501
3455
|
}
|
|
2502
3456
|
function bootstrapIdentity(config) {
|
|
2503
|
-
if (config.kind === "
|
|
3457
|
+
if (config.kind === "enrolled-compute")
|
|
2504
3458
|
return {
|
|
2505
3459
|
kind: config.kind,
|
|
2506
3460
|
apiUrl: config.apiUrl,
|
|
@@ -2513,9 +3467,11 @@ function bootstrapIdentity(config) {
|
|
|
2513
3467
|
deployRoot: config.deployRoot ?? "/opt/forgezero",
|
|
2514
3468
|
software: (config.software ?? []).map(({ id, version }) => ({ id, version })),
|
|
2515
3469
|
installCloudflared: Boolean(config.installCloudflared),
|
|
3470
|
+
installWarp: Boolean(config.installWarp),
|
|
2516
3471
|
bootstrapRunner: config.bootstrapRunner ? { targetTelemetryEndpoint: config.bootstrapRunner.targetTelemetryEndpoint } : null
|
|
2517
3472
|
};
|
|
2518
3473
|
const environment = config.runtime.environment;
|
|
3474
|
+
const { cloudflare: _cloudflare, realtime: _realtime, ...stableEnvironment } = environment;
|
|
2519
3475
|
return {
|
|
2520
3476
|
kind: config.kind,
|
|
2521
3477
|
environment: config.environment,
|
|
@@ -2545,10 +3501,12 @@ function bootstrapIdentity(config) {
|
|
|
2545
3501
|
publicApiPort: environment.publicApiPort,
|
|
2546
3502
|
healthPath: config.runtime.healthPath,
|
|
2547
3503
|
keepReleases: config.runtime.keepReleases,
|
|
2548
|
-
environment
|
|
3504
|
+
environment: stableEnvironment
|
|
2549
3505
|
},
|
|
2550
3506
|
firewall: config.firewall,
|
|
2551
|
-
installCloudflared: Boolean(config.installCloudflared)
|
|
3507
|
+
installCloudflared: Boolean(config.installCloudflared),
|
|
3508
|
+
installWarp: Boolean(config.installWarp),
|
|
3509
|
+
cloudflareNodeName: config.cloudflareHandoff?.nodeName ?? null
|
|
2552
3510
|
};
|
|
2553
3511
|
}
|
|
2554
3512
|
function bootstrapIdentityDigest(config) {
|
|
@@ -2564,7 +3522,7 @@ function parseStoredState(raw) {
|
|
|
2564
3522
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
2565
3523
|
throw new Error("bootstrap state is malformed");
|
|
2566
3524
|
const state = value;
|
|
2567
|
-
if (state.format !== 2 || !["platform", "
|
|
3525
|
+
if (state.format !== 2 || !["platform", "enrolled-compute"].includes(state.kind ?? "") || !/^[a-f0-9]{64}$/.test(state.identityDigest ?? "") || typeof state.profile !== "string" || typeof state.nodeHostname !== "string" || typeof state.apiUrl !== "string" || state.kind === "platform" && !["member", "none"].includes(state.databaseAgency ?? "")) {
|
|
2568
3526
|
throw new Error("bootstrap state is legacy or incomplete; refusing an unbound repair");
|
|
2569
3527
|
}
|
|
2570
3528
|
return state;
|
|
@@ -2579,7 +3537,7 @@ function parseStoredIntent(raw) {
|
|
|
2579
3537
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
2580
3538
|
throw new Error("bootstrap intent is malformed");
|
|
2581
3539
|
const intent = value;
|
|
2582
|
-
if (intent.format !== 1 || !["platform", "
|
|
3540
|
+
if (intent.format !== 1 || !["platform", "enrolled-compute"].includes(intent.kind ?? "") || !/^[a-f0-9]{64}$/.test(intent.identityDigest ?? "") || Number.isNaN(Date.parse(intent.createdAt ?? ""))) {
|
|
2583
3541
|
throw new Error("bootstrap intent is incomplete; refusing an unbound resume");
|
|
2584
3542
|
}
|
|
2585
3543
|
return intent;
|
|
@@ -2650,10 +3608,13 @@ function stateFor(config, cloudflare, previousCloudflareTunnelId) {
|
|
|
2650
3608
|
publicApiPort: config.runtime.environment.publicApiPort,
|
|
2651
3609
|
healthPath: config.runtime.healthPath,
|
|
2652
3610
|
cloudflare: config.runtime.environment.cloudflare,
|
|
2653
|
-
|
|
3611
|
+
realtime: config.runtime.environment.realtime,
|
|
3612
|
+
cloudflared: Boolean(config.cloudflareHandoff),
|
|
3613
|
+
warp: Boolean(config.installWarp)
|
|
2654
3614
|
} : {
|
|
2655
3615
|
realm: config.realm,
|
|
2656
3616
|
cloudflared: Boolean(config.cloudflareHandoff),
|
|
3617
|
+
warp: Boolean(config.installWarp),
|
|
2657
3618
|
cloudflareTunnelId: cloudflare?.tunnelId ?? previousCloudflareTunnelId
|
|
2658
3619
|
}
|
|
2659
3620
|
}, null, 2)}
|
|
@@ -2675,6 +3636,8 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
2675
3636
|
units.push(state.collectorUnit);
|
|
2676
3637
|
if (state.cloudflared)
|
|
2677
3638
|
units.push("cloudflared.service");
|
|
3639
|
+
if (state.warp)
|
|
3640
|
+
units.push("warp-svc.service", "forgezero-mesh-config.service");
|
|
2678
3641
|
if (state.kind === "platform" && state.databaseRole !== "none")
|
|
2679
3642
|
units.push("forgezero-db.service", "forgezero-db-verify.service");
|
|
2680
3643
|
const services = {};
|
|
@@ -2691,7 +3654,7 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
2691
3654
|
problems.push(`${socket} is missing`);
|
|
2692
3655
|
}
|
|
2693
3656
|
if (state.cloudflared) {
|
|
2694
|
-
for (const credential of [TUNNEL_CREDENTIAL,
|
|
3657
|
+
for (const credential of [TUNNEL_CREDENTIAL, CF_API_CREDENTIAL]) {
|
|
2695
3658
|
services[credential] = host.exists(credential);
|
|
2696
3659
|
if (!services[credential])
|
|
2697
3660
|
problems.push(`${credential} is missing`);
|
|
@@ -2707,20 +3670,16 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
2707
3670
|
problems.push(evidence.problem);
|
|
2708
3671
|
}
|
|
2709
3672
|
}
|
|
3673
|
+
if (state.warp) {
|
|
3674
|
+
services[WARP_CONNECTOR_CREDENTIAL] = host.exists(WARP_CONNECTOR_CREDENTIAL);
|
|
3675
|
+
if (!services[WARP_CONNECTOR_CREDENTIAL])
|
|
3676
|
+
problems.push(`${WARP_CONNECTOR_CREDENTIAL} is missing`);
|
|
3677
|
+
const status = await host.exec(["/usr/bin/warp-cli", "--accept-tos", "status"]);
|
|
3678
|
+
services["cloudflare-mesh"] = status.exitCode === 0 && /\bconnected\b/i.test(status.output) && !/\bdisconnected\b/i.test(status.output);
|
|
3679
|
+
if (!services["cloudflare-mesh"])
|
|
3680
|
+
problems.push("Cloudflare Mesh/WARP is not connected");
|
|
3681
|
+
}
|
|
2710
3682
|
if (state.kind === "platform") {
|
|
2711
|
-
if (state.cloudflared) {
|
|
2712
|
-
if (state.cloudflare?.warp) {
|
|
2713
|
-
for (const credential of [
|
|
2714
|
-
`${CREDS}/cloudflare-network-token.cred`,
|
|
2715
|
-
WARP_CLIENT_ID_CREDENTIAL,
|
|
2716
|
-
WARP_CLIENT_SECRET_CREDENTIAL
|
|
2717
|
-
]) {
|
|
2718
|
-
services[credential] = host.exists(credential);
|
|
2719
|
-
if (!services[credential])
|
|
2720
|
-
problems.push(`${credential} is missing`);
|
|
2721
|
-
}
|
|
2722
|
-
}
|
|
2723
|
-
}
|
|
2724
3683
|
const [blue, green] = await Promise.all([
|
|
2725
3684
|
host.exec(["systemctl", "is-active", "--quiet", "forgezero@blue.service"]),
|
|
2726
3685
|
host.exec(["systemctl", "is-active", "--quiet", "forgezero@green.service"])
|
|
@@ -2779,7 +3738,7 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
2779
3738
|
const config = validateBootstrapConfig(structuredClone(input));
|
|
2780
3739
|
if (host.uid() !== 0)
|
|
2781
3740
|
throw new Error("fz bootstrap --apply must run as root");
|
|
2782
|
-
if (config.kind === "
|
|
3741
|
+
if (config.kind === "enrolled-compute") {
|
|
2783
3742
|
const token = privateFile(host, config.enrolTokenFile, "tenant enrolment token");
|
|
2784
3743
|
if (!/^fze_[A-Za-z0-9_-]{32,128}$/.test(token))
|
|
2785
3744
|
throw new Error("tenant enrolment token is malformed");
|
|
@@ -2793,14 +3752,18 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
2793
3752
|
cloudflare = await readCloudflareHostHandoff(config.cloudflareHandoff.handoffFile, config.cloudflareHandoff.nodeName);
|
|
2794
3753
|
} else if (config.kind === "platform" && installed?.cloudflare) {
|
|
2795
3754
|
config.runtime.environment.cloudflare = installed.cloudflare;
|
|
3755
|
+
config.runtime.environment.realtime = installed.realtime;
|
|
2796
3756
|
config.runtime.environment = validatePlatformSharedEnvironment(config.runtime.environment);
|
|
2797
|
-
} else if (!(config.kind === "
|
|
3757
|
+
} else if (!(config.kind === "enrolled-compute" && installed?.cloudflareTunnelId)) {
|
|
2798
3758
|
throw new Error("node-specific Cloudflare host handoff is missing before credential sealing");
|
|
2799
3759
|
}
|
|
2800
3760
|
}
|
|
2801
3761
|
if (cloudflare && cloudflare.hostname !== config.nodeHostname) {
|
|
2802
3762
|
throw new Error("Cloudflare handoff hostname disagrees with node hostname");
|
|
2803
3763
|
}
|
|
3764
|
+
if (cloudflare && Boolean(cloudflare.mesh) !== Boolean(config.installWarp)) {
|
|
3765
|
+
throw new Error("Cloudflare Mesh handoff and installWarp selection disagree");
|
|
3766
|
+
}
|
|
2804
3767
|
if (cloudflare && config.kind === "platform") {
|
|
2805
3768
|
const expected = config.runtime.environment.cloudflare;
|
|
2806
3769
|
if (expected && (cloudflare.service !== expected.tunnelService || cloudflare.accountId !== expected.accountId || cloudflare.zoneId !== expected.zoneId || cloudflare.kvNamespaceId !== expected.kvNamespaceId || cloudflare.tunnelId !== expected.tunnelId)) {
|
|
@@ -2811,19 +3774,25 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
2811
3774
|
zoneId: cloudflare.zoneId,
|
|
2812
3775
|
kvNamespaceId: cloudflare.kvNamespaceId,
|
|
2813
3776
|
tunnelId: cloudflare.tunnelId,
|
|
2814
|
-
tunnelService: cloudflare.service
|
|
2815
|
-
...cloudflare.warp ? { warp: {
|
|
2816
|
-
organization: cloudflare.warp.organization,
|
|
2817
|
-
virtualNetworkId: cloudflare.warp.virtualNetworkId,
|
|
2818
|
-
deviceProfileId: cloudflare.warp.deviceProfileId
|
|
2819
|
-
} } : {}
|
|
3777
|
+
tunnelService: cloudflare.service
|
|
2820
3778
|
};
|
|
2821
3779
|
if (expected && JSON.stringify(expected) !== JSON.stringify(discovered)) {
|
|
2822
|
-
throw new Error("Cloudflare handoff disagrees with immutable
|
|
3780
|
+
throw new Error("Cloudflare handoff disagrees with immutable runtime coordinates");
|
|
2823
3781
|
}
|
|
2824
3782
|
config.runtime.environment.cloudflare = expected ?? discovered;
|
|
2825
|
-
if (
|
|
2826
|
-
|
|
3783
|
+
if (cloudflare.realtime) {
|
|
3784
|
+
const expectedRealtime = config.runtime.environment.realtime;
|
|
3785
|
+
const discoveredRealtime = {
|
|
3786
|
+
workerScriptName: cloudflare.realtime.workerScriptName,
|
|
3787
|
+
endpoint: cloudflare.realtime.endpoint,
|
|
3788
|
+
producer: cloudflare.realtime.producer
|
|
3789
|
+
};
|
|
3790
|
+
if (expectedRealtime && JSON.stringify(expectedRealtime) !== JSON.stringify(discoveredRealtime)) {
|
|
3791
|
+
throw new Error("Cloudflare realtime handoff disagrees with immutable runtime coordinates");
|
|
3792
|
+
}
|
|
3793
|
+
config.runtime.environment.realtime = expectedRealtime ?? discoveredRealtime;
|
|
3794
|
+
} else if (config.runtime.environment.realtime) {
|
|
3795
|
+
throw new Error("platform realtime coordinates require a realtime-enabled Cloudflare handoff");
|
|
2827
3796
|
}
|
|
2828
3797
|
}
|
|
2829
3798
|
if (installed) {
|
|
@@ -2831,21 +3800,30 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
2831
3800
|
throw new Error("bootstrap repair coordinates do not match the installed host identity");
|
|
2832
3801
|
}
|
|
2833
3802
|
}
|
|
3803
|
+
const platformPrivate = config.kind === "platform" ? {
|
|
3804
|
+
root: privateFile(host, config.database.bootstrapSecretFile, "database bootstrap secret"),
|
|
3805
|
+
email: config.runtime.credentialFiles?.emailSecret && !host.exists(`${CREDS}/${config.runtime.environment.email?.provider === "jetemail" ? "bootstrap-jetemail-api-key" : "bootstrap-smtp-password"}.cred`) ? privateFile(host, config.runtime.credentialFiles.emailSecret, "bootstrap email credential") : undefined,
|
|
3806
|
+
backup: config.runtime.credentialFiles?.backupS3Secret && !host.exists(`${CREDS}/backup-s3-secret.cred`) ? privateFile(host, config.runtime.credentialFiles.backupS3Secret, "backup S3 credential") : undefined
|
|
3807
|
+
} : undefined;
|
|
3808
|
+
if (config.kind === "enrolled-compute" && config.bootstrapRunner?.sshPrivateKeyFile && !host.exists(BOOTSTRAP_SSH_CREDENTIAL)) {
|
|
3809
|
+
privateFile(host, config.bootstrapRunner.sshPrivateKeyFile, "bootstrap runner SSH private key");
|
|
3810
|
+
}
|
|
2834
3811
|
bindBootstrapIntent(host, config);
|
|
2835
3812
|
const plan = planBootstrap(config, host.exists(STATE_PATH));
|
|
2836
3813
|
const alreadyEnrolled = host.exists("/var/lib/forgezero/enrolment.json");
|
|
2837
3814
|
host.mkdir(CREDS, 448);
|
|
2838
3815
|
host.mkdir("/var/lib/forgezero", 448);
|
|
2839
|
-
if (
|
|
2840
|
-
await seal(host, "
|
|
2841
|
-
host.remove(config.bootstrapRunner.sshPrivateKeyFile);
|
|
3816
|
+
if (cloudflare && !host.exists(CF_API_CREDENTIAL)) {
|
|
3817
|
+
await seal(host, "CF_API_TOKEN", CF_API_CREDENTIAL, cloudflare.apiToken);
|
|
2842
3818
|
}
|
|
2843
|
-
if (cloudflare?.
|
|
2844
|
-
await seal(host, "
|
|
2845
|
-
await seal(host, "warp-auth-client-secret", WARP_CLIENT_SECRET_CREDENTIAL, cloudflare.warp.clientSecret);
|
|
3819
|
+
if (cloudflare?.realtime && !host.exists(REALTIME_PUBLISH_CREDENTIAL)) {
|
|
3820
|
+
await seal(host, "REALTIME_PUBLISH_SECRET", REALTIME_PUBLISH_CREDENTIAL, cloudflare.realtime.publishSecret);
|
|
2846
3821
|
}
|
|
2847
|
-
if (cloudflare && !host.exists(
|
|
2848
|
-
await seal(host, "
|
|
3822
|
+
if (cloudflare?.realtime && !host.exists(REALTIME_TICKET_CREDENTIAL)) {
|
|
3823
|
+
await seal(host, "REALTIME_TICKET_SECRET", REALTIME_TICKET_CREDENTIAL, cloudflare.realtime.ticketSecret);
|
|
3824
|
+
}
|
|
3825
|
+
if (cloudflare?.mesh && !host.exists(WARP_CONNECTOR_CREDENTIAL)) {
|
|
3826
|
+
await seal(host, "CF_WARP_CONNECTOR_TOKEN", WARP_CONNECTOR_CREDENTIAL, cloudflare.mesh.connectorToken);
|
|
2849
3827
|
}
|
|
2850
3828
|
await host.installAgent(config);
|
|
2851
3829
|
if (config.kind === "platform" && config.firewall.enabled) {
|
|
@@ -2853,50 +3831,52 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
2853
3831
|
} else
|
|
2854
3832
|
await host.ensureSoftware(plan.software);
|
|
2855
3833
|
if (config.kind === "platform" && config.firewall.enabled) {
|
|
2856
|
-
await
|
|
2857
|
-
await
|
|
2858
|
-
await
|
|
3834
|
+
await checked2(host, ["ufw", "--force", "default", "deny", "incoming"], "firewall inbound policy");
|
|
3835
|
+
await checked2(host, ["ufw", "--force", "default", "allow", "outgoing"], "firewall outbound policy");
|
|
3836
|
+
await checked2(host, ["ufw", "allow", `${config.firewall.sshPort}/tcp`], "firewall SSH rule");
|
|
2859
3837
|
for (const cidr of config.firewall.privateCidrs) {
|
|
2860
3838
|
if (config.database.role !== "none")
|
|
2861
|
-
await
|
|
3839
|
+
await checked2(host, ["ufw", "allow", "from", cidr, "to", "any", "port", "8528:8539", "proto", "tcp"], "database firewall rule");
|
|
2862
3840
|
for (const port of [config.runtime.bluePort, config.runtime.greenPort])
|
|
2863
|
-
await
|
|
3841
|
+
await checked2(host, ["ufw", "allow", "from", cidr, "to", "any", "port", String(port), "proto", "tcp"], "seed-mesh firewall rule");
|
|
2864
3842
|
}
|
|
2865
|
-
await
|
|
3843
|
+
await checked2(host, ["ufw", "--force", "enable"], "firewall activation");
|
|
2866
3844
|
await host.ensureSoftware(plan.software.filter(({ id }) => id !== "ufw"));
|
|
2867
3845
|
}
|
|
2868
3846
|
if (config.kind === "platform") {
|
|
2869
|
-
const root =
|
|
3847
|
+
const root = platformPrivate.root;
|
|
2870
3848
|
if (!host.exists(JWT_CREDENTIAL)) {
|
|
2871
3849
|
await seal(host, "arangodb-jwt", JWT_CREDENTIAL, derive(root, "forgezero/cluster/arangodb-jwt/v1"));
|
|
2872
3850
|
}
|
|
2873
3851
|
await seal(host, "seed-sync-root", SEED_CREDENTIAL, derive(root, "forgezero/cluster/seed-mesh/v1"));
|
|
2874
3852
|
await seal(host, "backup-recovery-root", BACKUP_RECOVERY_CREDENTIAL, derive(root, "forgezero/backup/recovery-root/v1"));
|
|
2875
3853
|
const credentialFiles = config.runtime.credentialFiles ?? {};
|
|
3854
|
+
const emailCredentialName = config.runtime.environment.email?.provider === "smtp" ? "bootstrap-smtp-password" : config.runtime.environment.email?.provider === "jetemail" ? "bootstrap-jetemail-api-key" : undefined;
|
|
3855
|
+
if (Boolean(config.runtime.environment.email) !== Boolean(credentialFiles.emailSecret)) {
|
|
3856
|
+
throw new Error("Bootstrap email configuration and its owner-only credential file must be supplied together");
|
|
3857
|
+
}
|
|
2876
3858
|
for (const [name, source] of Object.entries({
|
|
2877
|
-
|
|
2878
|
-
"backup-s3-secret":
|
|
3859
|
+
...emailCredentialName ? { [emailCredentialName]: platformPrivate.email } : {},
|
|
3860
|
+
"backup-s3-secret": platformPrivate.backup
|
|
2879
3861
|
})) {
|
|
2880
3862
|
if (source) {
|
|
2881
|
-
const
|
|
2882
|
-
if (!host.exists(
|
|
2883
|
-
await seal(host, name,
|
|
2884
|
-
|
|
3863
|
+
const destination2 = `${CREDS}/${name}.cred`;
|
|
3864
|
+
if (!host.exists(destination2)) {
|
|
3865
|
+
await seal(host, name, destination2, source);
|
|
3866
|
+
const sourcePath = name === "backup-s3-secret" ? credentialFiles.backupS3Secret : credentialFiles.emailSecret;
|
|
3867
|
+
if (sourcePath)
|
|
3868
|
+
host.remove(sourcePath);
|
|
2885
3869
|
}
|
|
2886
3870
|
}
|
|
2887
3871
|
}
|
|
2888
|
-
if (cloudflare) {
|
|
2889
|
-
if (cloudflare.privateNetworkRuntimeToken)
|
|
2890
|
-
await seal(host, "cloudflare-network-token", `${CREDS}/cloudflare-network-token.cred`, cloudflare.privateNetworkRuntimeToken);
|
|
2891
|
-
}
|
|
2892
3872
|
const runtime = config.runtime;
|
|
3873
|
+
await ensureForgeZeroOtelCollector(host, config.telemetryEndpoint);
|
|
2893
3874
|
const cloudflareConfigured = Boolean(runtime.environment.cloudflare);
|
|
2894
|
-
const cloudflareNetworkConfigured = Boolean(runtime.environment.cloudflare?.warp);
|
|
2895
3875
|
const envPath = `${runtime.environment.sharedDirectory}/.env`;
|
|
2896
3876
|
const credentials = platformApiCredentialSpecs({
|
|
2897
|
-
|
|
3877
|
+
emailProvider: runtime.environment.email?.provider,
|
|
2898
3878
|
cloudflareKv: cloudflareConfigured,
|
|
2899
|
-
|
|
3879
|
+
realtime: Boolean(runtime.environment.realtime)
|
|
2900
3880
|
});
|
|
2901
3881
|
const units = renderPlatformApiUnits({
|
|
2902
3882
|
serviceUser: runtime.serviceUser,
|
|
@@ -2924,8 +3904,8 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
2924
3904
|
keepReleases: runtime.keepReleases,
|
|
2925
3905
|
drainDeadlineMs: runtime.environment.drainDeadlineMs
|
|
2926
3906
|
});
|
|
2927
|
-
await
|
|
2928
|
-
await
|
|
3907
|
+
await checked2(host, ["useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", runtime.serviceUser], "API service account").catch(async () => {
|
|
3908
|
+
await checked2(host, ["id", runtime.serviceUser], "existing API service account");
|
|
2929
3909
|
});
|
|
2930
3910
|
host.mkdir(runtime.environment.sharedDirectory, 488);
|
|
2931
3911
|
host.mkdir(runtime.slotsDirectory, 493);
|
|
@@ -2934,39 +3914,39 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
2934
3914
|
host.write("/etc/forgezero/capacity.env", `FZ_CONCURRENCY_LIMIT=${runtime.environment.concurrencyLimit}
|
|
2935
3915
|
`, 420);
|
|
2936
3916
|
}
|
|
2937
|
-
await
|
|
3917
|
+
await checked2(host, ["chown", `root:${runtime.serviceUser}`, runtime.environment.sharedDirectory, envPath], "runtime ownership");
|
|
2938
3918
|
host.write("/etc/systemd/system/forgezero@.service", units.template, 420);
|
|
2939
3919
|
host.write("/etc/systemd/system/forgezero@blue.service.d/port.conf", units.dropIns.blue, 420);
|
|
2940
3920
|
host.write("/etc/systemd/system/forgezero@green.service.d/port.conf", units.dropIns.green, 420);
|
|
2941
3921
|
host.write("/etc/nginx/conf.d/forgezero-upstream.conf", edge.upstream, 420);
|
|
2942
3922
|
host.write("/etc/nginx/conf.d/forgezero.conf", edge.site, 420);
|
|
2943
3923
|
host.write("/etc/forgezero/deploy.env", activation.environment, 420);
|
|
2944
|
-
host.write("/
|
|
3924
|
+
host.write("/etc/forgezero/deploy-activation.json", activation.helper, 384);
|
|
2945
3925
|
host.write("/etc/sudoers.d/forgezero-runner", activation.sudoers, 288);
|
|
2946
|
-
await
|
|
3926
|
+
await checked2(host, ["visudo", "-cf", "/etc/sudoers.d/forgezero-runner"], "activation sudo policy");
|
|
2947
3927
|
const telemetry = planLocalOtlpProof(runtime.environment.otlpEndpoint, runtime.environment.otlpCollectorUnit);
|
|
2948
|
-
await
|
|
2949
|
-
const otlpStatus = (await
|
|
3928
|
+
await checked2(host, telemetry.unitCheck.argv, "OTLP collector supervision");
|
|
3929
|
+
const otlpStatus = (await checked2(host, [telemetry.receiverCheck.command, ...telemetry.receiverCheck.argv], "OTLP receiver")).trim();
|
|
2950
3930
|
if (!/^2\d\d$/.test(otlpStatus))
|
|
2951
3931
|
throw new Error(`OTLP receiver returned HTTP ${otlpStatus || "unknown"}`);
|
|
2952
|
-
await
|
|
2953
|
-
await
|
|
2954
|
-
await
|
|
3932
|
+
await checked2(host, ["nginx", "-t"], "nginx configuration");
|
|
3933
|
+
await checked2(host, ["systemctl", "daemon-reload"], "systemd reload");
|
|
3934
|
+
await checked2(host, ["systemctl", "enable", "--now", "nginx.service"], "nginx supervision");
|
|
2955
3935
|
if (config.database.role === "master") {
|
|
2956
3936
|
const invite = `${runtime.environment.sharedDirectory}/platform-invite.token`;
|
|
2957
3937
|
if (!host.exists(invite)) {
|
|
2958
|
-
host.write(invite, `plt_${
|
|
3938
|
+
host.write(invite, `plt_${randomBytes3(24).toString("hex")}
|
|
2959
3939
|
`, 384);
|
|
2960
|
-
await
|
|
3940
|
+
await checked2(host, ["chown", `${runtime.serviceUser}:${runtime.serviceUser}`, invite], "platform invite ownership");
|
|
2961
3941
|
}
|
|
2962
3942
|
}
|
|
2963
3943
|
if (config.database.role !== "none") {
|
|
2964
3944
|
host.mkdir("/var/lib/forgezero-cluster", 448);
|
|
2965
|
-
await
|
|
3945
|
+
await checked2(host, ["chown", "arangodb:arangodb", "/var/lib/forgezero-cluster"], "database state ownership");
|
|
2966
3946
|
host.write("/etc/systemd/system/forgezero-db.service", databaseUnit(config), 420);
|
|
2967
3947
|
host.write("/etc/systemd/system/forgezero-db-verify.service", databaseVerifyUnit(config), 420);
|
|
2968
|
-
await
|
|
2969
|
-
await
|
|
3948
|
+
await checked2(host, ["systemctl", "daemon-reload"], "database unit reload");
|
|
3949
|
+
await checked2(host, ["systemctl", "enable", "--now", "forgezero-db.service", "forgezero-db-verify.service"], "database supervision");
|
|
2970
3950
|
const evidence = {
|
|
2971
3951
|
expectedMode: "default",
|
|
2972
3952
|
role: "COORDINATOR",
|
|
@@ -2984,7 +3964,7 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
2984
3964
|
host.write(PLATFORM_ENROL_SOURCE, `${enrolToken}
|
|
2985
3965
|
`, 384);
|
|
2986
3966
|
}
|
|
2987
|
-
await
|
|
3967
|
+
await checked2(host, [
|
|
2988
3968
|
"runuser",
|
|
2989
3969
|
"-u",
|
|
2990
3970
|
"forgezero-agent",
|
|
@@ -3001,18 +3981,25 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3001
3981
|
}
|
|
3002
3982
|
if (config.cloudflareHandoff) {
|
|
3003
3983
|
if (!host.exists(TUNNEL_CREDENTIAL) && cloudflare) {
|
|
3004
|
-
await seal(host, "
|
|
3984
|
+
await seal(host, "CF_TUNNEL_CONNECTOR_TOKEN", TUNNEL_CREDENTIAL, cloudflare.connectorToken);
|
|
3005
3985
|
}
|
|
3006
3986
|
if (!host.exists(TUNNEL_CREDENTIAL))
|
|
3007
3987
|
throw new Error("sealed cloudflared connector credential is missing");
|
|
3008
3988
|
host.write("/etc/systemd/system/cloudflared.service", tunnelUnit(), 420);
|
|
3009
|
-
await
|
|
3010
|
-
await
|
|
3989
|
+
await checked2(host, ["systemctl", "daemon-reload"], "cloudflared unit reload");
|
|
3990
|
+
await checked2(host, ["systemctl", "enable", "--now", "cloudflared.service"], "cloudflared connector supervision");
|
|
3011
3991
|
const tunnelId = cloudflare?.tunnelId ?? installed?.cloudflareTunnelId ?? (config.kind === "platform" ? config.runtime.environment.cloudflare?.tunnelId : undefined);
|
|
3012
3992
|
if (!tunnelId)
|
|
3013
3993
|
throw new Error("Cloudflare tunnel identity is missing after handoff validation");
|
|
3014
3994
|
await waitForCloudflaredTunnel(host, tunnelId);
|
|
3015
3995
|
}
|
|
3996
|
+
if (config.installWarp) {
|
|
3997
|
+
if (!host.exists(WARP_CONNECTOR_CREDENTIAL))
|
|
3998
|
+
throw new Error("sealed Cloudflare Mesh connector credential is missing");
|
|
3999
|
+
host.write("/etc/systemd/system/forgezero-mesh-config.service", meshConnectorUnit(), 420);
|
|
4000
|
+
await checked2(host, ["systemctl", "daemon-reload"], "Cloudflare Mesh unit reload");
|
|
4001
|
+
await checked2(host, ["systemctl", "enable", "--now", "warp-svc.service", "forgezero-mesh-config.service"], "Cloudflare Mesh connector supervision");
|
|
4002
|
+
}
|
|
3016
4003
|
host.write(STATE_PATH, stateFor(config, cloudflare, installed?.cloudflareTunnelId), 384);
|
|
3017
4004
|
const status = await bootstrapStatus(host);
|
|
3018
4005
|
if (!status.initialized)
|
|
@@ -3030,7 +4017,10 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3030
4017
|
if (!/^plt_[a-f0-9]{48}$/.test(token))
|
|
3031
4018
|
throw new Error("platform invite is malformed");
|
|
3032
4019
|
launch = {
|
|
3033
|
-
|
|
4020
|
+
executable: "/usr/local/bin/fz",
|
|
4021
|
+
argv: ["genesis", "--mode", "1-of-1", "--api", config.apiUrl, "--app", config.runtime.environment.appOrigin],
|
|
4022
|
+
environment: { FZ_SHARED_DIR: config.runtime.environment.sharedDirectory },
|
|
4023
|
+
runAs: config.runtime.serviceUser,
|
|
3034
4024
|
inviteUrl: `${config.runtime.environment.appOrigin}/invite?token=${encodeURIComponent(token)}`
|
|
3035
4025
|
};
|
|
3036
4026
|
}
|
|
@@ -3062,6 +4052,7 @@ function strictBootstrapDocument(value) {
|
|
|
3062
4052
|
"runtime",
|
|
3063
4053
|
"firewall",
|
|
3064
4054
|
"installCloudflared",
|
|
4055
|
+
"installWarp",
|
|
3065
4056
|
"cloudflareHandoff",
|
|
3066
4057
|
"realm",
|
|
3067
4058
|
"enrolTokenFile",
|
|
@@ -3111,46 +4102,53 @@ function strictBootstrapDocument(value) {
|
|
|
3111
4102
|
"otlpCollectorUnit",
|
|
3112
4103
|
"agentOtlpEndpoint",
|
|
3113
4104
|
"custodianEmail",
|
|
3114
|
-
"
|
|
4105
|
+
"email",
|
|
3115
4106
|
"repository",
|
|
3116
4107
|
"branch",
|
|
3117
4108
|
"deployProfile",
|
|
3118
4109
|
"otlpFlushIntervalMs",
|
|
3119
4110
|
"otlpTraceSampleRatio",
|
|
3120
4111
|
"backup",
|
|
3121
|
-
"cloudflare"
|
|
4112
|
+
"cloudflare",
|
|
4113
|
+
"realtime"
|
|
3122
4114
|
], "runtime environment");
|
|
3123
4115
|
if (runtime.credentialFiles !== undefined)
|
|
3124
|
-
exactKeys(runtime.credentialFiles, ["
|
|
4116
|
+
exactKeys(runtime.credentialFiles, ["emailSecret", "backupS3Secret"], "runtime credential files");
|
|
3125
4117
|
const environment = runtime.environment;
|
|
3126
|
-
if (environment.
|
|
3127
|
-
exactKeys(environment.
|
|
4118
|
+
if (environment.email !== undefined) {
|
|
4119
|
+
const email = exactKeys(environment.email, ["provider", "host", "port", "user", "from", "eu"], "email config");
|
|
4120
|
+
if (email.provider === "smtp")
|
|
4121
|
+
exactKeys(email, ["provider", "host", "port", "user", "from"], "SMTP config");
|
|
4122
|
+
else if (email.provider === "jetemail")
|
|
4123
|
+
exactKeys(email, ["provider", "from", "eu"], "JetEmail config");
|
|
4124
|
+
else
|
|
4125
|
+
throw new Error("email provider must be smtp or jetemail");
|
|
4126
|
+
}
|
|
3128
4127
|
if (environment.backup !== undefined)
|
|
3129
4128
|
exactKeys(environment.backup, ["endpoint", "region", "bucket", "accessKeyId"], "backup config");
|
|
3130
4129
|
if (environment.cloudflare !== undefined)
|
|
3131
|
-
exactKeys(environment.cloudflare, ["accountId", "zoneId", "kvNamespaceId", "tunnelId", "tunnelService"
|
|
3132
|
-
if (environment.
|
|
3133
|
-
exactKeys(environment.
|
|
3134
|
-
|
|
3135
|
-
} else if (root.kind === "tenant") {
|
|
4130
|
+
exactKeys(environment.cloudflare, ["accountId", "zoneId", "kvNamespaceId", "tunnelId", "tunnelService"], "Cloudflare runtime config");
|
|
4131
|
+
if (environment.realtime !== undefined)
|
|
4132
|
+
exactKeys(environment.realtime, ["workerScriptName", "endpoint", "producer"], "realtime runtime config");
|
|
4133
|
+
} else if (root.kind === "enrolled-compute") {
|
|
3136
4134
|
if (root.cloudflareHandoff !== undefined)
|
|
3137
4135
|
exactKeys(root.cloudflareHandoff, ["handoffFile", "nodeName"], "Cloudflare handoff");
|
|
3138
4136
|
if (root.bootstrapRunner !== undefined)
|
|
3139
4137
|
exactKeys(root.bootstrapRunner, ["sshPrivateKeyFile", "targetTelemetryEndpoint"], "bootstrap runner config");
|
|
3140
4138
|
for (const key of ["environment", "profile", "computeReference", "database", "enrolment", "runtime"]) {
|
|
3141
4139
|
if (root[key] !== undefined && key !== "profile")
|
|
3142
|
-
throw new Error(`
|
|
4140
|
+
throw new Error(`enrolled-compute activation cannot contain ${key}`);
|
|
3143
4141
|
}
|
|
3144
4142
|
} else
|
|
3145
|
-
throw new Error("bootstrap config kind must be platform or
|
|
4143
|
+
throw new Error("bootstrap config kind must be platform or enrolled-compute");
|
|
3146
4144
|
return value;
|
|
3147
4145
|
}
|
|
3148
4146
|
function readBootstrapConfig(path) {
|
|
3149
|
-
const metadata =
|
|
4147
|
+
const metadata = lstatSync2(path);
|
|
3150
4148
|
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.uid !== (process.getuid?.() ?? metadata.uid) || metadata.nlink !== 1 || (metadata.mode & 63) !== 0 || metadata.size > 64 * 1024) {
|
|
3151
4149
|
throw new Error("bootstrap config must be an owner-only regular file with one link and at most 64 KiB");
|
|
3152
4150
|
}
|
|
3153
|
-
return validateBootstrapConfig(strictBootstrapDocument(JSON.parse(
|
|
4151
|
+
return validateBootstrapConfig(strictBootstrapDocument(JSON.parse(readFileSync3(path, "utf8"))));
|
|
3154
4152
|
}
|
|
3155
4153
|
function localBootstrapHost() {
|
|
3156
4154
|
const execute = async (argv, options = {}) => {
|
|
@@ -3168,19 +4166,19 @@ function localBootstrapHost() {
|
|
|
3168
4166
|
};
|
|
3169
4167
|
return {
|
|
3170
4168
|
uid: () => process.getuid?.() ?? -1,
|
|
3171
|
-
exists:
|
|
3172
|
-
read: (path) =>
|
|
4169
|
+
exists: existsSync3,
|
|
4170
|
+
read: (path) => readFileSync3(path, "utf8"),
|
|
3173
4171
|
write(path, content, mode) {
|
|
3174
|
-
|
|
4172
|
+
mkdirSync3(dirname4(path), { recursive: true, mode: 493 });
|
|
3175
4173
|
const temporary = `${path}.next.${process.pid}`;
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
4174
|
+
writeFileSync3(temporary, content, { mode });
|
|
4175
|
+
chmodSync2(temporary, mode);
|
|
4176
|
+
renameSync3(temporary, path);
|
|
3179
4177
|
},
|
|
3180
|
-
mkdir: (path, mode) =>
|
|
3181
|
-
remove: (path) =>
|
|
4178
|
+
mkdir: (path, mode) => mkdirSync3(path, { recursive: true, mode }),
|
|
4179
|
+
remove: (path) => rmSync3(path, { force: true }),
|
|
3182
4180
|
inspect(path) {
|
|
3183
|
-
const value =
|
|
4181
|
+
const value = lstatSync2(path);
|
|
3184
4182
|
return { regular: value.isFile(), symbolic: value.isSymbolicLink(), uid: value.uid, mode: value.mode, links: value.nlink, size: value.size };
|
|
3185
4183
|
},
|
|
3186
4184
|
exec: execute,
|
|
@@ -3202,6 +4200,7 @@ function localBootstrapHost() {
|
|
|
3202
4200
|
async installAgent(config, enrolTokenSourcePath) {
|
|
3203
4201
|
const capabilities = await readCapabilities(localRunner);
|
|
3204
4202
|
const deployRoot = config.deployRoot ?? "/opt/forgezero";
|
|
4203
|
+
const hasBinding = config.kind === "enrolled-compute" || Boolean(enrolTokenSourcePath) || existsSync3("/var/lib/forgezero/enrolment.json");
|
|
3205
4204
|
if (config.kind === "platform") {
|
|
3206
4205
|
const lifecycle = config.database.role === "none" ? {
|
|
3207
4206
|
apiUnits: ["forgezero@blue.service", "forgezero@green.service"],
|
|
@@ -3213,8 +4212,8 @@ function localBootstrapHost() {
|
|
|
3213
4212
|
databaseHealthUrl: `http://${config.database.address}:8529/_api/version`,
|
|
3214
4213
|
databasePorts: [8529]
|
|
3215
4214
|
};
|
|
3216
|
-
|
|
3217
|
-
|
|
4215
|
+
mkdirSync3(dirname4(LIFECYCLE_PROFILE), { recursive: true, mode: 493 });
|
|
4216
|
+
writeFileSync3(LIFECYCLE_PROFILE, `${JSON.stringify(lifecycle, null, 2)}
|
|
3218
4217
|
`, { mode: 256 });
|
|
3219
4218
|
}
|
|
3220
4219
|
const plan = planInstall({
|
|
@@ -3231,39 +4230,32 @@ function localBootstrapHost() {
|
|
|
3231
4230
|
gitCredentialPath: "/etc/forgezero/creds/git-deploy-key.cred",
|
|
3232
4231
|
gitPublicKeyPath: "/etc/forgezero/git/deploy.pub",
|
|
3233
4232
|
generateGitIdentity: true,
|
|
3234
|
-
pullDeployments:
|
|
3235
|
-
pullMigrations: config.kind === "platform",
|
|
3236
|
-
pullBootstrap: config.kind === "
|
|
3237
|
-
bootstrapSshCredentialPath: config.kind === "
|
|
3238
|
-
|
|
4233
|
+
pullDeployments: hasBinding,
|
|
4234
|
+
pullMigrations: config.kind === "platform" && hasBinding,
|
|
4235
|
+
pullBootstrap: platformBootstrapRunner(config) || config.kind === "enrolled-compute" && Boolean(config.bootstrapRunner),
|
|
4236
|
+
bootstrapSshCredentialPath: platformBootstrapRunner(config) || config.kind === "enrolled-compute" && config.bootstrapRunner ? BOOTSTRAP_SSH_CREDENTIAL : undefined,
|
|
4237
|
+
bootstrapSshSourcePath: config.kind === "enrolled-compute" ? config.bootstrapRunner?.sshPrivateKeyFile : undefined,
|
|
4238
|
+
bootstrapSshPublicKeyPath: platformBootstrapRunner(config) || config.kind === "enrolled-compute" && config.bootstrapRunner ? BOOTSTRAP_SSH_PUBLIC_KEY : undefined,
|
|
4239
|
+
bootstrapTargetTelemetryEndpoint: platformBootstrapRunner(config) ? config.telemetryEndpoint : config.kind === "enrolled-compute" ? config.bootstrapRunner?.targetTelemetryEndpoint : undefined,
|
|
3239
4240
|
lifecycleProfilePath: config.kind === "platform" ? LIFECYCLE_PROFILE : undefined,
|
|
3240
|
-
...config.kind === "platform" && config.runtime.environment.cloudflare?.warp ? {
|
|
3241
|
-
warpOrganization: config.runtime.environment.cloudflare.warp.organization,
|
|
3242
|
-
warpClientIdCredentialPath: WARP_CLIENT_ID_CREDENTIAL,
|
|
3243
|
-
warpClientSecretCredentialPath: WARP_CLIENT_SECRET_CREDENTIAL,
|
|
3244
|
-
cloudflareAccountId: config.runtime.environment.cloudflare.accountId,
|
|
3245
|
-
cloudflareTunnelId: config.runtime.environment.cloudflare.tunnelId,
|
|
3246
|
-
cloudflareVirtualNetworkId: config.runtime.environment.cloudflare.warp.virtualNetworkId,
|
|
3247
|
-
cloudflareWarpPolicyId: config.runtime.environment.cloudflare.warp.deviceProfileId
|
|
3248
|
-
} : {},
|
|
3249
4241
|
enforceEgress: true,
|
|
3250
4242
|
nodeHostname: config.nodeHostname,
|
|
3251
4243
|
telemetryEndpoint: config.telemetryEndpoint,
|
|
3252
4244
|
binPath: "/usr/local/lib/forgezero/agent/fz-agent",
|
|
3253
4245
|
sourceBinPath: PACKAGED_AGENT_BIN,
|
|
3254
|
-
...config.kind === "
|
|
3255
|
-
enrolTokenSourcePath: config.kind === "
|
|
4246
|
+
...config.kind === "enrolled-compute" || enrolTokenSourcePath ? {
|
|
4247
|
+
enrolTokenSourcePath: config.kind === "enrolled-compute" ? config.enrolTokenFile : enrolTokenSourcePath,
|
|
3256
4248
|
enrolTokenCredentialPath: ENROL_CREDENTIAL,
|
|
3257
4249
|
enrolStatePath: "/var/lib/forgezero/enrolment.json",
|
|
3258
4250
|
apiUrl: config.apiUrl,
|
|
3259
|
-
project: config.kind === "
|
|
3260
|
-
environment: config.kind === "
|
|
3261
|
-
nodeLabel: config.kind === "
|
|
4251
|
+
project: config.kind === "enrolled-compute" ? config.realm : "platform",
|
|
4252
|
+
environment: config.kind === "enrolled-compute" ? undefined : config.environment,
|
|
4253
|
+
nodeLabel: config.kind === "enrolled-compute" ? config.nodeHostname : config.computeReference
|
|
3262
4254
|
} : {}
|
|
3263
4255
|
});
|
|
3264
4256
|
for (const unit of [{ path: plan.unitPath, unit: plan.unit }, ...plan.auxiliaryUnits]) {
|
|
3265
|
-
|
|
3266
|
-
|
|
4257
|
+
mkdirSync3(dirname4(unit.path), { recursive: true, mode: 493 });
|
|
4258
|
+
writeFileSync3(unit.path, unit.unit, { mode: 420 });
|
|
3267
4259
|
}
|
|
3268
4260
|
await applyPlan(plan, localRunner);
|
|
3269
4261
|
return plan;
|