@forgezero/agent 0.1.39 → 0.1.41
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 +357 -348
- package/dist/agent-heartbeat.js +16 -14
- package/dist/agent-update-helper.js +15 -13
- package/dist/agent-update.js +15 -13
- package/dist/bootstrap.d.ts +13 -0
- package/dist/bootstrap.js +244 -832
- package/dist/cli/index.d.ts +0 -2
- package/dist/cloudflare-bootstrap.d.ts +21 -128
- package/dist/cloudflare-bootstrap.js +146 -788
- package/dist/cloudflare-edge.d.ts +3 -151
- package/dist/cloudflare-edge.js +28 -248
- package/dist/credential-schema.d.ts +60 -0
- package/dist/credential-schema.js +335 -0
- package/dist/definition.js +2 -2
- package/dist/deploy-file.js +2 -2
- package/dist/fz-agent.js +1787 -3387
- package/dist/fz.js +1938 -4712
- package/dist/index.d.ts +33 -0
- package/dist/metal-bootstrap.js +1 -1
- package/dist/platform-bootstrap-runtime.d.ts +5 -0
- package/dist/platform-bootstrap-runtime.js +25 -6
- package/dist/project-context.js +1 -1
- package/dist/provision.js +18 -16
- package/dist/software-helper.js +2 -2
- package/dist/software.js +2 -2
- package/dist/version.d.ts +1 -1
- package/package.json +9 -5
- package/dist/cli/custody.d.ts +0 -35
package/dist/bootstrap.js
CHANGED
|
@@ -130,23 +130,32 @@ async function configureCloudflareEdge(config, fetcher = fetch) {
|
|
|
130
130
|
const tunnelAuth = { apiToken: config.tunnelApiToken?.trim() || config.apiToken };
|
|
131
131
|
const dnsAuth = { apiToken: config.dnsApiToken?.trim() || config.apiToken };
|
|
132
132
|
const tunnelPath = `/accounts/${config.accountId}/cfd_tunnel/${config.tunnelId}/configurations`;
|
|
133
|
-
const current = await cf(tunnelAuth, tunnelPath, {}, fetcher);
|
|
134
|
-
const existing = current.config?.ingress ?? [];
|
|
135
|
-
const catchAll = existing.filter((rule) => !("hostname" in rule));
|
|
136
|
-
const otherHosts = existing.filter((rule) => ("hostname" in rule) && rule.hostname !== config.hostname);
|
|
137
|
-
await cf(tunnelAuth, tunnelPath, {
|
|
138
|
-
method: "PUT",
|
|
139
|
-
body: JSON.stringify({ config: { ingress: [
|
|
140
|
-
{ hostname: config.hostname, service: config.service },
|
|
141
|
-
...otherHosts,
|
|
142
|
-
...catchAll.length > 0 ? catchAll : [{ service: "http_status:404" }]
|
|
143
|
-
] } })
|
|
144
|
-
}, fetcher);
|
|
145
133
|
const dnsPath = `/zones/${config.zoneId}/dns_records`;
|
|
146
|
-
const records = await
|
|
134
|
+
const [current, records] = await Promise.all([
|
|
135
|
+
cf(tunnelAuth, tunnelPath, {}, fetcher),
|
|
136
|
+
cf(dnsAuth, `${dnsPath}?name=${encodeURIComponent(config.hostname)}&per_page=1000`, {}, fetcher)
|
|
137
|
+
]);
|
|
147
138
|
if (records.length > 1) {
|
|
148
139
|
throw new Error(`Cloudflare DNS record for ${config.hostname} is ambiguous`);
|
|
149
140
|
}
|
|
141
|
+
const existingRecord = records[0];
|
|
142
|
+
if (existingRecord && (existingRecord.type !== "CNAME" || existingRecord.name && existingRecord.name.toLowerCase() !== config.hostname.toLowerCase())) {
|
|
143
|
+
throw new Error(`Cloudflare DNS hostname ${config.hostname} is already owned by an incompatible ${existingRecord.type ?? "unknown"} record`);
|
|
144
|
+
}
|
|
145
|
+
const existing = current.config?.ingress ?? [];
|
|
146
|
+
const catchAll = existing.filter((rule) => !("hostname" in rule));
|
|
147
|
+
const otherHosts = existing.filter((rule) => ("hostname" in rule) && rule.hostname !== config.hostname);
|
|
148
|
+
const desiredIngress = [
|
|
149
|
+
{ hostname: config.hostname, service: config.service },
|
|
150
|
+
...otherHosts,
|
|
151
|
+
...catchAll.length > 0 ? catchAll : [{ service: "http_status:404" }]
|
|
152
|
+
];
|
|
153
|
+
if (JSON.stringify(existing) !== JSON.stringify(desiredIngress)) {
|
|
154
|
+
await cf(tunnelAuth, tunnelPath, {
|
|
155
|
+
method: "PUT",
|
|
156
|
+
body: JSON.stringify({ config: { ingress: desiredIngress } })
|
|
157
|
+
}, fetcher);
|
|
158
|
+
}
|
|
150
159
|
const record = {
|
|
151
160
|
type: "CNAME",
|
|
152
161
|
name: config.hostname,
|
|
@@ -154,233 +163,13 @@ async function configureCloudflareEdge(config, fetcher = fetch) {
|
|
|
154
163
|
proxied: true,
|
|
155
164
|
ttl: 1
|
|
156
165
|
};
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const groups = await cf(config, `/accounts/${config.accountId}/tokens/permission_groups?name=${encodeURIComponent(name)}` + "&scope=com.cloudflare.api.account", {}, fetcher);
|
|
164
|
-
const matches = groups.filter((group) => group.name === name && group.scopes?.includes("com.cloudflare.api.account") && Boolean(group.id && /^[a-f0-9]{32}$/i.test(group.id)));
|
|
165
|
-
if (matches.length !== 1) {
|
|
166
|
-
throw new Error(`Cloudflare account token permission group ${name} is ${matches.length === 0 ? "missing" : "ambiguous"}`);
|
|
167
|
-
}
|
|
168
|
-
return { id: matches[0].id, name };
|
|
169
|
-
};
|
|
170
|
-
async function createCloudflareAccountRuntimeToken(config, fetcher = fetch) {
|
|
171
|
-
if (!/^[a-f0-9]{32}$/i.test(config.accountId))
|
|
172
|
-
throw new Error("Cloudflare account id is invalid");
|
|
173
|
-
const name = config.name.trim();
|
|
174
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,119}$/.test(name)) {
|
|
175
|
-
throw new Error("Cloudflare account runtime-token name is invalid");
|
|
176
|
-
}
|
|
177
|
-
const permissionNames = [...new Set(config.permissionNames)];
|
|
178
|
-
if (permissionNames.length === 0)
|
|
179
|
-
throw new Error("Cloudflare account runtime token needs a permission group");
|
|
180
|
-
const permissionGroups = await Promise.all(permissionNames.map((permissionName) => exactAccountPermissionGroup(config, permissionName, fetcher)));
|
|
181
|
-
const body = {
|
|
182
|
-
name,
|
|
183
|
-
policies: [{
|
|
184
|
-
effect: "allow",
|
|
185
|
-
permission_groups: permissionGroups.map(({ id }) => ({ id })),
|
|
186
|
-
resources: { [`com.cloudflare.api.account.${config.accountId}`]: "*" }
|
|
187
|
-
}]
|
|
188
|
-
};
|
|
189
|
-
const created = await cf(config, `/accounts/${config.accountId}/tokens`, { method: "POST", body: JSON.stringify(body) }, fetcher);
|
|
190
|
-
if (!created.id || !/^[a-f0-9]{32}$/i.test(created.id) || !created.value || !/^[A-Za-z0-9._-]{40,80}$/.test(created.value)) {
|
|
191
|
-
throw new Error("Cloudflare did not return the one-time account runtime-token id and value");
|
|
192
|
-
}
|
|
193
|
-
return { id: created.id, value: created.value, name, permissionNames };
|
|
194
|
-
}
|
|
195
|
-
async function ensureCloudflareAccessServiceToken(config, fetcher = fetch) {
|
|
196
|
-
const name = config.name.trim();
|
|
197
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name)) {
|
|
198
|
-
throw new Error("Cloudflare Access service-token name is invalid");
|
|
199
|
-
}
|
|
200
|
-
const path = `/accounts/${config.accountId}/access/service_tokens`;
|
|
201
|
-
const tokens = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
202
|
-
const matches = tokens.filter((token) => token.name === name);
|
|
203
|
-
if (matches.length > 1)
|
|
204
|
-
throw new Error(`Cloudflare Access service token ${name} is ambiguous`);
|
|
205
|
-
if (matches[0]) {
|
|
206
|
-
if (!config.existing || config.existing.tokenId !== matches[0].id || config.existing.clientId !== matches[0].client_id || !config.existing.clientSecret) {
|
|
207
|
-
throw new Error(`Cloudflare Access service token ${name} exists but its one-time client secret was not supplied`);
|
|
208
|
-
}
|
|
209
|
-
return { credentials: config.existing, created: false };
|
|
210
|
-
}
|
|
211
|
-
const created = await cf(config, path, {
|
|
212
|
-
method: "POST",
|
|
213
|
-
body: JSON.stringify({ name, duration: config.duration ?? "8760h" })
|
|
214
|
-
}, fetcher);
|
|
215
|
-
if (!created.id || !created.client_id || !created.client_secret) {
|
|
216
|
-
throw new Error("Cloudflare did not return the new Access service-token secret");
|
|
217
|
-
}
|
|
218
|
-
return {
|
|
219
|
-
credentials: {
|
|
220
|
-
tokenId: created.id,
|
|
221
|
-
clientId: created.client_id,
|
|
222
|
-
clientSecret: created.client_secret
|
|
223
|
-
},
|
|
224
|
-
created: true
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
async function ensureCloudflareAccessPolicy(config, fetcher = fetch) {
|
|
228
|
-
const path = `/accounts/${config.accountId}/access/policies`;
|
|
229
|
-
const policies = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
230
|
-
const matches = policies.filter((policy2) => policy2.name === config.name);
|
|
231
|
-
if (matches.length > 1)
|
|
232
|
-
throw new Error(`Cloudflare Access policy ${config.name} is ambiguous`);
|
|
233
|
-
const desired = {
|
|
234
|
-
name: config.name,
|
|
235
|
-
decision: "non_identity",
|
|
236
|
-
include: [{ service_token: { token_id: config.serviceTokenId } }]
|
|
237
|
-
};
|
|
238
|
-
const policy = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, {
|
|
239
|
-
method: matches[0] ? "PUT" : "POST",
|
|
240
|
-
body: JSON.stringify(desired)
|
|
241
|
-
}, fetcher);
|
|
242
|
-
return { policy, created: !matches[0] };
|
|
243
|
-
}
|
|
244
|
-
async function ensureCloudflareAccessApplication(config, fetcher = fetch) {
|
|
245
|
-
const path = `/accounts/${config.accountId}/access/apps`;
|
|
246
|
-
const applications = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
247
|
-
const matches = applications.filter((application2) => application2.domain === config.hostname || application2.self_hosted_domains?.includes(config.hostname));
|
|
248
|
-
if (matches.length > 1)
|
|
249
|
-
throw new Error(`Cloudflare Access application for ${config.hostname} is ambiguous`);
|
|
250
|
-
const desired = {
|
|
251
|
-
name: config.name,
|
|
252
|
-
type: "self_hosted",
|
|
253
|
-
domain: config.hostname,
|
|
254
|
-
session_duration: "24h",
|
|
255
|
-
service_auth_401_redirect: true,
|
|
256
|
-
policies: [{ id: config.policyId, precedence: 1 }]
|
|
257
|
-
};
|
|
258
|
-
const application = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, { method: matches[0] ? "PUT" : "POST", body: JSON.stringify(desired) }, fetcher);
|
|
259
|
-
return { application, created: !matches[0] };
|
|
260
|
-
}
|
|
261
|
-
async function ensureCloudflareWarpEnrollmentApplication(config, fetcher = fetch) {
|
|
262
|
-
const name = config.name.trim();
|
|
263
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name)) {
|
|
264
|
-
throw new Error("Cloudflare WARP enrollment application name is invalid");
|
|
265
|
-
}
|
|
266
|
-
const path = `/accounts/${config.accountId}/access/apps`;
|
|
267
|
-
const applications = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
268
|
-
const matches = applications.filter((application2) => application2.type === "warp" || application2.name === name);
|
|
269
|
-
if (matches.length > 1)
|
|
270
|
-
throw new Error(`Cloudflare WARP enrollment application ${name} is ambiguous`);
|
|
271
|
-
if (matches[0] && (matches[0].type !== "warp" || matches[0].name !== name)) {
|
|
272
|
-
throw new Error(`Cloudflare Access application ${name} is not the owned WARP enrollment application`);
|
|
273
|
-
}
|
|
274
|
-
const desired = {
|
|
275
|
-
name,
|
|
276
|
-
type: "warp",
|
|
277
|
-
policies: [{ id: config.policyId, precedence: 1 }]
|
|
278
|
-
};
|
|
279
|
-
const application = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, { method: matches[0] ? "PUT" : "POST", body: JSON.stringify(desired) }, fetcher);
|
|
280
|
-
if (!application.id || application.type && application.type !== "warp") {
|
|
281
|
-
throw new Error("Cloudflare did not return the WARP enrollment application");
|
|
282
|
-
}
|
|
283
|
-
return { application, created: !matches[0] };
|
|
284
|
-
}
|
|
285
|
-
async function ensureCloudflareVirtualNetwork(config, fetcher = fetch) {
|
|
286
|
-
const name = config.name.trim();
|
|
287
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name))
|
|
288
|
-
throw new Error("Cloudflare VNET name is invalid");
|
|
289
|
-
const path = `/accounts/${config.accountId}/teamnet/virtual_networks`;
|
|
290
|
-
const networks = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
291
|
-
const matches = networks.filter((network) => !network.deleted_at && network.name === name);
|
|
292
|
-
if (matches.length > 1)
|
|
293
|
-
throw new Error(`Cloudflare VNET ${name} is ambiguous`);
|
|
294
|
-
if (matches[0])
|
|
295
|
-
return { virtualNetwork: matches[0], created: false };
|
|
296
|
-
const virtualNetwork = await cf(config, path, {
|
|
297
|
-
method: "POST",
|
|
298
|
-
body: JSON.stringify({ name, comment: config.comment.slice(0, 256), is_default_network: false })
|
|
299
|
-
}, fetcher);
|
|
300
|
-
if (!virtualNetwork.id || !/^[0-9a-f-]{36}$/i.test(virtualNetwork.id)) {
|
|
301
|
-
throw new Error("Cloudflare did not return the VNET id");
|
|
302
|
-
}
|
|
303
|
-
return { virtualNetwork, created: true };
|
|
304
|
-
}
|
|
305
|
-
async function ensureCloudflareWarpDevicePolicy(config, fetcher = fetch) {
|
|
306
|
-
const name = config.name.trim();
|
|
307
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,99}$/.test(name))
|
|
308
|
-
throw new Error("Cloudflare WARP device profile name is invalid");
|
|
309
|
-
if (!/^[A-Za-z0-9_-]{1,128}$/.test(config.serviceTokenId))
|
|
310
|
-
throw new Error("Cloudflare service-token id is invalid");
|
|
311
|
-
if (!/^[0-9a-f-]{36}$/i.test(config.virtualNetworkId))
|
|
312
|
-
throw new Error("Cloudflare VNET id is invalid");
|
|
313
|
-
const precedence = config.precedence ?? 100;
|
|
314
|
-
if (!Number.isInteger(precedence) || precedence < 1 || precedence > 999999) {
|
|
315
|
-
throw new Error("Cloudflare WARP device profile precedence is invalid");
|
|
316
|
-
}
|
|
317
|
-
const match = `identity.service_token_uuid == "${config.serviceTokenId}"`;
|
|
318
|
-
const listPath = `/accounts/${config.accountId}/devices/policies`;
|
|
319
|
-
const path = `/accounts/${config.accountId}/devices/policy`;
|
|
320
|
-
const policies = await cf(config, `${listPath}?per_page=1000`, {}, fetcher);
|
|
321
|
-
const matches = policies.filter((policy2) => policy2.name === name);
|
|
322
|
-
if (matches.length > 1)
|
|
323
|
-
throw new Error(`Cloudflare WARP device profile ${name} is ambiguous`);
|
|
324
|
-
if (matches[0]?.match && matches[0].match !== match) {
|
|
325
|
-
throw new Error(`Cloudflare WARP device profile ${name} belongs to another enrollment identity`);
|
|
326
|
-
}
|
|
327
|
-
const desired = {
|
|
328
|
-
name,
|
|
329
|
-
match,
|
|
330
|
-
precedence,
|
|
331
|
-
description: "ForgeZero non-interactive compute enrollment",
|
|
332
|
-
enabled: true,
|
|
333
|
-
allow_mode_switch: false,
|
|
334
|
-
allowed_to_leave: false,
|
|
335
|
-
auto_connect: 0,
|
|
336
|
-
switch_locked: true,
|
|
337
|
-
service_mode_v2: { mode: "warp" },
|
|
338
|
-
virtual_networks: { allowed: [config.virtualNetworkId], default: config.virtualNetworkId }
|
|
339
|
-
};
|
|
340
|
-
const policy = await cf(config, matches[0] ? `${path}/${encodeURIComponent(matches[0].id)}` : path, { method: matches[0] ? "PATCH" : "POST", body: JSON.stringify(desired) }, fetcher);
|
|
341
|
-
if (!policy.id)
|
|
342
|
-
throw new Error("Cloudflare did not return the WARP device profile id");
|
|
343
|
-
return { policy, created: !matches[0] };
|
|
344
|
-
}
|
|
345
|
-
async function configureCloudflareWorkerAccessSecrets(config, fetcher = fetch) {
|
|
346
|
-
if (!/^[a-z][a-z0-9-]{0,62}$/.test(config.scriptName)) {
|
|
347
|
-
throw new Error("Cloudflare Worker script name is invalid");
|
|
348
|
-
}
|
|
349
|
-
await cf(config, `/accounts/${config.accountId}/workers/scripts/${config.scriptName}/secrets-bulk`, {
|
|
350
|
-
method: "PATCH",
|
|
351
|
-
body: JSON.stringify({
|
|
352
|
-
secrets: {
|
|
353
|
-
CF_ACCESS_CLIENT_ID: {
|
|
354
|
-
name: "CF_ACCESS_CLIENT_ID",
|
|
355
|
-
type: "secret_text",
|
|
356
|
-
text: config.credentials.clientId
|
|
357
|
-
},
|
|
358
|
-
CF_ACCESS_CLIENT_SECRET: {
|
|
359
|
-
name: "CF_ACCESS_CLIENT_SECRET",
|
|
360
|
-
type: "secret_text",
|
|
361
|
-
text: config.credentials.clientSecret
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
})
|
|
365
|
-
}, fetcher);
|
|
366
|
-
}
|
|
367
|
-
async function ensureCloudflareKvNamespace(config, fetcher = fetch) {
|
|
368
|
-
const title = config.title.trim();
|
|
369
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(title)) {
|
|
370
|
-
throw new Error("Cloudflare KV namespace title is invalid");
|
|
166
|
+
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
|
+
if (!dnsAlreadyCorrect) {
|
|
168
|
+
await cf(dnsAuth, existingRecord ? `${dnsPath}/${encodeURIComponent(existingRecord.id)}` : dnsPath, {
|
|
169
|
+
method: existingRecord ? "PUT" : "POST",
|
|
170
|
+
body: JSON.stringify(record)
|
|
171
|
+
}, fetcher);
|
|
371
172
|
}
|
|
372
|
-
const path = `/accounts/${config.accountId}/storage/kv/namespaces`;
|
|
373
|
-
const namespaces = await cf(config, `${path}?per_page=1000`, {}, fetcher);
|
|
374
|
-
const matches = namespaces.filter((namespace2) => namespace2.title === title);
|
|
375
|
-
if (matches.length > 1)
|
|
376
|
-
throw new Error(`Cloudflare KV namespace ${title} is ambiguous`);
|
|
377
|
-
if (matches[0])
|
|
378
|
-
return { namespace: matches[0], created: false };
|
|
379
|
-
const namespace = await cf(config, path, {
|
|
380
|
-
method: "POST",
|
|
381
|
-
body: JSON.stringify({ title })
|
|
382
|
-
}, fetcher);
|
|
383
|
-
return { namespace, created: true };
|
|
384
173
|
}
|
|
385
174
|
async function ensureCloudflareTunnel(config, fetcher = fetch) {
|
|
386
175
|
const name = config.name.trim();
|
|
@@ -406,27 +195,13 @@ async function ensureCloudflareTunnel(config, fetcher = fetch) {
|
|
|
406
195
|
|
|
407
196
|
// src/cloudflare-bootstrap.ts
|
|
408
197
|
import { constants } from "fs";
|
|
409
|
-
import { chmod, lstat, mkdir, mkdtemp, open, rename, rm, stat, unlink } from "fs/promises";
|
|
410
|
-
import { dirname, join, resolve } from "path";
|
|
411
|
-
import { tmpdir } from "os";
|
|
412
198
|
import { randomUUID } from "crypto";
|
|
413
|
-
import {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
headers,
|
|
420
|
-
redirect: "manual",
|
|
421
|
-
signal: AbortSignal.timeout(5000)
|
|
422
|
-
});
|
|
423
|
-
} catch {
|
|
424
|
-
throw new Error(`${label} is unreachable`);
|
|
425
|
-
}
|
|
426
|
-
if (!response.ok)
|
|
427
|
-
throw new Error(`${label} returned HTTP ${response.status}`);
|
|
428
|
-
return response.status;
|
|
429
|
-
};
|
|
199
|
+
import { chmod, lstat, mkdir, open, rename, stat, unlink } from "fs/promises";
|
|
200
|
+
import { dirname, join, resolve } from "path";
|
|
201
|
+
var TOKEN = /^[A-Za-z0-9._-]{40,80}$/;
|
|
202
|
+
var CONNECTOR_TOKEN = /^[A-Za-z0-9._-]{40,16384}$/;
|
|
203
|
+
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
|
+
var HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
430
205
|
var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
431
206
|
async function assertOwnerOnlyHandle(path, handle, maximumBytes) {
|
|
432
207
|
const metadata = await handle.stat();
|
|
@@ -435,8 +210,9 @@ async function assertOwnerOnlyHandle(path, handle, maximumBytes) {
|
|
|
435
210
|
if (metadata.nlink !== 1)
|
|
436
211
|
throw new Error(`${path} must not have multiple hard links`);
|
|
437
212
|
const uid = ownerUid();
|
|
438
|
-
if (uid !== undefined && uid !== 0 && metadata.uid !== uid)
|
|
213
|
+
if (uid !== undefined && uid !== 0 && metadata.uid !== uid) {
|
|
439
214
|
throw new Error(`${path} must be owned by the current operator`);
|
|
215
|
+
}
|
|
440
216
|
if ((metadata.mode & 63) !== 0)
|
|
441
217
|
throw new Error(`${path} must not be accessible by group or other users`);
|
|
442
218
|
if ((metadata.mode & 256) === 0)
|
|
@@ -461,25 +237,36 @@ async function readOwnerOnlyFile(path, maximumBytes) {
|
|
|
461
237
|
}
|
|
462
238
|
async function readOwnerApiToken(path) {
|
|
463
239
|
const token = (await readOwnerOnlyFile(path, 4096)).trim();
|
|
464
|
-
if (
|
|
240
|
+
if (!TOKEN.test(token))
|
|
465
241
|
throw new Error(`${resolve(path)} must contain exactly one Cloudflare API token`);
|
|
466
|
-
}
|
|
467
242
|
return token;
|
|
468
243
|
}
|
|
469
244
|
async function readCloudflareBootstrapTokens(files) {
|
|
470
245
|
const entries = await Promise.all([
|
|
471
246
|
["apiToken", files.apiTokenFile],
|
|
247
|
+
["managementApiToken", files.managementApiTokenFile],
|
|
248
|
+
["runtimeApiToken", files.runtimeApiTokenFile],
|
|
472
249
|
["tunnelApiToken", files.tunnelApiTokenFile],
|
|
473
250
|
["dnsApiToken", files.dnsApiTokenFile],
|
|
474
|
-
["kvApiToken", files.kvApiTokenFile]
|
|
475
|
-
["accessApiToken", files.accessApiTokenFile],
|
|
476
|
-
["workerApiToken", files.workerApiTokenFile]
|
|
251
|
+
["kvApiToken", files.kvApiTokenFile]
|
|
477
252
|
].map(async ([key, path]) => [key, path ? await readOwnerApiToken(path) : undefined]));
|
|
478
|
-
const
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
+
}
|
|
483
270
|
}
|
|
484
271
|
return tokens;
|
|
485
272
|
}
|
|
@@ -489,36 +276,30 @@ var validateId = (value, label) => {
|
|
|
489
276
|
throw new Error(`${label} must be a 32-character hexadecimal id`);
|
|
490
277
|
return normalized;
|
|
491
278
|
};
|
|
492
|
-
var validateName = (value, label
|
|
279
|
+
var validateName = (value, label) => {
|
|
493
280
|
const normalized = value.trim();
|
|
494
|
-
|
|
495
|
-
if (!normalized || normalized.length > maximum || !pattern.test(normalized)) {
|
|
281
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(normalized))
|
|
496
282
|
throw new Error(`${label} is invalid`);
|
|
497
|
-
}
|
|
498
283
|
return normalized;
|
|
499
284
|
};
|
|
500
|
-
var
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
return address;
|
|
285
|
+
var normalizeService = (value) => {
|
|
286
|
+
let service;
|
|
287
|
+
try {
|
|
288
|
+
service = new URL(value);
|
|
289
|
+
} catch {
|
|
290
|
+
throw new Error("Cloudflare Tunnel service must be an explicit loopback HTTP port");
|
|
507
291
|
}
|
|
508
|
-
if (
|
|
509
|
-
|
|
510
|
-
if (Number.isFinite(first) && (first & 65024) === 64512)
|
|
511
|
-
return address;
|
|
292
|
+
if (service.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(service.hostname) || !service.port || service.pathname !== "/" || service.username || service.password || service.search || service.hash) {
|
|
293
|
+
throw new Error("Cloudflare Tunnel service must be an explicit loopback HTTP port");
|
|
512
294
|
}
|
|
513
|
-
|
|
295
|
+
return service.toString().replace(/\/$/, "");
|
|
514
296
|
};
|
|
515
297
|
function validateCloudflareBootstrapCoordinates(input) {
|
|
516
298
|
const nodeInputs = input.nodes?.length ? input.nodes : [{
|
|
517
299
|
nodeName: input.tunnelName,
|
|
518
300
|
hostname: input.hostname,
|
|
519
301
|
service: input.service,
|
|
520
|
-
tunnelName: input.tunnelName
|
|
521
|
-
applicationName: input.applicationName
|
|
302
|
+
tunnelName: input.tunnelName
|
|
522
303
|
}];
|
|
523
304
|
if (nodeInputs.length < 1 || nodeInputs.length > 32) {
|
|
524
305
|
throw new Error("Cloudflare bootstrap requires between 1 and 32 explicit nodes");
|
|
@@ -528,88 +309,32 @@ function validateCloudflareBootstrapCoordinates(input) {
|
|
|
528
309
|
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(nodeName))
|
|
529
310
|
throw new Error("Cloudflare node name is invalid");
|
|
530
311
|
const hostname = node.hostname.trim().toLowerCase();
|
|
531
|
-
if (
|
|
312
|
+
if (!HOSTNAME.test(hostname))
|
|
532
313
|
throw new Error("Cloudflare public node hostname is invalid");
|
|
533
|
-
}
|
|
534
|
-
const serviceUrl = new URL(node.service);
|
|
535
|
-
if (serviceUrl.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(serviceUrl.hostname) || !serviceUrl.port || serviceUrl.pathname !== "/" || serviceUrl.search || serviceUrl.hash) {
|
|
536
|
-
throw new Error("Cloudflare Tunnel service must be an explicit loopback HTTP port");
|
|
537
|
-
}
|
|
538
314
|
return {
|
|
539
315
|
nodeName,
|
|
540
316
|
hostname,
|
|
541
|
-
service:
|
|
542
|
-
tunnelName: validateName(node.tunnelName, "Cloudflare Tunnel name"
|
|
543
|
-
applicationName: validateName(node.applicationName, "Cloudflare Access application name", 100),
|
|
544
|
-
...node.privateAddress ? { privateAddress: privateAddress(node.privateAddress) } : {}
|
|
317
|
+
service: normalizeService(node.service),
|
|
318
|
+
tunnelName: validateName(node.tunnelName, "Cloudflare Tunnel name")
|
|
545
319
|
};
|
|
546
320
|
});
|
|
547
321
|
for (const [label, values] of [
|
|
548
322
|
["node name", nodes.map(({ nodeName }) => nodeName)],
|
|
549
323
|
["hostname", nodes.map(({ hostname }) => hostname)],
|
|
550
|
-
["Tunnel name", nodes.map(({ tunnelName }) => tunnelName)]
|
|
551
|
-
["Access application name", nodes.map(({ applicationName }) => applicationName)]
|
|
324
|
+
["Tunnel name", nodes.map(({ tunnelName }) => tunnelName)]
|
|
552
325
|
]) {
|
|
553
326
|
if (new Set(values).size !== values.length)
|
|
554
327
|
throw new Error(`Cloudflare fleet ${label} must be unique`);
|
|
555
328
|
}
|
|
556
329
|
const first = nodes[0];
|
|
557
|
-
const workerScriptName = input.workerScriptName.trim();
|
|
558
|
-
if (!/^[a-z][a-z0-9-]{0,62}$/.test(workerScriptName))
|
|
559
|
-
throw new Error("Cloudflare Worker script name is invalid");
|
|
560
|
-
const workerCompatibilityDate = input.workerCompatibilityDate.trim();
|
|
561
|
-
if (!/^20\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/.test(workerCompatibilityDate)) {
|
|
562
|
-
throw new Error("Cloudflare Worker compatibility date is invalid");
|
|
563
|
-
}
|
|
564
|
-
const publicDomains = [...new Set(input.publicDomains.map((domain) => domain.trim().toLowerCase()))];
|
|
565
|
-
if (publicDomains.length < 1 || publicDomains.length > 10 || publicDomains.some((domain) => !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain) || nodes.some(({ hostname }) => hostname === domain))) {
|
|
566
|
-
throw new Error("Cloudflare Worker public domains are invalid or include the private origin hostname");
|
|
567
|
-
}
|
|
568
|
-
const workerDirectory = resolve(input.workerDirectory);
|
|
569
|
-
const workerMain = input.workerMain.trim();
|
|
570
|
-
if (!workerMain || workerMain.startsWith("/") || workerMain.split(/[\\/]/).includes("..")) {
|
|
571
|
-
throw new Error("Cloudflare Worker main must be a project-relative path");
|
|
572
|
-
}
|
|
573
|
-
const runtimeTokenNamePrefix = input.runtimeTokenNamePrefix.trim();
|
|
574
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(runtimeTokenNamePrefix)) {
|
|
575
|
-
throw new Error("Cloudflare runtime-token name prefix is invalid");
|
|
576
|
-
}
|
|
577
|
-
if (input.createPrivateNetworkRuntimeToken && !input.createRuntimeTokens) {
|
|
578
|
-
throw new Error("private-network runtime token requires runtime-token creation");
|
|
579
|
-
}
|
|
580
|
-
const privateNetwork = input.privateNetwork ? {
|
|
581
|
-
warpOrganization: validateName(input.privateNetwork.warpOrganization, "Cloudflare WARP organization", 63, false).toLowerCase(),
|
|
582
|
-
virtualNetworkName: validateName(input.privateNetwork.virtualNetworkName, "Cloudflare VNET name", 100),
|
|
583
|
-
deviceProfileName: validateName(input.privateNetwork.deviceProfileName, "Cloudflare WARP device profile name", 100),
|
|
584
|
-
enrollmentApplicationName: validateName(input.privateNetwork.enrollmentApplicationName, "Cloudflare WARP enrollment application name", 100),
|
|
585
|
-
...input.privateNetwork.deviceProfilePrecedence !== undefined ? { deviceProfilePrecedence: input.privateNetwork.deviceProfilePrecedence } : {}
|
|
586
|
-
} : undefined;
|
|
587
|
-
if (privateNetwork && (!input.createPrivateNetworkRuntimeToken || nodes.every((node) => !node.privateAddress))) {
|
|
588
|
-
throw new Error("Cloudflare private network requires its runtime token and at least one DB node private address");
|
|
589
|
-
}
|
|
590
|
-
if (!privateNetwork && nodes.some((node) => node.privateAddress)) {
|
|
591
|
-
throw new Error("Cloudflare node private addresses require privateNetwork coordinates");
|
|
592
|
-
}
|
|
593
330
|
return {
|
|
594
331
|
accountId: validateId(input.accountId, "Cloudflare account id"),
|
|
595
332
|
zoneId: validateId(input.zoneId, "Cloudflare zone id"),
|
|
596
333
|
hostname: first.hostname,
|
|
597
334
|
service: first.service,
|
|
598
335
|
tunnelName: first.tunnelName,
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
serviceTokenName: validateName(input.serviceTokenName, "Cloudflare Access service-token name", 100),
|
|
602
|
-
policyName: validateName(input.policyName, "Cloudflare Access policy name", 100),
|
|
603
|
-
applicationName: first.applicationName,
|
|
604
|
-
workerDirectory,
|
|
605
|
-
workerMain,
|
|
606
|
-
workerCompatibilityDate,
|
|
607
|
-
publicDomains,
|
|
608
|
-
createRuntimeTokens: input.createRuntimeTokens,
|
|
609
|
-
createPrivateNetworkRuntimeToken: input.createPrivateNetworkRuntimeToken,
|
|
610
|
-
runtimeTokenNamePrefix,
|
|
611
|
-
nodes,
|
|
612
|
-
...privateNetwork ? { privateNetwork } : {}
|
|
336
|
+
kvNamespaceId: validateId(input.kvNamespaceId, "Cloudflare KV namespace id"),
|
|
337
|
+
nodes
|
|
613
338
|
};
|
|
614
339
|
}
|
|
615
340
|
function planCloudflareBootstrap(input, outputPath) {
|
|
@@ -621,120 +346,18 @@ function planCloudflareBootstrap(input, outputPath) {
|
|
|
621
346
|
outputFile: resolve(outputPath),
|
|
622
347
|
coordinates,
|
|
623
348
|
operations: [
|
|
624
|
-
"create or reuse one Workers KV namespace",
|
|
625
349
|
"create or reuse one remotely-managed Tunnel per node and checkpoint every connector token",
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
"deploy the shared Worker once with the created NODES binding and stable custom domains",
|
|
630
|
-
"create or reuse one shared Access service token/policy and one self-hosted application per node",
|
|
631
|
-
...coordinates.privateNetwork ? [
|
|
632
|
-
"create or reuse the VNET, WARP enrollment application, locked service-token device profile and exact DB host routes"
|
|
633
|
-
] : [],
|
|
634
|
-
"write the Access client id and secret to the existing Worker as encrypted secrets",
|
|
635
|
-
"reconcile each node ingress rule and proxied CNAME only after all Access applications are ready"
|
|
350
|
+
"preflight each exact DNS hostname, refuse ambiguous or incompatible records, and update its existing CNAME or create it only when absent",
|
|
351
|
+
"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-write token"
|
|
636
353
|
],
|
|
637
354
|
secrets: [
|
|
638
|
-
"API tokens are read only from owner-only files and are never
|
|
639
|
-
"the
|
|
640
|
-
"the normal API process
|
|
355
|
+
"API tokens are read only from owner-only files and are never placed in argv or stdout",
|
|
356
|
+
"the management token is never persisted; connector and KV runtime capabilities are atomically checkpointed with mode 0600",
|
|
357
|
+
"the normal API process receives only its KV-write token and never Tunnel or DNS management authority"
|
|
641
358
|
]
|
|
642
359
|
};
|
|
643
360
|
}
|
|
644
|
-
var defaultWorkerCommandRunner = async ({ command, cwd, env }) => {
|
|
645
|
-
const child = Bun.spawn([...command], {
|
|
646
|
-
cwd,
|
|
647
|
-
env: { ...env },
|
|
648
|
-
stdin: "ignore",
|
|
649
|
-
stdout: "pipe",
|
|
650
|
-
stderr: "pipe"
|
|
651
|
-
});
|
|
652
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
653
|
-
child.exited,
|
|
654
|
-
new Response(child.stdout).text(),
|
|
655
|
-
new Response(child.stderr).text()
|
|
656
|
-
]);
|
|
657
|
-
return { exitCode, stdout, stderr };
|
|
658
|
-
};
|
|
659
|
-
var inheritedWorkerEnvironment = () => {
|
|
660
|
-
const allowed = [
|
|
661
|
-
"PATH",
|
|
662
|
-
"HOME",
|
|
663
|
-
"TMPDIR",
|
|
664
|
-
"XDG_CONFIG_HOME",
|
|
665
|
-
"XDG_CACHE_HOME",
|
|
666
|
-
"SSL_CERT_FILE",
|
|
667
|
-
"SSL_CERT_DIR",
|
|
668
|
-
"NODE_EXTRA_CA_CERTS",
|
|
669
|
-
"HTTPS_PROXY",
|
|
670
|
-
"HTTP_PROXY",
|
|
671
|
-
"NO_PROXY"
|
|
672
|
-
];
|
|
673
|
-
return Object.fromEntries(allowed.flatMap((key) => process.env[key] ? [[key, process.env[key]]] : []));
|
|
674
|
-
};
|
|
675
|
-
var redact = (text, secrets) => {
|
|
676
|
-
let safe = text.slice(0, 4096);
|
|
677
|
-
for (const secret of secrets)
|
|
678
|
-
if (secret)
|
|
679
|
-
safe = safe.split(secret).join("[REDACTED]");
|
|
680
|
-
return safe.trim();
|
|
681
|
-
};
|
|
682
|
-
async function deployCloudflareWorker(coordinates, kvNamespaceId, apiToken, runner = defaultWorkerCommandRunner) {
|
|
683
|
-
const validated = validateCloudflareBootstrapCoordinates(coordinates);
|
|
684
|
-
const workerDirectoryMetadata = await stat(validated.workerDirectory);
|
|
685
|
-
if (!workerDirectoryMetadata.isDirectory())
|
|
686
|
-
throw new Error("Cloudflare Worker directory is not a directory");
|
|
687
|
-
const workerMain = resolve(validated.workerDirectory, validated.workerMain);
|
|
688
|
-
const workerMainMetadata = await stat(workerMain);
|
|
689
|
-
if (!workerMainMetadata.isFile())
|
|
690
|
-
throw new Error("Cloudflare Worker main is not a regular file");
|
|
691
|
-
const wrangler = resolve(validated.workerDirectory, "node_modules/.bin/wrangler");
|
|
692
|
-
const wranglerMetadata = await stat(wrangler);
|
|
693
|
-
if (!wranglerMetadata.isFile())
|
|
694
|
-
throw new Error("Cloudflare Wrangler is not installed in the Worker project");
|
|
695
|
-
if (!/^[a-f0-9]{32}$/.test(kvNamespaceId))
|
|
696
|
-
throw new Error("Cloudflare KV namespace id is invalid");
|
|
697
|
-
const temporaryDirectory = await mkdtemp(join(tmpdir(), "fz-wrangler-"));
|
|
698
|
-
await chmod(temporaryDirectory, 448);
|
|
699
|
-
const configurationPath = join(temporaryDirectory, "wrangler.json");
|
|
700
|
-
try {
|
|
701
|
-
const handle = await open(configurationPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 384);
|
|
702
|
-
try {
|
|
703
|
-
await handle.writeFile(`${JSON.stringify({
|
|
704
|
-
name: validated.workerScriptName,
|
|
705
|
-
main: workerMain,
|
|
706
|
-
compatibility_date: validated.workerCompatibilityDate,
|
|
707
|
-
workers_dev: false,
|
|
708
|
-
routes: validated.publicDomains.map((pattern) => ({ pattern, custom_domain: true })),
|
|
709
|
-
observability: { enabled: true },
|
|
710
|
-
kv_namespaces: [{ binding: "NODES", id: kvNamespaceId }]
|
|
711
|
-
}, null, 2)}
|
|
712
|
-
`);
|
|
713
|
-
await handle.sync();
|
|
714
|
-
} finally {
|
|
715
|
-
await handle.close();
|
|
716
|
-
}
|
|
717
|
-
const result = await runner({
|
|
718
|
-
command: [wrangler, "deploy", "--config", configurationPath],
|
|
719
|
-
cwd: validated.workerDirectory,
|
|
720
|
-
env: {
|
|
721
|
-
...inheritedWorkerEnvironment(),
|
|
722
|
-
XDG_CONFIG_HOME: temporaryDirectory,
|
|
723
|
-
XDG_CACHE_HOME: temporaryDirectory,
|
|
724
|
-
WRANGLER_LOG_PATH: join(temporaryDirectory, "wrangler.log"),
|
|
725
|
-
CLOUDFLARE_ACCOUNT_ID: validated.accountId,
|
|
726
|
-
CLOUDFLARE_API_TOKEN: apiToken,
|
|
727
|
-
WRANGLER_SEND_METRICS: "false"
|
|
728
|
-
}
|
|
729
|
-
});
|
|
730
|
-
if (result.exitCode !== 0) {
|
|
731
|
-
const detail = redact(result.stderr || result.stdout || "no Wrangler diagnostic", [apiToken]);
|
|
732
|
-
throw new Error(`Cloudflare Worker deployment failed with exit ${result.exitCode}: ${detail}`);
|
|
733
|
-
}
|
|
734
|
-
} finally {
|
|
735
|
-
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
361
|
async function readExistingOutput(path) {
|
|
739
362
|
try {
|
|
740
363
|
await lstat(path);
|
|
@@ -743,13 +366,15 @@ async function readExistingOutput(path) {
|
|
|
743
366
|
return;
|
|
744
367
|
throw cause;
|
|
745
368
|
}
|
|
746
|
-
|
|
747
|
-
let output;
|
|
369
|
+
let parsed;
|
|
748
370
|
try {
|
|
749
|
-
|
|
750
|
-
} catch {
|
|
751
|
-
|
|
371
|
+
parsed = JSON.parse(await readOwnerOnlyFile(path, 1048576));
|
|
372
|
+
} catch (cause) {
|
|
373
|
+
if (cause instanceof SyntaxError)
|
|
374
|
+
throw new Error(`${resolve(path)} is not valid bootstrap JSON`);
|
|
375
|
+
throw cause;
|
|
752
376
|
}
|
|
377
|
+
const output = parsed;
|
|
753
378
|
if (output.format !== 1 || output.kind !== "forgezero-cloudflare-bootstrap" || !output.resources) {
|
|
754
379
|
throw new Error(`${resolve(path)} is not a ForgeZero Cloudflare bootstrap output`);
|
|
755
380
|
}
|
|
@@ -777,7 +402,7 @@ async function readCloudflareConnectorHandoff(checkpointPath, nodeName) {
|
|
|
777
402
|
throw new Error(`Cloudflare connector handoff has no unique completed node ${normalizedNodeName}`);
|
|
778
403
|
}
|
|
779
404
|
const resource = matches[0];
|
|
780
|
-
if (resource.hostname !== expected.hostname || resource.service !== expected.service || resource.tunnelName !== expected.tunnelName || !
|
|
405
|
+
if (resource.hostname !== expected.hostname || resource.service !== expected.service || resource.tunnelName !== expected.tunnelName || !UUID.test(resource.tunnelId) || !CONNECTOR_TOKEN.test(resource.connectorToken)) {
|
|
781
406
|
throw new Error(`Cloudflare connector handoff for ${normalizedNodeName} is malformed or incomplete`);
|
|
782
407
|
}
|
|
783
408
|
return {
|
|
@@ -817,44 +442,29 @@ async function readCloudflareHostHandoff(handoffPath, nodeName) {
|
|
|
817
442
|
].includes(key));
|
|
818
443
|
if (unknown.length)
|
|
819
444
|
throw new Error(`Cloudflare host handoff contains unsupported field ${unknown[0]}`);
|
|
820
|
-
if (output.warp) {
|
|
821
|
-
const unknownWarp = Object.keys(output.warp).filter((key) => ![
|
|
822
|
-
"organization",
|
|
823
|
-
"clientId",
|
|
824
|
-
"clientSecret",
|
|
825
|
-
"virtualNetworkId",
|
|
826
|
-
"deviceProfileId"
|
|
827
|
-
].includes(key));
|
|
828
|
-
if (unknownWarp.length)
|
|
829
|
-
throw new Error(`Cloudflare host handoff WARP contains unsupported field ${unknownWarp[0]}`);
|
|
830
|
-
}
|
|
831
445
|
const normalizedNodeName = nodeName.trim().toLowerCase();
|
|
832
446
|
let service;
|
|
833
447
|
try {
|
|
834
|
-
service =
|
|
448
|
+
service = normalizeService(output.service ?? "");
|
|
835
449
|
} catch {}
|
|
836
|
-
if (output.format !== 1 || output.kind !== "forgezero-cloudflare-host-handoff" || output.nodeName !== normalizedNodeName ||
|
|
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.kvRuntimeToken ?? "") || !/^[a-f0-9]{32}$/i.test(output.accountId ?? "") || !/^[a-f0-9]{32}$/i.test(output.zoneId ?? "") || !/^[a-f0-9]{32}$/i.test(output.kvNamespaceId ?? "")) {
|
|
837
451
|
throw new Error("Cloudflare host handoff is malformed or belongs to another node");
|
|
838
452
|
}
|
|
839
|
-
|
|
840
|
-
if (network !== undefined && !/^[A-Za-z0-9._-]{40,80}$/.test(network)) {
|
|
453
|
+
if (output.privateNetworkRuntimeToken !== undefined && !TOKEN.test(output.privateNetworkRuntimeToken)) {
|
|
841
454
|
throw new Error("Cloudflare host handoff private-network capability is malformed");
|
|
842
455
|
}
|
|
843
|
-
if (Boolean(output.warp) !== Boolean(
|
|
456
|
+
if (Boolean(output.warp) !== Boolean(output.privateNetworkRuntimeToken)) {
|
|
844
457
|
throw new Error("Cloudflare host handoff private-network resources and capability disagree");
|
|
845
458
|
}
|
|
846
|
-
if (output.warp && (!output.warp.clientId || !output.warp.clientSecret || !/^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/.test(output.warp.organization) || !/^[0-9a-f-]{36}$/i.test(output.warp.virtualNetworkId) || !output.warp.deviceProfileId)) {
|
|
847
|
-
throw new Error("Cloudflare host handoff WARP enrollment is malformed");
|
|
848
|
-
}
|
|
849
459
|
const { format: _format, kind: _kind, ...handoff } = output;
|
|
850
460
|
return handoff;
|
|
851
461
|
}
|
|
852
462
|
async function prepareOwnerOutputDirectory(absolutePath) {
|
|
853
463
|
const directory = dirname(absolutePath);
|
|
854
464
|
await mkdir(directory, { recursive: true, mode: 448 });
|
|
855
|
-
const
|
|
465
|
+
const metadata = await stat(directory);
|
|
856
466
|
const uid = ownerUid();
|
|
857
|
-
if (!
|
|
467
|
+
if (!metadata.isDirectory() || uid !== undefined && metadata.uid !== uid || (metadata.mode & 18) !== 0) {
|
|
858
468
|
throw new Error(`bootstrap output directory ${directory} must be operator-owned and not group/other writable`);
|
|
859
469
|
}
|
|
860
470
|
return directory;
|
|
@@ -891,13 +501,6 @@ async function writeOwnerBootstrapOutput(path, output) {
|
|
|
891
501
|
await writeOwnerJson(path, output);
|
|
892
502
|
}
|
|
893
503
|
async function writeCloudflareHostHandoffs(checkpointPath, output) {
|
|
894
|
-
const kv = output.resources.runtimeTokens?.kv?.value;
|
|
895
|
-
if (!kv || !/^[A-Za-z0-9._-]{40,80}$/.test(kv)) {
|
|
896
|
-
return;
|
|
897
|
-
}
|
|
898
|
-
const network = output.resources.runtimeTokens?.privateNetwork?.value;
|
|
899
|
-
const privateNetwork = output.resources.privateNetwork;
|
|
900
|
-
const access = output.resources.access;
|
|
901
504
|
for (const node of output.resources.nodes) {
|
|
902
505
|
const handoff = {
|
|
903
506
|
format: 1,
|
|
@@ -910,34 +513,19 @@ async function writeCloudflareHostHandoffs(checkpointPath, output) {
|
|
|
910
513
|
accountId: output.coordinates.accountId,
|
|
911
514
|
zoneId: output.coordinates.zoneId,
|
|
912
515
|
kvNamespaceId: output.resources.kvNamespaceId,
|
|
913
|
-
kvRuntimeToken:
|
|
914
|
-
...network ? { privateNetworkRuntimeToken: network } : {},
|
|
915
|
-
...privateNetwork && access ? { warp: {
|
|
916
|
-
organization: privateNetwork.warpOrganization,
|
|
917
|
-
clientId: access.clientId,
|
|
918
|
-
clientSecret: access.clientSecret,
|
|
919
|
-
virtualNetworkId: privateNetwork.virtualNetworkId,
|
|
920
|
-
deviceProfileId: privateNetwork.deviceProfileId
|
|
921
|
-
} } : {}
|
|
516
|
+
kvRuntimeToken: output.resources.kvRuntimeToken
|
|
922
517
|
};
|
|
923
518
|
await writeOwnerJson(cloudflareHostHandoffPath(checkpointPath, node.nodeName), handoff);
|
|
924
519
|
}
|
|
925
520
|
}
|
|
926
521
|
var tokenFor = (tokens, key) => {
|
|
927
522
|
const token = tokens[key]?.trim() || tokens.apiToken?.trim();
|
|
928
|
-
if (!token)
|
|
523
|
+
if (!token || !TOKEN.test(token))
|
|
929
524
|
throw new Error(`Cloudflare ${key} is not configured`);
|
|
930
525
|
return token;
|
|
931
526
|
};
|
|
932
|
-
var initialManagementToken = (tokens) => {
|
|
933
|
-
const token = tokens.apiToken?.trim();
|
|
934
|
-
if (!token) {
|
|
935
|
-
throw new Error("initial Cloudflare --token-file is required to create account-owned runtime tokens");
|
|
936
|
-
}
|
|
937
|
-
return token;
|
|
938
|
-
};
|
|
939
527
|
var sameCoordinates = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
940
|
-
async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fetch
|
|
528
|
+
async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fetch) {
|
|
941
529
|
const coordinates = validateCloudflareBootstrapCoordinates(input);
|
|
942
530
|
const absoluteOutput = resolve(outputPath);
|
|
943
531
|
const existing = await readExistingOutput(absoluteOutput);
|
|
@@ -945,19 +533,18 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
945
533
|
throw new Error("bootstrap output belongs to different Cloudflare coordinates; choose a different output file");
|
|
946
534
|
}
|
|
947
535
|
await prepareOwnerOutputDirectory(absoluteOutput);
|
|
948
|
-
const
|
|
949
|
-
accountId: coordinates.accountId,
|
|
950
|
-
title: coordinates.kvNamespaceTitle,
|
|
951
|
-
apiToken: tokenFor(tokens, "kvApiToken")
|
|
952
|
-
}, fetcher);
|
|
536
|
+
const kvRuntimeToken = tokenFor(tokens, "kvApiToken");
|
|
953
537
|
const nodeResources = [];
|
|
954
538
|
const createdNodes = [];
|
|
955
|
-
let resources;
|
|
956
539
|
for (const node of coordinates.nodes) {
|
|
957
540
|
const checkpointed = existing?.resources.nodes?.find(({ nodeName }) => nodeName === node.nodeName);
|
|
541
|
+
let resource;
|
|
958
542
|
let created = false;
|
|
959
543
|
if (checkpointed) {
|
|
960
|
-
|
|
544
|
+
if (checkpointed.hostname !== node.hostname || checkpointed.service !== node.service || checkpointed.tunnelName !== node.tunnelName || !UUID.test(checkpointed.tunnelId) || !CONNECTOR_TOKEN.test(checkpointed.connectorToken)) {
|
|
545
|
+
throw new Error(`checkpointed Cloudflare node ${node.nodeName} is malformed`);
|
|
546
|
+
}
|
|
547
|
+
resource = checkpointed;
|
|
961
548
|
} else {
|
|
962
549
|
const tunnel = await ensureCloudflareTunnel({
|
|
963
550
|
accountId: coordinates.accountId,
|
|
@@ -965,234 +552,29 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
965
552
|
apiToken: tokenFor(tokens, "tunnelApiToken")
|
|
966
553
|
}, fetcher);
|
|
967
554
|
created = tunnel.created;
|
|
968
|
-
|
|
969
|
-
nodeName: node.nodeName,
|
|
970
|
-
hostname: node.hostname,
|
|
971
|
-
service: node.service,
|
|
972
|
-
tunnelName: node.tunnelName,
|
|
973
|
-
tunnelId: tunnel.tunnel.id,
|
|
974
|
-
connectorToken: tunnel.connectorToken
|
|
975
|
-
});
|
|
976
|
-
}
|
|
977
|
-
createdNodes.push({ nodeName: node.nodeName, tunnel: created, application: false });
|
|
978
|
-
const firstNode = nodeResources[0];
|
|
979
|
-
resources = {
|
|
980
|
-
tunnelId: firstNode.tunnelId,
|
|
981
|
-
kvNamespaceId: namespace.namespace.id,
|
|
982
|
-
hostname: firstNode.hostname,
|
|
983
|
-
service: firstNode.service,
|
|
984
|
-
connectorToken: firstNode.connectorToken,
|
|
985
|
-
nodes: [...nodeResources],
|
|
986
|
-
...existing?.resources.access ? { access: existing.resources.access } : {},
|
|
987
|
-
...existing?.resources.runtimeTokens ? { runtimeTokens: existing.resources.runtimeTokens } : {},
|
|
988
|
-
...existing?.resources.worker ? { worker: existing.resources.worker } : {},
|
|
989
|
-
...existing?.resources.privateNetwork ? { privateNetwork: existing.resources.privateNetwork } : {}
|
|
990
|
-
};
|
|
991
|
-
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
992
|
-
format: 1,
|
|
993
|
-
kind: "forgezero-cloudflare-bootstrap",
|
|
994
|
-
phase: resources.access ? "access-token-provisioned" : "edge-resources-provisioned",
|
|
995
|
-
updatedAt: new Date().toISOString(),
|
|
996
|
-
coordinates,
|
|
997
|
-
resources
|
|
998
|
-
});
|
|
999
|
-
}
|
|
1000
|
-
if (!resources)
|
|
1001
|
-
throw new Error("Cloudflare fleet has no nodes");
|
|
1002
|
-
if (coordinates.createRuntimeTokens && !resources.runtimeTokens) {
|
|
1003
|
-
resources = {
|
|
1004
|
-
...resources,
|
|
1005
|
-
runtimeTokens: { kv: await createCloudflareAccountRuntimeToken({
|
|
1006
|
-
accountId: coordinates.accountId,
|
|
1007
|
-
name: `${coordinates.runtimeTokenNamePrefix}-kv-runtime`,
|
|
1008
|
-
permissionNames: ["Workers KV Storage Write"],
|
|
1009
|
-
apiToken: initialManagementToken(tokens)
|
|
1010
|
-
}, fetcher) }
|
|
1011
|
-
};
|
|
1012
|
-
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
1013
|
-
format: 1,
|
|
1014
|
-
kind: "forgezero-cloudflare-bootstrap",
|
|
1015
|
-
phase: "runtime-tokens-created",
|
|
1016
|
-
updatedAt: new Date().toISOString(),
|
|
1017
|
-
coordinates,
|
|
1018
|
-
resources
|
|
1019
|
-
});
|
|
1020
|
-
}
|
|
1021
|
-
if (coordinates.createPrivateNetworkRuntimeToken && resources.runtimeTokens && !resources.runtimeTokens.privateNetwork) {
|
|
1022
|
-
resources = {
|
|
1023
|
-
...resources,
|
|
1024
|
-
runtimeTokens: {
|
|
1025
|
-
...resources.runtimeTokens,
|
|
1026
|
-
privateNetwork: await createCloudflareAccountRuntimeToken({
|
|
1027
|
-
accountId: coordinates.accountId,
|
|
1028
|
-
name: `${coordinates.runtimeTokenNamePrefix}-private-network-runtime`,
|
|
1029
|
-
permissionNames: ["Cloudflare One Networks Write", "Zero Trust Write"],
|
|
1030
|
-
apiToken: initialManagementToken(tokens)
|
|
1031
|
-
}, fetcher)
|
|
1032
|
-
}
|
|
1033
|
-
};
|
|
1034
|
-
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
1035
|
-
format: 1,
|
|
1036
|
-
kind: "forgezero-cloudflare-bootstrap",
|
|
1037
|
-
phase: "runtime-tokens-created",
|
|
1038
|
-
updatedAt: new Date().toISOString(),
|
|
1039
|
-
coordinates,
|
|
1040
|
-
resources
|
|
1041
|
-
});
|
|
1042
|
-
}
|
|
1043
|
-
if (!resources.worker) {
|
|
1044
|
-
await deployCloudflareWorker(coordinates, namespace.namespace.id, tokenFor(tokens, "workerApiToken"), workerRunner);
|
|
1045
|
-
resources = {
|
|
1046
|
-
...resources,
|
|
1047
|
-
worker: {
|
|
1048
|
-
scriptName: coordinates.workerScriptName,
|
|
1049
|
-
publicDomains: coordinates.publicDomains,
|
|
1050
|
-
deployed: true
|
|
1051
|
-
}
|
|
1052
|
-
};
|
|
1053
|
-
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
1054
|
-
format: 1,
|
|
1055
|
-
kind: "forgezero-cloudflare-bootstrap",
|
|
1056
|
-
phase: "worker-deployed",
|
|
1057
|
-
updatedAt: new Date().toISOString(),
|
|
1058
|
-
coordinates,
|
|
1059
|
-
resources
|
|
1060
|
-
});
|
|
1061
|
-
}
|
|
1062
|
-
const serviceToken = await ensureCloudflareAccessServiceToken({
|
|
1063
|
-
accountId: coordinates.accountId,
|
|
1064
|
-
name: coordinates.serviceTokenName,
|
|
1065
|
-
apiToken: tokenFor(tokens, "accessApiToken"),
|
|
1066
|
-
existing: resources.access
|
|
1067
|
-
}, fetcher);
|
|
1068
|
-
resources = { ...resources, access: serviceToken.credentials };
|
|
1069
|
-
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
1070
|
-
format: 1,
|
|
1071
|
-
kind: "forgezero-cloudflare-bootstrap",
|
|
1072
|
-
phase: "access-token-provisioned",
|
|
1073
|
-
updatedAt: new Date().toISOString(),
|
|
1074
|
-
coordinates,
|
|
1075
|
-
resources
|
|
1076
|
-
});
|
|
1077
|
-
const policy = await ensureCloudflareAccessPolicy({
|
|
1078
|
-
accountId: coordinates.accountId,
|
|
1079
|
-
name: coordinates.policyName,
|
|
1080
|
-
serviceTokenId: serviceToken.credentials.tokenId,
|
|
1081
|
-
apiToken: tokenFor(tokens, "accessApiToken")
|
|
1082
|
-
}, fetcher);
|
|
1083
|
-
if (!policy.policy.id)
|
|
1084
|
-
throw new Error("Cloudflare did not return the Access policy id");
|
|
1085
|
-
resources = {
|
|
1086
|
-
...resources,
|
|
1087
|
-
access: { ...serviceToken.credentials, policyId: policy.policy.id }
|
|
1088
|
-
};
|
|
1089
|
-
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
1090
|
-
format: 1,
|
|
1091
|
-
kind: "forgezero-cloudflare-bootstrap",
|
|
1092
|
-
phase: "access-token-provisioned",
|
|
1093
|
-
updatedAt: new Date().toISOString(),
|
|
1094
|
-
coordinates,
|
|
1095
|
-
resources
|
|
1096
|
-
});
|
|
1097
|
-
let privateNetworkCreated = false;
|
|
1098
|
-
if (coordinates.privateNetwork && !resources.privateNetwork) {
|
|
1099
|
-
const managementToken = initialManagementToken(tokens);
|
|
1100
|
-
const virtualNetwork = await ensureCloudflareVirtualNetwork({
|
|
1101
|
-
accountId: coordinates.accountId,
|
|
1102
|
-
name: coordinates.privateNetwork.virtualNetworkName,
|
|
1103
|
-
comment: "ForgeZero private database network",
|
|
1104
|
-
apiToken: managementToken
|
|
1105
|
-
}, fetcher);
|
|
1106
|
-
const enrollment = await ensureCloudflareWarpEnrollmentApplication({
|
|
1107
|
-
accountId: coordinates.accountId,
|
|
1108
|
-
name: coordinates.privateNetwork.enrollmentApplicationName,
|
|
1109
|
-
policyId: policy.policy.id,
|
|
1110
|
-
apiToken: tokenFor(tokens, "accessApiToken")
|
|
1111
|
-
}, fetcher);
|
|
1112
|
-
const deviceProfile = await ensureCloudflareWarpDevicePolicy({
|
|
1113
|
-
accountId: coordinates.accountId,
|
|
1114
|
-
name: coordinates.privateNetwork.deviceProfileName,
|
|
1115
|
-
serviceTokenId: serviceToken.credentials.tokenId,
|
|
1116
|
-
virtualNetworkId: virtualNetwork.virtualNetwork.id,
|
|
1117
|
-
precedence: coordinates.privateNetwork.deviceProfilePrecedence,
|
|
1118
|
-
apiToken: managementToken
|
|
1119
|
-
}, fetcher);
|
|
1120
|
-
const routes = [];
|
|
1121
|
-
for (const node of coordinates.nodes.filter((item) => item.privateAddress)) {
|
|
1122
|
-
const resource = resources.nodes.find((item) => item.nodeName === node.nodeName);
|
|
1123
|
-
const route = await ensureCloudflarePrivateDatabaseRoute({
|
|
1124
|
-
accountId: coordinates.accountId,
|
|
1125
|
-
tunnelId: resource.tunnelId,
|
|
1126
|
-
privateAddress: node.privateAddress,
|
|
1127
|
-
virtualNetworkId: virtualNetwork.virtualNetwork.id,
|
|
1128
|
-
comment: `ForgeZero ${node.nodeName} database`,
|
|
1129
|
-
apiToken: managementToken
|
|
1130
|
-
}, fetcher);
|
|
1131
|
-
await ensureCloudflareWarpDatabaseInclude({
|
|
1132
|
-
accountId: coordinates.accountId,
|
|
1133
|
-
policyId: deviceProfile.policy.id,
|
|
1134
|
-
privateAddress: node.privateAddress,
|
|
1135
|
-
description: `ForgeZero ${node.nodeName} database`,
|
|
1136
|
-
apiToken: managementToken
|
|
1137
|
-
}, fetcher);
|
|
1138
|
-
routes.push({ nodeName: node.nodeName, routeId: route.route.id, privateAddress: node.privateAddress });
|
|
555
|
+
resource = { ...node, tunnelId: tunnel.tunnel.id, connectorToken: tunnel.connectorToken };
|
|
1139
556
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
warpOrganization: coordinates.privateNetwork.warpOrganization,
|
|
1144
|
-
virtualNetworkId: virtualNetwork.virtualNetwork.id,
|
|
1145
|
-
deviceProfileId: deviceProfile.policy.id,
|
|
1146
|
-
enrollmentApplicationId: enrollment.application.id,
|
|
1147
|
-
routes
|
|
1148
|
-
}
|
|
1149
|
-
};
|
|
1150
|
-
privateNetworkCreated = virtualNetwork.created || enrollment.created || deviceProfile.created || routes.length > 0;
|
|
557
|
+
nodeResources.push(resource);
|
|
558
|
+
createdNodes.push({ nodeName: node.nodeName, tunnel: created });
|
|
559
|
+
const first2 = nodeResources[0];
|
|
1151
560
|
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
1152
561
|
format: 1,
|
|
1153
562
|
kind: "forgezero-cloudflare-bootstrap",
|
|
1154
|
-
phase: "
|
|
563
|
+
phase: "edge-resources-provisioned",
|
|
1155
564
|
updatedAt: new Date().toISOString(),
|
|
1156
565
|
coordinates,
|
|
1157
|
-
resources
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
policyId: policy.policy.id,
|
|
1166
|
-
apiToken: tokenFor(tokens, "accessApiToken")
|
|
1167
|
-
}, fetcher);
|
|
1168
|
-
if (!application.application.id)
|
|
1169
|
-
throw new Error(`Cloudflare did not return the Access application id for ${node.nodeName}`);
|
|
1170
|
-
resources = {
|
|
1171
|
-
...resources,
|
|
1172
|
-
nodes: resources.nodes.map((resource) => resource.nodeName === node.nodeName ? { ...resource, applicationId: application.application.id } : resource),
|
|
1173
|
-
access: {
|
|
1174
|
-
...resources.access,
|
|
1175
|
-
...node.nodeName === coordinates.nodes[0].nodeName ? { applicationId: application.application.id } : {}
|
|
566
|
+
resources: {
|
|
567
|
+
tunnelId: first2.tunnelId,
|
|
568
|
+
kvNamespaceId: coordinates.kvNamespaceId,
|
|
569
|
+
hostname: first2.hostname,
|
|
570
|
+
service: first2.service,
|
|
571
|
+
connectorToken: first2.connectorToken,
|
|
572
|
+
kvRuntimeToken,
|
|
573
|
+
nodes: [...nodeResources]
|
|
1176
574
|
}
|
|
1177
|
-
};
|
|
1178
|
-
const createdNode = createdNodes.find(({ nodeName }) => nodeName === node.nodeName);
|
|
1179
|
-
createdNode.application = application.created;
|
|
1180
|
-
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
1181
|
-
format: 1,
|
|
1182
|
-
kind: "forgezero-cloudflare-bootstrap",
|
|
1183
|
-
phase: "access-token-provisioned",
|
|
1184
|
-
updatedAt: new Date().toISOString(),
|
|
1185
|
-
coordinates,
|
|
1186
|
-
resources
|
|
1187
575
|
});
|
|
1188
576
|
}
|
|
1189
|
-
|
|
1190
|
-
accountId: coordinates.accountId,
|
|
1191
|
-
scriptName: coordinates.workerScriptName,
|
|
1192
|
-
credentials: serviceToken.credentials,
|
|
1193
|
-
apiToken: tokenFor(tokens, "workerApiToken")
|
|
1194
|
-
}, fetcher);
|
|
1195
|
-
for (const node of resources.nodes) {
|
|
577
|
+
for (const node of nodeResources) {
|
|
1196
578
|
await configureCloudflareEdge({
|
|
1197
579
|
accountId: coordinates.accountId,
|
|
1198
580
|
zoneId: coordinates.zoneId,
|
|
@@ -1204,21 +586,24 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
1204
586
|
dnsApiToken: tokenFor(tokens, "dnsApiToken")
|
|
1205
587
|
}, fetcher);
|
|
1206
588
|
}
|
|
589
|
+
const first = nodeResources[0];
|
|
1207
590
|
const output = {
|
|
1208
591
|
format: 1,
|
|
1209
592
|
kind: "forgezero-cloudflare-bootstrap",
|
|
1210
593
|
phase: "complete",
|
|
1211
594
|
updatedAt: new Date().toISOString(),
|
|
1212
595
|
coordinates,
|
|
1213
|
-
resources
|
|
596
|
+
resources: {
|
|
597
|
+
tunnelId: first.tunnelId,
|
|
598
|
+
kvNamespaceId: coordinates.kvNamespaceId,
|
|
599
|
+
hostname: first.hostname,
|
|
600
|
+
service: first.service,
|
|
601
|
+
connectorToken: first.connectorToken,
|
|
602
|
+
kvRuntimeToken,
|
|
603
|
+
nodes: nodeResources
|
|
604
|
+
},
|
|
1214
605
|
created: {
|
|
1215
606
|
tunnel: createdNodes.some(({ tunnel }) => tunnel),
|
|
1216
|
-
kvNamespace: namespace.created,
|
|
1217
|
-
serviceToken: serviceToken.created,
|
|
1218
|
-
policy: policy.created,
|
|
1219
|
-
application: createdNodes.some(({ application }) => application),
|
|
1220
|
-
privateNetwork: privateNetworkCreated,
|
|
1221
|
-
workerDeployed: true,
|
|
1222
607
|
nodes: createdNodes
|
|
1223
608
|
}
|
|
1224
609
|
};
|
|
@@ -1234,91 +619,65 @@ async function runAttendedCloudflareBootstrap(request, dependencies = {}) {
|
|
|
1234
619
|
kind: "forgezero-cloudflare-bootstrap-evidence",
|
|
1235
620
|
phase: "planned",
|
|
1236
621
|
checkpointFile: plan.outputFile,
|
|
1237
|
-
workerScriptName: plan.coordinates.workerScriptName,
|
|
1238
|
-
publicDomains: plan.coordinates.publicDomains,
|
|
1239
622
|
nodes: plan.coordinates.nodes.map(({ nodeName, hostname }) => ({ nodeName, hostname }))
|
|
1240
623
|
};
|
|
1241
624
|
}
|
|
1242
625
|
if (!request.tokenFiles || !Object.values(request.tokenFiles).some(Boolean)) {
|
|
1243
626
|
throw new Error("Cloudflare apply requires owner-only management token file paths");
|
|
1244
627
|
}
|
|
1245
|
-
if (plan.coordinates.createRuntimeTokens && !request.tokenFiles.apiTokenFile) {
|
|
1246
|
-
throw new Error("Cloudflare runtime-token creation requires the initial management token file");
|
|
1247
|
-
}
|
|
1248
628
|
const tokens = await readCloudflareBootstrapTokens(request.tokenFiles);
|
|
1249
|
-
const output = await applyCloudflareBootstrap(plan.coordinates, tokens, plan.outputFile, dependencies.fetcher ?? fetch
|
|
629
|
+
const output = await applyCloudflareBootstrap(plan.coordinates, tokens, plan.outputFile, dependencies.fetcher ?? fetch);
|
|
1250
630
|
return {
|
|
1251
631
|
format: 1,
|
|
1252
632
|
kind: "forgezero-cloudflare-bootstrap-evidence",
|
|
1253
633
|
phase: "complete",
|
|
1254
634
|
checkpointFile: plan.outputFile,
|
|
1255
635
|
kvNamespaceId: output.resources.kvNamespaceId,
|
|
1256
|
-
|
|
1257
|
-
publicDomains: output.resources.worker?.publicDomains ?? output.coordinates.publicDomains,
|
|
1258
|
-
runtimeTokenIds: {
|
|
1259
|
-
kv: output.resources.runtimeTokens?.kv.id,
|
|
1260
|
-
privateNetwork: output.resources.runtimeTokens?.privateNetwork?.id
|
|
1261
|
-
},
|
|
1262
|
-
nodes: output.resources.nodes.map(({ nodeName, hostname, tunnelId, applicationId }) => ({
|
|
636
|
+
nodes: output.resources.nodes.map(({ nodeName, hostname, tunnelId }) => ({
|
|
1263
637
|
nodeName,
|
|
1264
638
|
hostname,
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
} : {},
|
|
1268
|
-
tunnelId,
|
|
1269
|
-
applicationId
|
|
639
|
+
handoffFile: cloudflareHostHandoffPath(plan.outputFile, nodeName),
|
|
640
|
+
tunnelId
|
|
1270
641
|
}))
|
|
1271
642
|
};
|
|
1272
643
|
}
|
|
644
|
+
var acceptanceFetch = async (url, label, fetcher) => {
|
|
645
|
+
let response;
|
|
646
|
+
try {
|
|
647
|
+
response = await fetcher(url, { method: "GET", redirect: "manual", signal: AbortSignal.timeout(5000) });
|
|
648
|
+
} catch {
|
|
649
|
+
throw new Error(`${label} is unreachable`);
|
|
650
|
+
}
|
|
651
|
+
if (!response.ok)
|
|
652
|
+
throw new Error(`${label} returned HTTP ${response.status}`);
|
|
653
|
+
return response.status;
|
|
654
|
+
};
|
|
1273
655
|
async function verifyCloudflareBootstrapAcceptance(checkpointPath, fetcher = fetch) {
|
|
1274
656
|
const absolute = resolve(checkpointPath);
|
|
1275
657
|
const output = await readExistingOutput(absolute);
|
|
1276
658
|
if (!output || output.phase !== "complete")
|
|
1277
659
|
throw new Error("Cloudflare acceptance requires a completed owner checkpoint");
|
|
1278
660
|
const coordinates = validateCloudflareBootstrapCoordinates(output.coordinates);
|
|
1279
|
-
const access = output.resources.access;
|
|
1280
|
-
if (!access?.clientId?.trim() || !access.clientSecret?.trim()) {
|
|
1281
|
-
throw new Error("Cloudflare acceptance checkpoint is missing the Access service credential");
|
|
1282
|
-
}
|
|
1283
|
-
if (!output.resources.worker?.deployed || output.resources.worker.scriptName !== coordinates.workerScriptName || JSON.stringify(output.resources.worker.publicDomains) !== JSON.stringify(coordinates.publicDomains)) {
|
|
1284
|
-
throw new Error("Cloudflare acceptance checkpoint does not prove the expected Worker deployment");
|
|
1285
|
-
}
|
|
1286
661
|
if (output.resources.nodes.length !== coordinates.nodes.length) {
|
|
1287
662
|
throw new Error("Cloudflare acceptance checkpoint does not cover the declared node fleet");
|
|
1288
663
|
}
|
|
1289
|
-
const nodeNames = new Set;
|
|
1290
|
-
const hostnames = new Set;
|
|
1291
664
|
for (const node of output.resources.nodes) {
|
|
1292
665
|
const expected = coordinates.nodes.find((candidate) => candidate.nodeName === node.nodeName);
|
|
1293
|
-
if (!expected || node.hostname !== expected.hostname || node.service !== expected.service || node.tunnelName !== expected.tunnelName ||
|
|
666
|
+
if (!expected || node.hostname !== expected.hostname || node.service !== expected.service || node.tunnelName !== expected.tunnelName || !UUID.test(node.tunnelId)) {
|
|
1294
667
|
throw new Error("Cloudflare acceptance checkpoint has an unbound node resource");
|
|
1295
668
|
}
|
|
1296
|
-
if (nodeNames.has(node.nodeName) || hostnames.has(node.hostname)) {
|
|
1297
|
-
throw new Error("Cloudflare acceptance checkpoint has duplicate node coordinates");
|
|
1298
|
-
}
|
|
1299
|
-
nodeNames.add(node.nodeName);
|
|
1300
|
-
hostnames.add(node.hostname);
|
|
1301
669
|
}
|
|
1302
|
-
const accessHeaders = {
|
|
1303
|
-
"CF-Access-Client-Id": access.clientId,
|
|
1304
|
-
"CF-Access-Client-Secret": access.clientSecret
|
|
1305
|
-
};
|
|
1306
670
|
const nodes = await Promise.all(output.resources.nodes.map(async ({ nodeName, hostname }) => ({
|
|
1307
671
|
nodeName,
|
|
1308
672
|
hostname,
|
|
1309
|
-
status: await acceptanceFetch(`https://${hostname}/api/health`, `Cloudflare origin ${nodeName}`, fetcher
|
|
1310
|
-
})));
|
|
1311
|
-
const publicDomains = await Promise.all(output.resources.worker.publicDomains.map(async (hostname) => ({
|
|
1312
|
-
hostname,
|
|
1313
|
-
status: await acceptanceFetch(`https://${hostname}/api/health`, `Cloudflare public domain ${hostname}`, fetcher)
|
|
673
|
+
status: await acceptanceFetch(`https://${hostname}/api/health`, `Cloudflare origin ${nodeName}`, fetcher)
|
|
1314
674
|
})));
|
|
1315
675
|
return {
|
|
1316
676
|
format: 1,
|
|
1317
677
|
kind: "forgezero-cloudflare-bootstrap-acceptance",
|
|
1318
678
|
checkpointFile: absolute,
|
|
1319
679
|
verifiedAt: new Date().toISOString(),
|
|
1320
|
-
nodes
|
|
1321
|
-
publicDomains
|
|
680
|
+
nodes
|
|
1322
681
|
};
|
|
1323
682
|
}
|
|
1324
683
|
|
|
@@ -1353,7 +712,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
|
|
|
1353
712
|
var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
|
|
1354
713
|
|
|
1355
714
|
// src/version.ts
|
|
1356
|
-
var VERSION = "0.1.
|
|
715
|
+
var VERSION = "0.1.41";
|
|
1357
716
|
|
|
1358
717
|
// src/software.ts
|
|
1359
718
|
var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
|
|
@@ -1372,8 +731,8 @@ var UBUNTU_2604_X64 = [
|
|
|
1372
731
|
},
|
|
1373
732
|
{
|
|
1374
733
|
requirement: { id: "arangodb", version: "3.11.14" },
|
|
1375
|
-
check: `arangod --version 2>/dev/null | head -1 | grep -q '3.11.14'`,
|
|
1376
|
-
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb' -o "$tmp/arangodb.deb"; ` + `echo "${ARANGO_SHA256} $tmp/arangodb.deb" | sha256sum -c -; ` + `DEBIAN_FRONTEND=noninteractive dpkg -i "$tmp/arangodb.deb" >/dev/null 2>&1 || ` + `DEBIAN_FRONTEND=noninteractive apt-get -y -f install`
|
|
734
|
+
check: `arangod --version 2>/dev/null | head -1 | grep -q '3.11.14' && ` + `! systemctl is-active --quiet arangodb3.service && ` + `! systemctl is-enabled --quiet arangodb3.service`,
|
|
735
|
+
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb' -o "$tmp/arangodb.deb"; ` + `echo "${ARANGO_SHA256} $tmp/arangodb.deb" | sha256sum -c -; ` + `DEBIAN_FRONTEND=noninteractive dpkg -i "$tmp/arangodb.deb" >/dev/null 2>&1 || ` + `DEBIAN_FRONTEND=noninteractive apt-get -y -f install; ` + `systemctl disable --now arangodb3.service`
|
|
1377
736
|
},
|
|
1378
737
|
{
|
|
1379
738
|
requirement: { id: "cloudflared", version: "2026.7.3" },
|
|
@@ -1459,7 +818,7 @@ function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
|
|
|
1459
818
|
}
|
|
1460
819
|
|
|
1461
820
|
// src/provision.ts
|
|
1462
|
-
import { isIP as
|
|
821
|
+
import { isIP as isIP2 } from "net";
|
|
1463
822
|
function atLeast(version, floor) {
|
|
1464
823
|
const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
|
|
1465
824
|
const got = parse(version);
|
|
@@ -1916,7 +1275,7 @@ function agentUnit(options) {
|
|
|
1916
1275
|
} catch {
|
|
1917
1276
|
throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL");
|
|
1918
1277
|
}
|
|
1919
|
-
if (endpoint2.protocol !== "https:" || endpoint2.username || endpoint2.password || endpoint2.search || endpoint2.hash ||
|
|
1278
|
+
if (endpoint2.protocol !== "https:" || endpoint2.username || endpoint2.password || endpoint2.search || endpoint2.hash || isIP2(endpoint2.hostname) !== 0 || !endpoint2.hostname.includes(".") || endpoint2.hostname === "localhost" || endpoint2.hostname.endsWith(".local"))
|
|
1920
1279
|
throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL without credentials, query or fragment");
|
|
1921
1280
|
telemetryEndpoint = endpoint2.toString().replace(/\/$/, "");
|
|
1922
1281
|
}
|
|
@@ -2597,6 +1956,8 @@ function renderPlatformApiUnits(input) {
|
|
|
2597
1956
|
throw new Error("Blue and green ports must differ.");
|
|
2598
1957
|
const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
|
|
2599
1958
|
`);
|
|
1959
|
+
const capacityEnvironment = input.capacityEnvironmentFile ? `EnvironmentFile=-${input.capacityEnvironmentFile}
|
|
1960
|
+
` : "";
|
|
2600
1961
|
const template = `[Unit]
|
|
2601
1962
|
Description=ForgeZero (%i slot)
|
|
2602
1963
|
After=network-online.target ${input.collectorUnit}
|
|
@@ -2609,7 +1970,7 @@ WorkingDirectory=${input.slotsDirectory}/%i
|
|
|
2609
1970
|
Environment=NODE_ENV=production
|
|
2610
1971
|
Environment=FZ_SLOT=%i
|
|
2611
1972
|
EnvironmentFile=${input.sharedEnvironmentFile}
|
|
2612
|
-
${credentials}
|
|
1973
|
+
${capacityEnvironment}${credentials}
|
|
2613
1974
|
ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
|
|
2614
1975
|
Restart=always
|
|
2615
1976
|
RestartSec=2
|
|
@@ -2645,16 +2006,24 @@ Environment=PORT=${input.greenPort}
|
|
|
2645
2006
|
function renderPlatformNginx(input) {
|
|
2646
2007
|
boundedInteger("publicPort", input.publicPort, 1024, 65535);
|
|
2647
2008
|
boundedInteger("initialSlotPort", input.initialSlotPort, 1024, 65535);
|
|
2009
|
+
const concurrencyLimit = boundedInteger("concurrencyLimit", input.concurrencyLimit ?? 256, 1, 1e6);
|
|
2010
|
+
boundedInteger("workerDrainSeconds", input.workerDrainSeconds ?? 35, 1, 300);
|
|
2648
2011
|
if (input.publicPort === input.initialSlotPort)
|
|
2649
2012
|
throw new Error("Edge and slot ports must differ.");
|
|
2650
2013
|
return {
|
|
2651
2014
|
upstream: `upstream forgezero { server 127.0.0.1:${input.initialSlotPort}; }
|
|
2652
2015
|
`,
|
|
2653
|
-
site: `
|
|
2016
|
+
site: `limit_conn_zone $server_name zone=forgezero_admission:10m;
|
|
2017
|
+
map $http_upgrade $forgezero_connection { default upgrade; '' close; }
|
|
2018
|
+
map $limit_conn_status $forgezero_retry_after { default ''; REJECTED 1; REJECTED_DRY_RUN 1; }
|
|
2019
|
+
server {
|
|
2654
2020
|
listen 127.0.0.1:${input.publicPort};
|
|
2655
2021
|
server_name _;
|
|
2656
|
-
|
|
2657
|
-
|
|
2022
|
+
limit_conn forgezero_admission ${concurrencyLimit};
|
|
2023
|
+
limit_conn_status 503;
|
|
2024
|
+
add_header Retry-After $forgezero_retry_after always;
|
|
2025
|
+
location ^~ /api/ { proxy_pass http://forgezero; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $forgezero_connection; proxy_read_timeout 3600s; }
|
|
2026
|
+
location ^~ /v1/ { proxy_pass http://forgezero; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $forgezero_connection; proxy_read_timeout 3600s; }
|
|
2658
2027
|
location / { return 404; }
|
|
2659
2028
|
}
|
|
2660
2029
|
`
|
|
@@ -2670,6 +2039,7 @@ function renderPlatformActivationFiles(input) {
|
|
|
2670
2039
|
if (input.bluePort === input.greenPort)
|
|
2671
2040
|
throw new Error("Activation slot ports must differ.");
|
|
2672
2041
|
boundedInteger("keepReleases", input.keepReleases, 2, 100);
|
|
2042
|
+
const drainDeadlineMs = boundedInteger("drainDeadlineMs", input.drainDeadlineMs ?? 35000, 1000, 300000);
|
|
2673
2043
|
if (!/^\/[A-Za-z0-9/_-]{1,128}$/.test(input.healthPath) || input.healthPath.includes("..")) {
|
|
2674
2044
|
throw new Error("Activation health path is malformed.");
|
|
2675
2045
|
}
|
|
@@ -2679,7 +2049,8 @@ function renderPlatformActivationFiles(input) {
|
|
|
2679
2049
|
`FZ_BLUE_PORT=${input.bluePort}`,
|
|
2680
2050
|
`FZ_GREEN_PORT=${input.greenPort}`,
|
|
2681
2051
|
`FZ_HEALTH_PATH=${input.healthPath}`,
|
|
2682
|
-
`FZ_KEEP_RELEASES=${input.keepReleases}
|
|
2052
|
+
`FZ_KEEP_RELEASES=${input.keepReleases}`,
|
|
2053
|
+
`FZ_DRAIN_DEADLINE_MS=${drainDeadlineMs}`
|
|
2683
2054
|
].join(`
|
|
2684
2055
|
`) + `
|
|
2685
2056
|
`;
|
|
@@ -2701,7 +2072,14 @@ if (( ! healthy )); then systemctl stop "forgezero@\${target}.service" || true;
|
|
|
2701
2072
|
upstream=/etc/nginx/conf.d/forgezero-upstream.conf; backup="$(mktemp -p /run forgezero-upstream.XXXXXX)"; [[ -f "$upstream" ]] && cp "$upstream" "$backup" || : >"$backup"
|
|
2702
2073
|
printf 'upstream forgezero { server 127.0.0.1:%s; }\\n' "$port" >"$upstream"
|
|
2703
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
|
|
2704
|
-
rm -f "$backup"; printf '%s\\n' "$target" >"$slot_file"
|
|
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
|
|
2705
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-)
|
|
2706
2084
|
for path in "\${old[@]}"; do [[ "$path" == "$release" ]] || rm -rf -- "$path"; done
|
|
2707
2085
|
printf 'promoted %s on %s\\n' "$release" "$target"
|
|
@@ -2832,6 +2210,18 @@ function validateBootstrapConfig(value) {
|
|
|
2832
2210
|
if (telemetry.protocol !== "https:" || telemetry.port && telemetry.port !== "443" || telemetry.username || telemetry.password || telemetry.search || telemetry.hash) {
|
|
2833
2211
|
throw new Error("telemetry endpoint must be public HTTPS on port 443 without credentials, query or fragment");
|
|
2834
2212
|
}
|
|
2213
|
+
const deploymentCredentials = value.deploymentCredentials ?? {};
|
|
2214
|
+
if (!deploymentCredentials || typeof deploymentCredentials !== "object" || Array.isArray(deploymentCredentials) || Object.keys(deploymentCredentials).length > 64) {
|
|
2215
|
+
throw new Error("deployment credentials must be a bounded name-to-path object");
|
|
2216
|
+
}
|
|
2217
|
+
for (const [name, path] of Object.entries(deploymentCredentials)) {
|
|
2218
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || typeof path !== "string" || !path.startsWith("/") || /[\r\n:]/.test(path) || !path.endsWith(".cred")) {
|
|
2219
|
+
throw new Error(`deployment credential ${name} must map to an absolute encrypted .cred path`);
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
if (value.cloudflareHandoff && (!value.installCloudflared || !value.cloudflareHandoff.handoffFile || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(value.cloudflareHandoff.nodeName))) {
|
|
2223
|
+
throw new Error("Cloudflare handoff requires cloudflared installation, a checkpoint and a valid node name");
|
|
2224
|
+
}
|
|
2835
2225
|
if (value.kind === "tenant") {
|
|
2836
2226
|
if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(value.realm))
|
|
2837
2227
|
throw new Error("tenant realm is malformed");
|
|
@@ -2911,9 +2301,6 @@ function validateBootstrapConfig(value) {
|
|
|
2911
2301
|
if (!["genesis-derived", "api-token"].includes(value.enrolment.source) || value.enrolment.source === "api-token" && !value.enrolment.tokenFile || value.enrolment.source === "genesis-derived" && value.enrolment.tokenFile) {
|
|
2912
2302
|
throw new Error("platform enrolment source and token file disagree");
|
|
2913
2303
|
}
|
|
2914
|
-
if (value.cloudflareHandoff && (!value.installCloudflared || !value.cloudflareHandoff.handoffFile || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(value.cloudflareHandoff.nodeName))) {
|
|
2915
|
-
throw new Error("Cloudflare handoff requires cloudflared installation, a checkpoint and a valid node name");
|
|
2916
|
-
}
|
|
2917
2304
|
value.runtime.environment = runtime;
|
|
2918
2305
|
return value;
|
|
2919
2306
|
}
|
|
@@ -3243,7 +2630,7 @@ async function preparePlatformBootstrap(input, host = localBootstrapHost()) {
|
|
|
3243
2630
|
next: "register this read-only deploy key, then run --apply concurrently on all three genesis Agency members"
|
|
3244
2631
|
};
|
|
3245
2632
|
}
|
|
3246
|
-
function stateFor(config) {
|
|
2633
|
+
function stateFor(config, cloudflare, previousCloudflareTunnelId) {
|
|
3247
2634
|
return `${JSON.stringify({
|
|
3248
2635
|
format: 2,
|
|
3249
2636
|
kind: config.kind,
|
|
@@ -3264,7 +2651,11 @@ function stateFor(config) {
|
|
|
3264
2651
|
healthPath: config.runtime.healthPath,
|
|
3265
2652
|
cloudflare: config.runtime.environment.cloudflare,
|
|
3266
2653
|
cloudflared: Boolean(config.cloudflareHandoff)
|
|
3267
|
-
} : {
|
|
2654
|
+
} : {
|
|
2655
|
+
realm: config.realm,
|
|
2656
|
+
cloudflared: Boolean(config.cloudflareHandoff),
|
|
2657
|
+
cloudflareTunnelId: cloudflare?.tunnelId ?? previousCloudflareTunnelId
|
|
2658
|
+
}
|
|
3268
2659
|
}, null, 2)}
|
|
3269
2660
|
`;
|
|
3270
2661
|
}
|
|
@@ -3282,7 +2673,7 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
3282
2673
|
units.push("nginx.service");
|
|
3283
2674
|
if (state.kind === "platform" && state.collectorUnit)
|
|
3284
2675
|
units.push(state.collectorUnit);
|
|
3285
|
-
if (state.
|
|
2676
|
+
if (state.cloudflared)
|
|
3286
2677
|
units.push("cloudflared.service");
|
|
3287
2678
|
if (state.kind === "platform" && state.databaseRole !== "none")
|
|
3288
2679
|
units.push("forgezero-db.service", "forgezero-db-verify.service");
|
|
@@ -3299,13 +2690,25 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
3299
2690
|
if (!services[socket])
|
|
3300
2691
|
problems.push(`${socket} is missing`);
|
|
3301
2692
|
}
|
|
2693
|
+
if (state.cloudflared) {
|
|
2694
|
+
for (const credential of [TUNNEL_CREDENTIAL, `${CREDS}/cloudflare-kv-token.cred`]) {
|
|
2695
|
+
services[credential] = host.exists(credential);
|
|
2696
|
+
if (!services[credential])
|
|
2697
|
+
problems.push(`${credential} is missing`);
|
|
2698
|
+
}
|
|
2699
|
+
const tunnelId = state.cloudflare?.tunnelId ?? state.cloudflareTunnelId;
|
|
2700
|
+
if (!tunnelId) {
|
|
2701
|
+
services["cloudflared-tunnel"] = false;
|
|
2702
|
+
problems.push("Cloudflare tunnel identity is missing from bootstrap state");
|
|
2703
|
+
} else {
|
|
2704
|
+
const evidence = await inspectCloudflaredTunnel(host, tunnelId);
|
|
2705
|
+
services["cloudflared-tunnel"] = evidence.healthy;
|
|
2706
|
+
if (!evidence.healthy)
|
|
2707
|
+
problems.push(evidence.problem);
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
3302
2710
|
if (state.kind === "platform") {
|
|
3303
2711
|
if (state.cloudflared) {
|
|
3304
|
-
for (const credential of [TUNNEL_CREDENTIAL, `${CREDS}/cloudflare-kv-token.cred`]) {
|
|
3305
|
-
services[credential] = host.exists(credential);
|
|
3306
|
-
if (!services[credential])
|
|
3307
|
-
problems.push(`${credential} is missing`);
|
|
3308
|
-
}
|
|
3309
2712
|
if (state.cloudflare?.warp) {
|
|
3310
2713
|
for (const credential of [
|
|
3311
2714
|
`${CREDS}/cloudflare-network-token.cred`,
|
|
@@ -3317,15 +2720,6 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
3317
2720
|
problems.push(`${credential} is missing`);
|
|
3318
2721
|
}
|
|
3319
2722
|
}
|
|
3320
|
-
if (!state.cloudflare?.tunnelId) {
|
|
3321
|
-
services["cloudflared-tunnel"] = false;
|
|
3322
|
-
problems.push("Cloudflare tunnel identity is missing from bootstrap state");
|
|
3323
|
-
} else {
|
|
3324
|
-
const evidence = await inspectCloudflaredTunnel(host, state.cloudflare.tunnelId);
|
|
3325
|
-
services["cloudflared-tunnel"] = evidence.healthy;
|
|
3326
|
-
if (!evidence.healthy)
|
|
3327
|
-
problems.push(evidence.problem);
|
|
3328
|
-
}
|
|
3329
2723
|
}
|
|
3330
2724
|
const [blue, green] = await Promise.all([
|
|
3331
2725
|
host.exec(["systemctl", "is-active", "--quiet", "forgezero@blue.service"]),
|
|
@@ -3394,20 +2788,21 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3394
2788
|
if (host.exists(STATE_PATH))
|
|
3395
2789
|
installed = parseStoredState(host.read(STATE_PATH));
|
|
3396
2790
|
let cloudflare;
|
|
3397
|
-
if (config.
|
|
2791
|
+
if (config.cloudflareHandoff) {
|
|
3398
2792
|
if (host.exists(config.cloudflareHandoff.handoffFile)) {
|
|
3399
2793
|
cloudflare = await readCloudflareHostHandoff(config.cloudflareHandoff.handoffFile, config.cloudflareHandoff.nodeName);
|
|
3400
|
-
} else if (installed?.cloudflare) {
|
|
2794
|
+
} else if (config.kind === "platform" && installed?.cloudflare) {
|
|
3401
2795
|
config.runtime.environment.cloudflare = installed.cloudflare;
|
|
3402
2796
|
config.runtime.environment = validatePlatformSharedEnvironment(config.runtime.environment);
|
|
3403
|
-
} else {
|
|
2797
|
+
} else if (!(config.kind === "tenant" && installed?.cloudflareTunnelId)) {
|
|
3404
2798
|
throw new Error("node-specific Cloudflare host handoff is missing before credential sealing");
|
|
3405
2799
|
}
|
|
3406
2800
|
}
|
|
2801
|
+
if (cloudflare && cloudflare.hostname !== config.nodeHostname) {
|
|
2802
|
+
throw new Error("Cloudflare handoff hostname disagrees with node hostname");
|
|
2803
|
+
}
|
|
3407
2804
|
if (cloudflare && config.kind === "platform") {
|
|
3408
2805
|
const expected = config.runtime.environment.cloudflare;
|
|
3409
|
-
if (cloudflare.hostname !== config.nodeHostname)
|
|
3410
|
-
throw new Error("Cloudflare handoff hostname disagrees with platform node hostname");
|
|
3411
2806
|
if (expected && (cloudflare.service !== expected.tunnelService || cloudflare.accountId !== expected.accountId || cloudflare.zoneId !== expected.zoneId || cloudflare.kvNamespaceId !== expected.kvNamespaceId || cloudflare.tunnelId !== expected.tunnelId)) {
|
|
3412
2807
|
throw new Error("Cloudflare handoff disagrees with immutable platform runtime coordinates");
|
|
3413
2808
|
}
|
|
@@ -3449,6 +2844,9 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3449
2844
|
await seal(host, "warp-auth-client-id", WARP_CLIENT_ID_CREDENTIAL, cloudflare.warp.clientId);
|
|
3450
2845
|
await seal(host, "warp-auth-client-secret", WARP_CLIENT_SECRET_CREDENTIAL, cloudflare.warp.clientSecret);
|
|
3451
2846
|
}
|
|
2847
|
+
if (cloudflare && !host.exists(`${CREDS}/cloudflare-kv-token.cred`)) {
|
|
2848
|
+
await seal(host, "cloudflare-kv-token", `${CREDS}/cloudflare-kv-token.cred`, cloudflare.kvRuntimeToken);
|
|
2849
|
+
}
|
|
3452
2850
|
await host.installAgent(config);
|
|
3453
2851
|
if (config.kind === "platform" && config.firewall.enabled) {
|
|
3454
2852
|
await host.ensureSoftware(plan.software.filter(({ id }) => id === "ufw"));
|
|
@@ -3488,7 +2886,6 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3488
2886
|
}
|
|
3489
2887
|
}
|
|
3490
2888
|
if (cloudflare) {
|
|
3491
|
-
await seal(host, "cloudflare-kv-token", `${CREDS}/cloudflare-kv-token.cred`, cloudflare.kvRuntimeToken);
|
|
3492
2889
|
if (cloudflare.privateNetworkRuntimeToken)
|
|
3493
2890
|
await seal(host, "cloudflare-network-token", `${CREDS}/cloudflare-network-token.cred`, cloudflare.privateNetworkRuntimeToken);
|
|
3494
2891
|
}
|
|
@@ -3509,16 +2906,23 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3509
2906
|
bluePort: runtime.bluePort,
|
|
3510
2907
|
greenPort: runtime.greenPort,
|
|
3511
2908
|
collectorUnit: runtime.environment.otlpCollectorUnit,
|
|
3512
|
-
credentials
|
|
2909
|
+
credentials,
|
|
2910
|
+
capacityEnvironmentFile: "/etc/forgezero/capacity.env"
|
|
2911
|
+
});
|
|
2912
|
+
const edge = renderPlatformNginx({
|
|
2913
|
+
publicPort: runtime.environment.publicApiPort,
|
|
2914
|
+
initialSlotPort: runtime.bluePort,
|
|
2915
|
+
concurrencyLimit: runtime.environment.concurrencyLimit,
|
|
2916
|
+
workerDrainSeconds: Math.ceil(runtime.environment.drainDeadlineMs / 1000)
|
|
3513
2917
|
});
|
|
3514
|
-
const edge = renderPlatformNginx({ publicPort: runtime.environment.publicApiPort, initialSlotPort: runtime.bluePort });
|
|
3515
2918
|
const activation = renderPlatformActivationFiles({
|
|
3516
2919
|
root: config.deployRoot ?? "/opt/forgezero",
|
|
3517
2920
|
serviceUser: runtime.serviceUser,
|
|
3518
2921
|
bluePort: runtime.bluePort,
|
|
3519
2922
|
greenPort: runtime.greenPort,
|
|
3520
2923
|
healthPath: runtime.healthPath,
|
|
3521
|
-
keepReleases: runtime.keepReleases
|
|
2924
|
+
keepReleases: runtime.keepReleases,
|
|
2925
|
+
drainDeadlineMs: runtime.environment.drainDeadlineMs
|
|
3522
2926
|
});
|
|
3523
2927
|
await checked(host, ["useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", runtime.serviceUser], "API service account").catch(async () => {
|
|
3524
2928
|
await checked(host, ["id", runtime.serviceUser], "existing API service account");
|
|
@@ -3526,6 +2930,10 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3526
2930
|
host.mkdir(runtime.environment.sharedDirectory, 488);
|
|
3527
2931
|
host.mkdir(runtime.slotsDirectory, 493);
|
|
3528
2932
|
host.write(envPath, renderPlatformSharedEnvironment(runtime.environment), 416);
|
|
2933
|
+
if (!host.exists("/etc/forgezero/capacity.env")) {
|
|
2934
|
+
host.write("/etc/forgezero/capacity.env", `FZ_CONCURRENCY_LIMIT=${runtime.environment.concurrencyLimit}
|
|
2935
|
+
`, 420);
|
|
2936
|
+
}
|
|
3529
2937
|
await checked(host, ["chown", `root:${runtime.serviceUser}`, runtime.environment.sharedDirectory, envPath], "runtime ownership");
|
|
3530
2938
|
host.write("/etc/systemd/system/forgezero@.service", units.template, 420);
|
|
3531
2939
|
host.write("/etc/systemd/system/forgezero@blue.service.d/port.conf", units.dropIns.blue, 420);
|
|
@@ -3591,7 +2999,7 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3591
2999
|
host.remove(config.enrolment.tokenFile);
|
|
3592
3000
|
}
|
|
3593
3001
|
}
|
|
3594
|
-
if (config.
|
|
3002
|
+
if (config.cloudflareHandoff) {
|
|
3595
3003
|
if (!host.exists(TUNNEL_CREDENTIAL) && cloudflare) {
|
|
3596
3004
|
await seal(host, "cloudflared-token", TUNNEL_CREDENTIAL, cloudflare.connectorToken);
|
|
3597
3005
|
}
|
|
@@ -3600,17 +3008,17 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3600
3008
|
host.write("/etc/systemd/system/cloudflared.service", tunnelUnit(), 420);
|
|
3601
3009
|
await checked(host, ["systemctl", "daemon-reload"], "cloudflared unit reload");
|
|
3602
3010
|
await checked(host, ["systemctl", "enable", "--now", "cloudflared.service"], "cloudflared connector supervision");
|
|
3603
|
-
const tunnelId = config.runtime.environment.cloudflare?.tunnelId;
|
|
3011
|
+
const tunnelId = cloudflare?.tunnelId ?? installed?.cloudflareTunnelId ?? (config.kind === "platform" ? config.runtime.environment.cloudflare?.tunnelId : undefined);
|
|
3604
3012
|
if (!tunnelId)
|
|
3605
3013
|
throw new Error("Cloudflare tunnel identity is missing after handoff validation");
|
|
3606
3014
|
await waitForCloudflaredTunnel(host, tunnelId);
|
|
3607
3015
|
}
|
|
3608
|
-
host.write(STATE_PATH, stateFor(config), 384);
|
|
3016
|
+
host.write(STATE_PATH, stateFor(config, cloudflare, installed?.cloudflareTunnelId), 384);
|
|
3609
3017
|
const status = await bootstrapStatus(host);
|
|
3610
3018
|
if (!status.initialized)
|
|
3611
3019
|
throw new Error(`bootstrap verification failed: ${status.problems.join("; ")}`);
|
|
3612
3020
|
host.remove(INTENT_PATH);
|
|
3613
|
-
if (cloudflare && config.
|
|
3021
|
+
if (cloudflare && config.cloudflareHandoff) {
|
|
3614
3022
|
host.remove(config.cloudflareHandoff.handoffFile);
|
|
3615
3023
|
}
|
|
3616
3024
|
let launch;
|
|
@@ -3658,6 +3066,7 @@ function strictBootstrapDocument(value) {
|
|
|
3658
3066
|
"realm",
|
|
3659
3067
|
"enrolTokenFile",
|
|
3660
3068
|
"software",
|
|
3069
|
+
"deploymentCredentials",
|
|
3661
3070
|
"bootstrapRunner"
|
|
3662
3071
|
], "bootstrap config");
|
|
3663
3072
|
if (root.kind === "platform") {
|
|
@@ -3724,9 +3133,11 @@ function strictBootstrapDocument(value) {
|
|
|
3724
3133
|
exactKeys(environment.cloudflare.warp, ["organization", "virtualNetworkId", "deviceProfileId"], "Cloudflare WARP runtime config");
|
|
3725
3134
|
}
|
|
3726
3135
|
} else if (root.kind === "tenant") {
|
|
3136
|
+
if (root.cloudflareHandoff !== undefined)
|
|
3137
|
+
exactKeys(root.cloudflareHandoff, ["handoffFile", "nodeName"], "Cloudflare handoff");
|
|
3727
3138
|
if (root.bootstrapRunner !== undefined)
|
|
3728
3139
|
exactKeys(root.bootstrapRunner, ["sshPrivateKeyFile", "targetTelemetryEndpoint"], "bootstrap runner config");
|
|
3729
|
-
for (const key of ["environment", "profile", "computeReference", "database", "enrolment", "runtime"
|
|
3140
|
+
for (const key of ["environment", "profile", "computeReference", "database", "enrolment", "runtime"]) {
|
|
3730
3141
|
if (root[key] !== undefined && key !== "profile")
|
|
3731
3142
|
throw new Error(`tenant bootstrap cannot contain ${key}`);
|
|
3732
3143
|
}
|
|
@@ -3815,6 +3226,7 @@ function localBootstrapHost() {
|
|
|
3815
3226
|
branch: config.branch,
|
|
3816
3227
|
profile: config.kind === "platform" ? config.profile : config.profile,
|
|
3817
3228
|
deployRoot,
|
|
3229
|
+
deploymentCredentials: config.deploymentCredentials,
|
|
3818
3230
|
publicApiUrl: config.apiUrl,
|
|
3819
3231
|
gitCredentialPath: "/etc/forgezero/creds/git-deploy-key.cred",
|
|
3820
3232
|
gitPublicKeyPath: "/etc/forgezero/git/deploy.pub",
|