@forgezero/agent 0.1.41 → 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 +299 -86
- 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 +3932 -582
- package/dist/fz-git-ssh.js +122 -0
- package/dist/fz.js +3634 -1266
- 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
|
@@ -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
|
-
const supplied = Object.fromEntries(entries.filter(([, value]) => value !== undefined));
|
|
254
|
-
const tokens = {
|
|
255
|
-
...supplied.apiToken ? { apiToken: supplied.apiToken } : {},
|
|
256
|
-
...supplied.tunnelApiToken || supplied.managementApiToken ? {
|
|
257
|
-
tunnelApiToken: supplied.tunnelApiToken ?? supplied.managementApiToken
|
|
258
|
-
} : {},
|
|
259
|
-
...supplied.dnsApiToken || supplied.managementApiToken ? {
|
|
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");
|
|
270
346
|
}
|
|
271
|
-
|
|
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");
|
|
353
|
+
}
|
|
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,6 +1081,100 @@ 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
|
export {
|
|
684
1179
|
writeOwnerBootstrapOutput,
|
|
685
1180
|
verifyCloudflareBootstrapAcceptance,
|
|
@@ -689,7 +1184,9 @@ export {
|
|
|
689
1184
|
readCloudflareHostHandoff,
|
|
690
1185
|
readCloudflareConnectorHandoff,
|
|
691
1186
|
readCloudflareBootstrapTokens,
|
|
1187
|
+
readCloudflareBootstrapAcceptanceEvidence,
|
|
692
1188
|
planCloudflareBootstrap,
|
|
1189
|
+
finalizeCloudflareBootstrapAcceptance,
|
|
693
1190
|
cloudflareHostHandoffPath,
|
|
694
1191
|
applyCloudflareBootstrap
|
|
695
1192
|
};
|