@forgezero/agent 0.1.38 → 0.1.40
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 +118 -12
- package/dist/agent-heartbeat.d.ts +13 -0
- package/dist/agent-heartbeat.js +24 -14
- package/dist/agent-update-helper.js +15 -13
- package/dist/agent-update.js +15 -13
- package/dist/bootstrap.d.ts +49 -4
- package/dist/bootstrap.js +660 -791
- package/dist/cli/cloudflare-bootstrap.d.ts +5 -1
- package/dist/cli/index.d.ts +0 -2
- package/dist/cloudflare-bootstrap.d.ts +36 -114
- package/dist/cloudflare-bootstrap.js +237 -738
- 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/deployment-pull.d.ts +2 -0
- package/dist/fz-agent.js +6407 -58
- package/dist/fz.js +10668 -1151
- package/dist/index.d.ts +33 -0
- package/dist/metal-bootstrap.d.ts +1 -0
- package/dist/metal-bootstrap.js +5 -3
- package/dist/platform-bootstrap-runtime.d.ts +5 -28
- package/dist/platform-bootstrap-runtime.js +26 -77
- 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 +8 -4
- 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,11 +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 {
|
|
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}$/;
|
|
414
205
|
var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
415
206
|
async function assertOwnerOnlyHandle(path, handle, maximumBytes) {
|
|
416
207
|
const metadata = await handle.stat();
|
|
@@ -419,8 +210,9 @@ async function assertOwnerOnlyHandle(path, handle, maximumBytes) {
|
|
|
419
210
|
if (metadata.nlink !== 1)
|
|
420
211
|
throw new Error(`${path} must not have multiple hard links`);
|
|
421
212
|
const uid = ownerUid();
|
|
422
|
-
if (uid !== undefined && uid !== 0 && metadata.uid !== uid)
|
|
213
|
+
if (uid !== undefined && uid !== 0 && metadata.uid !== uid) {
|
|
423
214
|
throw new Error(`${path} must be owned by the current operator`);
|
|
215
|
+
}
|
|
424
216
|
if ((metadata.mode & 63) !== 0)
|
|
425
217
|
throw new Error(`${path} must not be accessible by group or other users`);
|
|
426
218
|
if ((metadata.mode & 256) === 0)
|
|
@@ -445,25 +237,36 @@ async function readOwnerOnlyFile(path, maximumBytes) {
|
|
|
445
237
|
}
|
|
446
238
|
async function readOwnerApiToken(path) {
|
|
447
239
|
const token = (await readOwnerOnlyFile(path, 4096)).trim();
|
|
448
|
-
if (
|
|
240
|
+
if (!TOKEN.test(token))
|
|
449
241
|
throw new Error(`${resolve(path)} must contain exactly one Cloudflare API token`);
|
|
450
|
-
}
|
|
451
242
|
return token;
|
|
452
243
|
}
|
|
453
244
|
async function readCloudflareBootstrapTokens(files) {
|
|
454
245
|
const entries = await Promise.all([
|
|
455
246
|
["apiToken", files.apiTokenFile],
|
|
247
|
+
["managementApiToken", files.managementApiTokenFile],
|
|
248
|
+
["runtimeApiToken", files.runtimeApiTokenFile],
|
|
456
249
|
["tunnelApiToken", files.tunnelApiTokenFile],
|
|
457
250
|
["dnsApiToken", files.dnsApiTokenFile],
|
|
458
|
-
["kvApiToken", files.kvApiTokenFile]
|
|
459
|
-
["accessApiToken", files.accessApiTokenFile],
|
|
460
|
-
["workerApiToken", files.workerApiTokenFile]
|
|
251
|
+
["kvApiToken", files.kvApiTokenFile]
|
|
461
252
|
].map(async ([key, path]) => [key, path ? await readOwnerApiToken(path) : undefined]));
|
|
462
|
-
const
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
+
}
|
|
467
270
|
}
|
|
468
271
|
return tokens;
|
|
469
272
|
}
|
|
@@ -473,36 +276,30 @@ var validateId = (value, label) => {
|
|
|
473
276
|
throw new Error(`${label} must be a 32-character hexadecimal id`);
|
|
474
277
|
return normalized;
|
|
475
278
|
};
|
|
476
|
-
var validateName = (value, label
|
|
279
|
+
var validateName = (value, label) => {
|
|
477
280
|
const normalized = value.trim();
|
|
478
|
-
|
|
479
|
-
if (!normalized || normalized.length > maximum || !pattern.test(normalized)) {
|
|
281
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(normalized))
|
|
480
282
|
throw new Error(`${label} is invalid`);
|
|
481
|
-
}
|
|
482
283
|
return normalized;
|
|
483
284
|
};
|
|
484
|
-
var
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
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");
|
|
491
291
|
}
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
if (Number.isFinite(first) && (first & 65024) === 64512)
|
|
495
|
-
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");
|
|
496
294
|
}
|
|
497
|
-
|
|
295
|
+
return service.toString().replace(/\/$/, "");
|
|
498
296
|
};
|
|
499
297
|
function validateCloudflareBootstrapCoordinates(input) {
|
|
500
298
|
const nodeInputs = input.nodes?.length ? input.nodes : [{
|
|
501
299
|
nodeName: input.tunnelName,
|
|
502
300
|
hostname: input.hostname,
|
|
503
301
|
service: input.service,
|
|
504
|
-
tunnelName: input.tunnelName
|
|
505
|
-
applicationName: input.applicationName
|
|
302
|
+
tunnelName: input.tunnelName
|
|
506
303
|
}];
|
|
507
304
|
if (nodeInputs.length < 1 || nodeInputs.length > 32) {
|
|
508
305
|
throw new Error("Cloudflare bootstrap requires between 1 and 32 explicit nodes");
|
|
@@ -512,88 +309,32 @@ function validateCloudflareBootstrapCoordinates(input) {
|
|
|
512
309
|
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(nodeName))
|
|
513
310
|
throw new Error("Cloudflare node name is invalid");
|
|
514
311
|
const hostname = node.hostname.trim().toLowerCase();
|
|
515
|
-
if (
|
|
312
|
+
if (!HOSTNAME.test(hostname))
|
|
516
313
|
throw new Error("Cloudflare public node hostname is invalid");
|
|
517
|
-
}
|
|
518
|
-
const serviceUrl = new URL(node.service);
|
|
519
|
-
if (serviceUrl.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(serviceUrl.hostname) || !serviceUrl.port || serviceUrl.pathname !== "/" || serviceUrl.search || serviceUrl.hash) {
|
|
520
|
-
throw new Error("Cloudflare Tunnel service must be an explicit loopback HTTP port");
|
|
521
|
-
}
|
|
522
314
|
return {
|
|
523
315
|
nodeName,
|
|
524
316
|
hostname,
|
|
525
|
-
service:
|
|
526
|
-
tunnelName: validateName(node.tunnelName, "Cloudflare Tunnel name"
|
|
527
|
-
applicationName: validateName(node.applicationName, "Cloudflare Access application name", 100),
|
|
528
|
-
...node.privateAddress ? { privateAddress: privateAddress(node.privateAddress) } : {}
|
|
317
|
+
service: normalizeService(node.service),
|
|
318
|
+
tunnelName: validateName(node.tunnelName, "Cloudflare Tunnel name")
|
|
529
319
|
};
|
|
530
320
|
});
|
|
531
321
|
for (const [label, values] of [
|
|
532
322
|
["node name", nodes.map(({ nodeName }) => nodeName)],
|
|
533
323
|
["hostname", nodes.map(({ hostname }) => hostname)],
|
|
534
|
-
["Tunnel name", nodes.map(({ tunnelName }) => tunnelName)]
|
|
535
|
-
["Access application name", nodes.map(({ applicationName }) => applicationName)]
|
|
324
|
+
["Tunnel name", nodes.map(({ tunnelName }) => tunnelName)]
|
|
536
325
|
]) {
|
|
537
326
|
if (new Set(values).size !== values.length)
|
|
538
327
|
throw new Error(`Cloudflare fleet ${label} must be unique`);
|
|
539
328
|
}
|
|
540
329
|
const first = nodes[0];
|
|
541
|
-
const workerScriptName = input.workerScriptName.trim();
|
|
542
|
-
if (!/^[a-z][a-z0-9-]{0,62}$/.test(workerScriptName))
|
|
543
|
-
throw new Error("Cloudflare Worker script name is invalid");
|
|
544
|
-
const workerCompatibilityDate = input.workerCompatibilityDate.trim();
|
|
545
|
-
if (!/^20\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/.test(workerCompatibilityDate)) {
|
|
546
|
-
throw new Error("Cloudflare Worker compatibility date is invalid");
|
|
547
|
-
}
|
|
548
|
-
const publicDomains = [...new Set(input.publicDomains.map((domain) => domain.trim().toLowerCase()))];
|
|
549
|
-
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))) {
|
|
550
|
-
throw new Error("Cloudflare Worker public domains are invalid or include the private origin hostname");
|
|
551
|
-
}
|
|
552
|
-
const workerDirectory = resolve(input.workerDirectory);
|
|
553
|
-
const workerMain = input.workerMain.trim();
|
|
554
|
-
if (!workerMain || workerMain.startsWith("/") || workerMain.split(/[\\/]/).includes("..")) {
|
|
555
|
-
throw new Error("Cloudflare Worker main must be a project-relative path");
|
|
556
|
-
}
|
|
557
|
-
const runtimeTokenNamePrefix = input.runtimeTokenNamePrefix.trim();
|
|
558
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(runtimeTokenNamePrefix)) {
|
|
559
|
-
throw new Error("Cloudflare runtime-token name prefix is invalid");
|
|
560
|
-
}
|
|
561
|
-
if (input.createPrivateNetworkRuntimeToken && !input.createRuntimeTokens) {
|
|
562
|
-
throw new Error("private-network runtime token requires runtime-token creation");
|
|
563
|
-
}
|
|
564
|
-
const privateNetwork = input.privateNetwork ? {
|
|
565
|
-
warpOrganization: validateName(input.privateNetwork.warpOrganization, "Cloudflare WARP organization", 63, false).toLowerCase(),
|
|
566
|
-
virtualNetworkName: validateName(input.privateNetwork.virtualNetworkName, "Cloudflare VNET name", 100),
|
|
567
|
-
deviceProfileName: validateName(input.privateNetwork.deviceProfileName, "Cloudflare WARP device profile name", 100),
|
|
568
|
-
enrollmentApplicationName: validateName(input.privateNetwork.enrollmentApplicationName, "Cloudflare WARP enrollment application name", 100),
|
|
569
|
-
...input.privateNetwork.deviceProfilePrecedence !== undefined ? { deviceProfilePrecedence: input.privateNetwork.deviceProfilePrecedence } : {}
|
|
570
|
-
} : undefined;
|
|
571
|
-
if (privateNetwork && (!input.createPrivateNetworkRuntimeToken || nodes.every((node) => !node.privateAddress))) {
|
|
572
|
-
throw new Error("Cloudflare private network requires its runtime token and at least one DB node private address");
|
|
573
|
-
}
|
|
574
|
-
if (!privateNetwork && nodes.some((node) => node.privateAddress)) {
|
|
575
|
-
throw new Error("Cloudflare node private addresses require privateNetwork coordinates");
|
|
576
|
-
}
|
|
577
330
|
return {
|
|
578
331
|
accountId: validateId(input.accountId, "Cloudflare account id"),
|
|
579
332
|
zoneId: validateId(input.zoneId, "Cloudflare zone id"),
|
|
580
333
|
hostname: first.hostname,
|
|
581
334
|
service: first.service,
|
|
582
335
|
tunnelName: first.tunnelName,
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
serviceTokenName: validateName(input.serviceTokenName, "Cloudflare Access service-token name", 100),
|
|
586
|
-
policyName: validateName(input.policyName, "Cloudflare Access policy name", 100),
|
|
587
|
-
applicationName: first.applicationName,
|
|
588
|
-
workerDirectory,
|
|
589
|
-
workerMain,
|
|
590
|
-
workerCompatibilityDate,
|
|
591
|
-
publicDomains,
|
|
592
|
-
createRuntimeTokens: input.createRuntimeTokens,
|
|
593
|
-
createPrivateNetworkRuntimeToken: input.createPrivateNetworkRuntimeToken,
|
|
594
|
-
runtimeTokenNamePrefix,
|
|
595
|
-
nodes,
|
|
596
|
-
...privateNetwork ? { privateNetwork } : {}
|
|
336
|
+
kvNamespaceId: validateId(input.kvNamespaceId, "Cloudflare KV namespace id"),
|
|
337
|
+
nodes
|
|
597
338
|
};
|
|
598
339
|
}
|
|
599
340
|
function planCloudflareBootstrap(input, outputPath) {
|
|
@@ -605,120 +346,18 @@ function planCloudflareBootstrap(input, outputPath) {
|
|
|
605
346
|
outputFile: resolve(outputPath),
|
|
606
347
|
coordinates,
|
|
607
348
|
operations: [
|
|
608
|
-
"create or reuse one Workers KV namespace",
|
|
609
349
|
"create or reuse one remotely-managed Tunnel per node and checkpoint every connector token",
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
"deploy the shared Worker once with the created NODES binding and stable custom domains",
|
|
614
|
-
"create or reuse one shared Access service token/policy and one self-hosted application per node",
|
|
615
|
-
...coordinates.privateNetwork ? [
|
|
616
|
-
"create or reuse the VNET, WARP enrollment application, locked service-token device profile and exact DB host routes"
|
|
617
|
-
] : [],
|
|
618
|
-
"write the Access client id and secret to the existing Worker as encrypted secrets",
|
|
619
|
-
"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"
|
|
620
353
|
],
|
|
621
354
|
secrets: [
|
|
622
|
-
"API tokens are read only from owner-only files and are never
|
|
623
|
-
"the
|
|
624
|
-
"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"
|
|
625
358
|
]
|
|
626
359
|
};
|
|
627
360
|
}
|
|
628
|
-
var defaultWorkerCommandRunner = async ({ command, cwd, env }) => {
|
|
629
|
-
const child = Bun.spawn([...command], {
|
|
630
|
-
cwd,
|
|
631
|
-
env: { ...env },
|
|
632
|
-
stdin: "ignore",
|
|
633
|
-
stdout: "pipe",
|
|
634
|
-
stderr: "pipe"
|
|
635
|
-
});
|
|
636
|
-
const [exitCode, stdout, stderr] = await Promise.all([
|
|
637
|
-
child.exited,
|
|
638
|
-
new Response(child.stdout).text(),
|
|
639
|
-
new Response(child.stderr).text()
|
|
640
|
-
]);
|
|
641
|
-
return { exitCode, stdout, stderr };
|
|
642
|
-
};
|
|
643
|
-
var inheritedWorkerEnvironment = () => {
|
|
644
|
-
const allowed = [
|
|
645
|
-
"PATH",
|
|
646
|
-
"HOME",
|
|
647
|
-
"TMPDIR",
|
|
648
|
-
"XDG_CONFIG_HOME",
|
|
649
|
-
"XDG_CACHE_HOME",
|
|
650
|
-
"SSL_CERT_FILE",
|
|
651
|
-
"SSL_CERT_DIR",
|
|
652
|
-
"NODE_EXTRA_CA_CERTS",
|
|
653
|
-
"HTTPS_PROXY",
|
|
654
|
-
"HTTP_PROXY",
|
|
655
|
-
"NO_PROXY"
|
|
656
|
-
];
|
|
657
|
-
return Object.fromEntries(allowed.flatMap((key) => process.env[key] ? [[key, process.env[key]]] : []));
|
|
658
|
-
};
|
|
659
|
-
var redact = (text, secrets) => {
|
|
660
|
-
let safe = text.slice(0, 4096);
|
|
661
|
-
for (const secret of secrets)
|
|
662
|
-
if (secret)
|
|
663
|
-
safe = safe.split(secret).join("[REDACTED]");
|
|
664
|
-
return safe.trim();
|
|
665
|
-
};
|
|
666
|
-
async function deployCloudflareWorker(coordinates, kvNamespaceId, apiToken, runner = defaultWorkerCommandRunner) {
|
|
667
|
-
const validated = validateCloudflareBootstrapCoordinates(coordinates);
|
|
668
|
-
const workerDirectoryMetadata = await stat(validated.workerDirectory);
|
|
669
|
-
if (!workerDirectoryMetadata.isDirectory())
|
|
670
|
-
throw new Error("Cloudflare Worker directory is not a directory");
|
|
671
|
-
const workerMain = resolve(validated.workerDirectory, validated.workerMain);
|
|
672
|
-
const workerMainMetadata = await stat(workerMain);
|
|
673
|
-
if (!workerMainMetadata.isFile())
|
|
674
|
-
throw new Error("Cloudflare Worker main is not a regular file");
|
|
675
|
-
const wrangler = resolve(validated.workerDirectory, "node_modules/.bin/wrangler");
|
|
676
|
-
const wranglerMetadata = await stat(wrangler);
|
|
677
|
-
if (!wranglerMetadata.isFile())
|
|
678
|
-
throw new Error("Cloudflare Wrangler is not installed in the Worker project");
|
|
679
|
-
if (!/^[a-f0-9]{32}$/.test(kvNamespaceId))
|
|
680
|
-
throw new Error("Cloudflare KV namespace id is invalid");
|
|
681
|
-
const temporaryDirectory = await mkdtemp(join(tmpdir(), "fz-wrangler-"));
|
|
682
|
-
await chmod(temporaryDirectory, 448);
|
|
683
|
-
const configurationPath = join(temporaryDirectory, "wrangler.json");
|
|
684
|
-
try {
|
|
685
|
-
const handle = await open(configurationPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 384);
|
|
686
|
-
try {
|
|
687
|
-
await handle.writeFile(`${JSON.stringify({
|
|
688
|
-
name: validated.workerScriptName,
|
|
689
|
-
main: workerMain,
|
|
690
|
-
compatibility_date: validated.workerCompatibilityDate,
|
|
691
|
-
workers_dev: false,
|
|
692
|
-
routes: validated.publicDomains.map((pattern) => ({ pattern, custom_domain: true })),
|
|
693
|
-
observability: { enabled: true },
|
|
694
|
-
kv_namespaces: [{ binding: "NODES", id: kvNamespaceId }]
|
|
695
|
-
}, null, 2)}
|
|
696
|
-
`);
|
|
697
|
-
await handle.sync();
|
|
698
|
-
} finally {
|
|
699
|
-
await handle.close();
|
|
700
|
-
}
|
|
701
|
-
const result = await runner({
|
|
702
|
-
command: [wrangler, "deploy", "--config", configurationPath],
|
|
703
|
-
cwd: validated.workerDirectory,
|
|
704
|
-
env: {
|
|
705
|
-
...inheritedWorkerEnvironment(),
|
|
706
|
-
XDG_CONFIG_HOME: temporaryDirectory,
|
|
707
|
-
XDG_CACHE_HOME: temporaryDirectory,
|
|
708
|
-
WRANGLER_LOG_PATH: join(temporaryDirectory, "wrangler.log"),
|
|
709
|
-
CLOUDFLARE_ACCOUNT_ID: validated.accountId,
|
|
710
|
-
CLOUDFLARE_API_TOKEN: apiToken,
|
|
711
|
-
WRANGLER_SEND_METRICS: "false"
|
|
712
|
-
}
|
|
713
|
-
});
|
|
714
|
-
if (result.exitCode !== 0) {
|
|
715
|
-
const detail = redact(result.stderr || result.stdout || "no Wrangler diagnostic", [apiToken]);
|
|
716
|
-
throw new Error(`Cloudflare Worker deployment failed with exit ${result.exitCode}: ${detail}`);
|
|
717
|
-
}
|
|
718
|
-
} finally {
|
|
719
|
-
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
361
|
async function readExistingOutput(path) {
|
|
723
362
|
try {
|
|
724
363
|
await lstat(path);
|
|
@@ -727,18 +366,26 @@ async function readExistingOutput(path) {
|
|
|
727
366
|
return;
|
|
728
367
|
throw cause;
|
|
729
368
|
}
|
|
730
|
-
|
|
731
|
-
let output;
|
|
369
|
+
let parsed;
|
|
732
370
|
try {
|
|
733
|
-
|
|
734
|
-
} catch {
|
|
735
|
-
|
|
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;
|
|
736
376
|
}
|
|
377
|
+
const output = parsed;
|
|
737
378
|
if (output.format !== 1 || output.kind !== "forgezero-cloudflare-bootstrap" || !output.resources) {
|
|
738
379
|
throw new Error(`${resolve(path)} is not a ForgeZero Cloudflare bootstrap output`);
|
|
739
380
|
}
|
|
740
381
|
return output;
|
|
741
382
|
}
|
|
383
|
+
function cloudflareHostHandoffPath(checkpointPath, nodeName) {
|
|
384
|
+
const normalized = nodeName.trim().toLowerCase();
|
|
385
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(normalized))
|
|
386
|
+
throw new Error("Cloudflare host handoff node name is invalid");
|
|
387
|
+
return join(`${resolve(checkpointPath)}.hosts`, `${normalized}.json`);
|
|
388
|
+
}
|
|
742
389
|
async function readCloudflareConnectorHandoff(checkpointPath, nodeName) {
|
|
743
390
|
const output = await readExistingOutput(resolve(checkpointPath));
|
|
744
391
|
if (!output || output.phase !== "complete") {
|
|
@@ -755,7 +402,7 @@ async function readCloudflareConnectorHandoff(checkpointPath, nodeName) {
|
|
|
755
402
|
throw new Error(`Cloudflare connector handoff has no unique completed node ${normalizedNodeName}`);
|
|
756
403
|
}
|
|
757
404
|
const resource = matches[0];
|
|
758
|
-
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)) {
|
|
759
406
|
throw new Error(`Cloudflare connector handoff for ${normalizedNodeName} is malformed or incomplete`);
|
|
760
407
|
}
|
|
761
408
|
return {
|
|
@@ -766,52 +413,63 @@ async function readCloudflareConnectorHandoff(checkpointPath, nodeName) {
|
|
|
766
413
|
connectorToken: resource.connectorToken
|
|
767
414
|
};
|
|
768
415
|
}
|
|
769
|
-
async function readCloudflareHostHandoff(
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
416
|
+
async function readCloudflareHostHandoff(handoffPath, nodeName) {
|
|
417
|
+
let parsed;
|
|
418
|
+
try {
|
|
419
|
+
parsed = JSON.parse(await readOwnerOnlyFile(handoffPath, 65536));
|
|
420
|
+
} catch (cause) {
|
|
421
|
+
if (cause instanceof SyntaxError)
|
|
422
|
+
throw new Error("Cloudflare host handoff is not valid JSON");
|
|
423
|
+
throw cause;
|
|
424
|
+
}
|
|
425
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
426
|
+
throw new Error("Cloudflare host handoff is malformed");
|
|
427
|
+
const output = parsed;
|
|
428
|
+
const unknown = Object.keys(output).filter((key) => ![
|
|
429
|
+
"format",
|
|
430
|
+
"kind",
|
|
431
|
+
"nodeName",
|
|
432
|
+
"hostname",
|
|
433
|
+
"service",
|
|
434
|
+
"tunnelId",
|
|
435
|
+
"connectorToken",
|
|
436
|
+
"accountId",
|
|
437
|
+
"zoneId",
|
|
438
|
+
"kvNamespaceId",
|
|
439
|
+
"kvRuntimeToken",
|
|
440
|
+
"privateNetworkRuntimeToken",
|
|
441
|
+
"warp"
|
|
442
|
+
].includes(key));
|
|
443
|
+
if (unknown.length)
|
|
444
|
+
throw new Error(`Cloudflare host handoff contains unsupported field ${unknown[0]}`);
|
|
445
|
+
const normalizedNodeName = nodeName.trim().toLowerCase();
|
|
446
|
+
let service;
|
|
447
|
+
try {
|
|
448
|
+
service = normalizeService(output.service ?? "");
|
|
449
|
+
} 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.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 ?? "")) {
|
|
451
|
+
throw new Error("Cloudflare host handoff is malformed or belongs to another node");
|
|
775
452
|
}
|
|
776
|
-
|
|
777
|
-
if (network !== undefined && !/^[A-Za-z0-9._-]{40,80}$/.test(network)) {
|
|
453
|
+
if (output.privateNetworkRuntimeToken !== undefined && !TOKEN.test(output.privateNetworkRuntimeToken)) {
|
|
778
454
|
throw new Error("Cloudflare host handoff private-network capability is malformed");
|
|
779
455
|
}
|
|
780
|
-
|
|
781
|
-
const access = output.resources.access;
|
|
782
|
-
if (Boolean(privateNetwork) !== Boolean(network)) {
|
|
456
|
+
if (Boolean(output.warp) !== Boolean(output.privateNetworkRuntimeToken)) {
|
|
783
457
|
throw new Error("Cloudflare host handoff private-network resources and capability disagree");
|
|
784
458
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
}
|
|
788
|
-
return {
|
|
789
|
-
...connector,
|
|
790
|
-
accountId: output.coordinates.accountId,
|
|
791
|
-
zoneId: output.coordinates.zoneId,
|
|
792
|
-
kvNamespaceId: output.resources.kvNamespaceId,
|
|
793
|
-
kvRuntimeToken: kv.value,
|
|
794
|
-
...network ? { privateNetworkRuntimeToken: network } : {},
|
|
795
|
-
...privateNetwork && access ? { warp: {
|
|
796
|
-
organization: privateNetwork.warpOrganization,
|
|
797
|
-
clientId: access.clientId,
|
|
798
|
-
clientSecret: access.clientSecret,
|
|
799
|
-
virtualNetworkId: privateNetwork.virtualNetworkId,
|
|
800
|
-
deviceProfileId: privateNetwork.deviceProfileId
|
|
801
|
-
} } : {}
|
|
802
|
-
};
|
|
459
|
+
const { format: _format, kind: _kind, ...handoff } = output;
|
|
460
|
+
return handoff;
|
|
803
461
|
}
|
|
804
462
|
async function prepareOwnerOutputDirectory(absolutePath) {
|
|
805
463
|
const directory = dirname(absolutePath);
|
|
806
464
|
await mkdir(directory, { recursive: true, mode: 448 });
|
|
807
|
-
const
|
|
465
|
+
const metadata = await stat(directory);
|
|
808
466
|
const uid = ownerUid();
|
|
809
|
-
if (!
|
|
467
|
+
if (!metadata.isDirectory() || uid !== undefined && metadata.uid !== uid || (metadata.mode & 18) !== 0) {
|
|
810
468
|
throw new Error(`bootstrap output directory ${directory} must be operator-owned and not group/other writable`);
|
|
811
469
|
}
|
|
812
470
|
return directory;
|
|
813
471
|
}
|
|
814
|
-
async function
|
|
472
|
+
async function writeOwnerJson(path, output) {
|
|
815
473
|
const absolute = resolve(path);
|
|
816
474
|
const directory = await prepareOwnerOutputDirectory(absolute);
|
|
817
475
|
const temporary = `${absolute}.${randomUUID()}.tmp`;
|
|
@@ -839,21 +497,35 @@ async function writeOwnerBootstrapOutput(path, output) {
|
|
|
839
497
|
});
|
|
840
498
|
}
|
|
841
499
|
}
|
|
500
|
+
async function writeOwnerBootstrapOutput(path, output) {
|
|
501
|
+
await writeOwnerJson(path, output);
|
|
502
|
+
}
|
|
503
|
+
async function writeCloudflareHostHandoffs(checkpointPath, output) {
|
|
504
|
+
for (const node of output.resources.nodes) {
|
|
505
|
+
const handoff = {
|
|
506
|
+
format: 1,
|
|
507
|
+
kind: "forgezero-cloudflare-host-handoff",
|
|
508
|
+
nodeName: node.nodeName,
|
|
509
|
+
hostname: node.hostname,
|
|
510
|
+
service: node.service,
|
|
511
|
+
tunnelId: node.tunnelId,
|
|
512
|
+
connectorToken: node.connectorToken,
|
|
513
|
+
accountId: output.coordinates.accountId,
|
|
514
|
+
zoneId: output.coordinates.zoneId,
|
|
515
|
+
kvNamespaceId: output.resources.kvNamespaceId,
|
|
516
|
+
kvRuntimeToken: output.resources.kvRuntimeToken
|
|
517
|
+
};
|
|
518
|
+
await writeOwnerJson(cloudflareHostHandoffPath(checkpointPath, node.nodeName), handoff);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
842
521
|
var tokenFor = (tokens, key) => {
|
|
843
522
|
const token = tokens[key]?.trim() || tokens.apiToken?.trim();
|
|
844
|
-
if (!token)
|
|
523
|
+
if (!token || !TOKEN.test(token))
|
|
845
524
|
throw new Error(`Cloudflare ${key} is not configured`);
|
|
846
525
|
return token;
|
|
847
526
|
};
|
|
848
|
-
var initialManagementToken = (tokens) => {
|
|
849
|
-
const token = tokens.apiToken?.trim();
|
|
850
|
-
if (!token) {
|
|
851
|
-
throw new Error("initial Cloudflare --token-file is required to create account-owned runtime tokens");
|
|
852
|
-
}
|
|
853
|
-
return token;
|
|
854
|
-
};
|
|
855
527
|
var sameCoordinates = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
856
|
-
async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fetch
|
|
528
|
+
async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fetch) {
|
|
857
529
|
const coordinates = validateCloudflareBootstrapCoordinates(input);
|
|
858
530
|
const absoluteOutput = resolve(outputPath);
|
|
859
531
|
const existing = await readExistingOutput(absoluteOutput);
|
|
@@ -861,19 +533,18 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
861
533
|
throw new Error("bootstrap output belongs to different Cloudflare coordinates; choose a different output file");
|
|
862
534
|
}
|
|
863
535
|
await prepareOwnerOutputDirectory(absoluteOutput);
|
|
864
|
-
const
|
|
865
|
-
accountId: coordinates.accountId,
|
|
866
|
-
title: coordinates.kvNamespaceTitle,
|
|
867
|
-
apiToken: tokenFor(tokens, "kvApiToken")
|
|
868
|
-
}, fetcher);
|
|
536
|
+
const kvRuntimeToken = tokenFor(tokens, "kvApiToken");
|
|
869
537
|
const nodeResources = [];
|
|
870
538
|
const createdNodes = [];
|
|
871
|
-
let resources;
|
|
872
539
|
for (const node of coordinates.nodes) {
|
|
873
540
|
const checkpointed = existing?.resources.nodes?.find(({ nodeName }) => nodeName === node.nodeName);
|
|
541
|
+
let resource;
|
|
874
542
|
let created = false;
|
|
875
543
|
if (checkpointed) {
|
|
876
|
-
|
|
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;
|
|
877
548
|
} else {
|
|
878
549
|
const tunnel = await ensureCloudflareTunnel({
|
|
879
550
|
accountId: coordinates.accountId,
|
|
@@ -881,234 +552,29 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
881
552
|
apiToken: tokenFor(tokens, "tunnelApiToken")
|
|
882
553
|
}, fetcher);
|
|
883
554
|
created = tunnel.created;
|
|
884
|
-
|
|
885
|
-
nodeName: node.nodeName,
|
|
886
|
-
hostname: node.hostname,
|
|
887
|
-
service: node.service,
|
|
888
|
-
tunnelName: node.tunnelName,
|
|
889
|
-
tunnelId: tunnel.tunnel.id,
|
|
890
|
-
connectorToken: tunnel.connectorToken
|
|
891
|
-
});
|
|
892
|
-
}
|
|
893
|
-
createdNodes.push({ nodeName: node.nodeName, tunnel: created, application: false });
|
|
894
|
-
const firstNode = nodeResources[0];
|
|
895
|
-
resources = {
|
|
896
|
-
tunnelId: firstNode.tunnelId,
|
|
897
|
-
kvNamespaceId: namespace.namespace.id,
|
|
898
|
-
hostname: firstNode.hostname,
|
|
899
|
-
service: firstNode.service,
|
|
900
|
-
connectorToken: firstNode.connectorToken,
|
|
901
|
-
nodes: [...nodeResources],
|
|
902
|
-
...existing?.resources.access ? { access: existing.resources.access } : {},
|
|
903
|
-
...existing?.resources.runtimeTokens ? { runtimeTokens: existing.resources.runtimeTokens } : {},
|
|
904
|
-
...existing?.resources.worker ? { worker: existing.resources.worker } : {},
|
|
905
|
-
...existing?.resources.privateNetwork ? { privateNetwork: existing.resources.privateNetwork } : {}
|
|
906
|
-
};
|
|
907
|
-
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
908
|
-
format: 1,
|
|
909
|
-
kind: "forgezero-cloudflare-bootstrap",
|
|
910
|
-
phase: resources.access ? "access-token-provisioned" : "edge-resources-provisioned",
|
|
911
|
-
updatedAt: new Date().toISOString(),
|
|
912
|
-
coordinates,
|
|
913
|
-
resources
|
|
914
|
-
});
|
|
915
|
-
}
|
|
916
|
-
if (!resources)
|
|
917
|
-
throw new Error("Cloudflare fleet has no nodes");
|
|
918
|
-
if (coordinates.createRuntimeTokens && !resources.runtimeTokens) {
|
|
919
|
-
resources = {
|
|
920
|
-
...resources,
|
|
921
|
-
runtimeTokens: { kv: await createCloudflareAccountRuntimeToken({
|
|
922
|
-
accountId: coordinates.accountId,
|
|
923
|
-
name: `${coordinates.runtimeTokenNamePrefix}-kv-runtime`,
|
|
924
|
-
permissionNames: ["Workers KV Storage Write"],
|
|
925
|
-
apiToken: initialManagementToken(tokens)
|
|
926
|
-
}, fetcher) }
|
|
927
|
-
};
|
|
928
|
-
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
929
|
-
format: 1,
|
|
930
|
-
kind: "forgezero-cloudflare-bootstrap",
|
|
931
|
-
phase: "runtime-tokens-created",
|
|
932
|
-
updatedAt: new Date().toISOString(),
|
|
933
|
-
coordinates,
|
|
934
|
-
resources
|
|
935
|
-
});
|
|
936
|
-
}
|
|
937
|
-
if (coordinates.createPrivateNetworkRuntimeToken && resources.runtimeTokens && !resources.runtimeTokens.privateNetwork) {
|
|
938
|
-
resources = {
|
|
939
|
-
...resources,
|
|
940
|
-
runtimeTokens: {
|
|
941
|
-
...resources.runtimeTokens,
|
|
942
|
-
privateNetwork: await createCloudflareAccountRuntimeToken({
|
|
943
|
-
accountId: coordinates.accountId,
|
|
944
|
-
name: `${coordinates.runtimeTokenNamePrefix}-private-network-runtime`,
|
|
945
|
-
permissionNames: ["Cloudflare One Networks Write", "Zero Trust Write"],
|
|
946
|
-
apiToken: initialManagementToken(tokens)
|
|
947
|
-
}, fetcher)
|
|
948
|
-
}
|
|
949
|
-
};
|
|
950
|
-
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
951
|
-
format: 1,
|
|
952
|
-
kind: "forgezero-cloudflare-bootstrap",
|
|
953
|
-
phase: "runtime-tokens-created",
|
|
954
|
-
updatedAt: new Date().toISOString(),
|
|
955
|
-
coordinates,
|
|
956
|
-
resources
|
|
957
|
-
});
|
|
958
|
-
}
|
|
959
|
-
if (!resources.worker) {
|
|
960
|
-
await deployCloudflareWorker(coordinates, namespace.namespace.id, tokenFor(tokens, "workerApiToken"), workerRunner);
|
|
961
|
-
resources = {
|
|
962
|
-
...resources,
|
|
963
|
-
worker: {
|
|
964
|
-
scriptName: coordinates.workerScriptName,
|
|
965
|
-
publicDomains: coordinates.publicDomains,
|
|
966
|
-
deployed: true
|
|
967
|
-
}
|
|
968
|
-
};
|
|
969
|
-
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
970
|
-
format: 1,
|
|
971
|
-
kind: "forgezero-cloudflare-bootstrap",
|
|
972
|
-
phase: "worker-deployed",
|
|
973
|
-
updatedAt: new Date().toISOString(),
|
|
974
|
-
coordinates,
|
|
975
|
-
resources
|
|
976
|
-
});
|
|
977
|
-
}
|
|
978
|
-
const serviceToken = await ensureCloudflareAccessServiceToken({
|
|
979
|
-
accountId: coordinates.accountId,
|
|
980
|
-
name: coordinates.serviceTokenName,
|
|
981
|
-
apiToken: tokenFor(tokens, "accessApiToken"),
|
|
982
|
-
existing: resources.access
|
|
983
|
-
}, fetcher);
|
|
984
|
-
resources = { ...resources, access: serviceToken.credentials };
|
|
985
|
-
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
986
|
-
format: 1,
|
|
987
|
-
kind: "forgezero-cloudflare-bootstrap",
|
|
988
|
-
phase: "access-token-provisioned",
|
|
989
|
-
updatedAt: new Date().toISOString(),
|
|
990
|
-
coordinates,
|
|
991
|
-
resources
|
|
992
|
-
});
|
|
993
|
-
const policy = await ensureCloudflareAccessPolicy({
|
|
994
|
-
accountId: coordinates.accountId,
|
|
995
|
-
name: coordinates.policyName,
|
|
996
|
-
serviceTokenId: serviceToken.credentials.tokenId,
|
|
997
|
-
apiToken: tokenFor(tokens, "accessApiToken")
|
|
998
|
-
}, fetcher);
|
|
999
|
-
if (!policy.policy.id)
|
|
1000
|
-
throw new Error("Cloudflare did not return the Access policy id");
|
|
1001
|
-
resources = {
|
|
1002
|
-
...resources,
|
|
1003
|
-
access: { ...serviceToken.credentials, policyId: policy.policy.id }
|
|
1004
|
-
};
|
|
1005
|
-
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
1006
|
-
format: 1,
|
|
1007
|
-
kind: "forgezero-cloudflare-bootstrap",
|
|
1008
|
-
phase: "access-token-provisioned",
|
|
1009
|
-
updatedAt: new Date().toISOString(),
|
|
1010
|
-
coordinates,
|
|
1011
|
-
resources
|
|
1012
|
-
});
|
|
1013
|
-
let privateNetworkCreated = false;
|
|
1014
|
-
if (coordinates.privateNetwork && !resources.privateNetwork) {
|
|
1015
|
-
const managementToken = initialManagementToken(tokens);
|
|
1016
|
-
const virtualNetwork = await ensureCloudflareVirtualNetwork({
|
|
1017
|
-
accountId: coordinates.accountId,
|
|
1018
|
-
name: coordinates.privateNetwork.virtualNetworkName,
|
|
1019
|
-
comment: "ForgeZero private database network",
|
|
1020
|
-
apiToken: managementToken
|
|
1021
|
-
}, fetcher);
|
|
1022
|
-
const enrollment = await ensureCloudflareWarpEnrollmentApplication({
|
|
1023
|
-
accountId: coordinates.accountId,
|
|
1024
|
-
name: coordinates.privateNetwork.enrollmentApplicationName,
|
|
1025
|
-
policyId: policy.policy.id,
|
|
1026
|
-
apiToken: tokenFor(tokens, "accessApiToken")
|
|
1027
|
-
}, fetcher);
|
|
1028
|
-
const deviceProfile = await ensureCloudflareWarpDevicePolicy({
|
|
1029
|
-
accountId: coordinates.accountId,
|
|
1030
|
-
name: coordinates.privateNetwork.deviceProfileName,
|
|
1031
|
-
serviceTokenId: serviceToken.credentials.tokenId,
|
|
1032
|
-
virtualNetworkId: virtualNetwork.virtualNetwork.id,
|
|
1033
|
-
precedence: coordinates.privateNetwork.deviceProfilePrecedence,
|
|
1034
|
-
apiToken: managementToken
|
|
1035
|
-
}, fetcher);
|
|
1036
|
-
const routes = [];
|
|
1037
|
-
for (const node of coordinates.nodes.filter((item) => item.privateAddress)) {
|
|
1038
|
-
const resource = resources.nodes.find((item) => item.nodeName === node.nodeName);
|
|
1039
|
-
const route = await ensureCloudflarePrivateDatabaseRoute({
|
|
1040
|
-
accountId: coordinates.accountId,
|
|
1041
|
-
tunnelId: resource.tunnelId,
|
|
1042
|
-
privateAddress: node.privateAddress,
|
|
1043
|
-
virtualNetworkId: virtualNetwork.virtualNetwork.id,
|
|
1044
|
-
comment: `ForgeZero ${node.nodeName} database`,
|
|
1045
|
-
apiToken: managementToken
|
|
1046
|
-
}, fetcher);
|
|
1047
|
-
await ensureCloudflareWarpDatabaseInclude({
|
|
1048
|
-
accountId: coordinates.accountId,
|
|
1049
|
-
policyId: deviceProfile.policy.id,
|
|
1050
|
-
privateAddress: node.privateAddress,
|
|
1051
|
-
description: `ForgeZero ${node.nodeName} database`,
|
|
1052
|
-
apiToken: managementToken
|
|
1053
|
-
}, fetcher);
|
|
1054
|
-
routes.push({ nodeName: node.nodeName, routeId: route.route.id, privateAddress: node.privateAddress });
|
|
555
|
+
resource = { ...node, tunnelId: tunnel.tunnel.id, connectorToken: tunnel.connectorToken };
|
|
1055
556
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
warpOrganization: coordinates.privateNetwork.warpOrganization,
|
|
1060
|
-
virtualNetworkId: virtualNetwork.virtualNetwork.id,
|
|
1061
|
-
deviceProfileId: deviceProfile.policy.id,
|
|
1062
|
-
enrollmentApplicationId: enrollment.application.id,
|
|
1063
|
-
routes
|
|
1064
|
-
}
|
|
1065
|
-
};
|
|
1066
|
-
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];
|
|
1067
560
|
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
1068
561
|
format: 1,
|
|
1069
562
|
kind: "forgezero-cloudflare-bootstrap",
|
|
1070
|
-
phase: "
|
|
563
|
+
phase: "edge-resources-provisioned",
|
|
1071
564
|
updatedAt: new Date().toISOString(),
|
|
1072
565
|
coordinates,
|
|
1073
|
-
resources
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
policyId: policy.policy.id,
|
|
1082
|
-
apiToken: tokenFor(tokens, "accessApiToken")
|
|
1083
|
-
}, fetcher);
|
|
1084
|
-
if (!application.application.id)
|
|
1085
|
-
throw new Error(`Cloudflare did not return the Access application id for ${node.nodeName}`);
|
|
1086
|
-
resources = {
|
|
1087
|
-
...resources,
|
|
1088
|
-
nodes: resources.nodes.map((resource) => resource.nodeName === node.nodeName ? { ...resource, applicationId: application.application.id } : resource),
|
|
1089
|
-
access: {
|
|
1090
|
-
...resources.access,
|
|
1091
|
-
...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]
|
|
1092
574
|
}
|
|
1093
|
-
};
|
|
1094
|
-
const createdNode = createdNodes.find(({ nodeName }) => nodeName === node.nodeName);
|
|
1095
|
-
createdNode.application = application.created;
|
|
1096
|
-
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
1097
|
-
format: 1,
|
|
1098
|
-
kind: "forgezero-cloudflare-bootstrap",
|
|
1099
|
-
phase: "access-token-provisioned",
|
|
1100
|
-
updatedAt: new Date().toISOString(),
|
|
1101
|
-
coordinates,
|
|
1102
|
-
resources
|
|
1103
575
|
});
|
|
1104
576
|
}
|
|
1105
|
-
|
|
1106
|
-
accountId: coordinates.accountId,
|
|
1107
|
-
scriptName: coordinates.workerScriptName,
|
|
1108
|
-
credentials: serviceToken.credentials,
|
|
1109
|
-
apiToken: tokenFor(tokens, "workerApiToken")
|
|
1110
|
-
}, fetcher);
|
|
1111
|
-
for (const node of resources.nodes) {
|
|
577
|
+
for (const node of nodeResources) {
|
|
1112
578
|
await configureCloudflareEdge({
|
|
1113
579
|
accountId: coordinates.accountId,
|
|
1114
580
|
zoneId: coordinates.zoneId,
|
|
@@ -1120,25 +586,29 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
1120
586
|
dnsApiToken: tokenFor(tokens, "dnsApiToken")
|
|
1121
587
|
}, fetcher);
|
|
1122
588
|
}
|
|
589
|
+
const first = nodeResources[0];
|
|
1123
590
|
const output = {
|
|
1124
591
|
format: 1,
|
|
1125
592
|
kind: "forgezero-cloudflare-bootstrap",
|
|
1126
593
|
phase: "complete",
|
|
1127
594
|
updatedAt: new Date().toISOString(),
|
|
1128
595
|
coordinates,
|
|
1129
|
-
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
|
+
},
|
|
1130
605
|
created: {
|
|
1131
606
|
tunnel: createdNodes.some(({ tunnel }) => tunnel),
|
|
1132
|
-
kvNamespace: namespace.created,
|
|
1133
|
-
serviceToken: serviceToken.created,
|
|
1134
|
-
policy: policy.created,
|
|
1135
|
-
application: createdNodes.some(({ application }) => application),
|
|
1136
|
-
privateNetwork: privateNetworkCreated,
|
|
1137
|
-
workerDeployed: true,
|
|
1138
607
|
nodes: createdNodes
|
|
1139
608
|
}
|
|
1140
609
|
};
|
|
1141
610
|
await writeOwnerBootstrapOutput(absoluteOutput, output);
|
|
611
|
+
await writeCloudflareHostHandoffs(absoluteOutput, output);
|
|
1142
612
|
return output;
|
|
1143
613
|
}
|
|
1144
614
|
async function runAttendedCloudflareBootstrap(request, dependencies = {}) {
|
|
@@ -1149,42 +619,70 @@ async function runAttendedCloudflareBootstrap(request, dependencies = {}) {
|
|
|
1149
619
|
kind: "forgezero-cloudflare-bootstrap-evidence",
|
|
1150
620
|
phase: "planned",
|
|
1151
621
|
checkpointFile: plan.outputFile,
|
|
1152
|
-
workerScriptName: plan.coordinates.workerScriptName,
|
|
1153
|
-
publicDomains: plan.coordinates.publicDomains,
|
|
1154
622
|
nodes: plan.coordinates.nodes.map(({ nodeName, hostname }) => ({ nodeName, hostname }))
|
|
1155
623
|
};
|
|
1156
624
|
}
|
|
1157
625
|
if (!request.tokenFiles || !Object.values(request.tokenFiles).some(Boolean)) {
|
|
1158
626
|
throw new Error("Cloudflare apply requires owner-only management token file paths");
|
|
1159
627
|
}
|
|
1160
|
-
if (plan.coordinates.createRuntimeTokens && !request.tokenFiles.apiTokenFile) {
|
|
1161
|
-
throw new Error("Cloudflare runtime-token creation requires the initial management token file");
|
|
1162
|
-
}
|
|
1163
628
|
const tokens = await readCloudflareBootstrapTokens(request.tokenFiles);
|
|
1164
|
-
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);
|
|
1165
630
|
return {
|
|
1166
631
|
format: 1,
|
|
1167
632
|
kind: "forgezero-cloudflare-bootstrap-evidence",
|
|
1168
633
|
phase: "complete",
|
|
1169
634
|
checkpointFile: plan.outputFile,
|
|
1170
635
|
kvNamespaceId: output.resources.kvNamespaceId,
|
|
1171
|
-
|
|
1172
|
-
publicDomains: output.resources.worker?.publicDomains ?? output.coordinates.publicDomains,
|
|
1173
|
-
runtimeTokenIds: {
|
|
1174
|
-
kv: output.resources.runtimeTokens?.kv.id,
|
|
1175
|
-
privateNetwork: output.resources.runtimeTokens?.privateNetwork?.id
|
|
1176
|
-
},
|
|
1177
|
-
nodes: output.resources.nodes.map(({ nodeName, hostname, tunnelId, applicationId }) => ({
|
|
636
|
+
nodes: output.resources.nodes.map(({ nodeName, hostname, tunnelId }) => ({
|
|
1178
637
|
nodeName,
|
|
1179
638
|
hostname,
|
|
1180
|
-
|
|
1181
|
-
|
|
639
|
+
handoffFile: cloudflareHostHandoffPath(plan.outputFile, nodeName),
|
|
640
|
+
tunnelId
|
|
1182
641
|
}))
|
|
1183
642
|
};
|
|
1184
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
|
+
};
|
|
655
|
+
async function verifyCloudflareBootstrapAcceptance(checkpointPath, fetcher = fetch) {
|
|
656
|
+
const absolute = resolve(checkpointPath);
|
|
657
|
+
const output = await readExistingOutput(absolute);
|
|
658
|
+
if (!output || output.phase !== "complete")
|
|
659
|
+
throw new Error("Cloudflare acceptance requires a completed owner checkpoint");
|
|
660
|
+
const coordinates = validateCloudflareBootstrapCoordinates(output.coordinates);
|
|
661
|
+
if (output.resources.nodes.length !== coordinates.nodes.length) {
|
|
662
|
+
throw new Error("Cloudflare acceptance checkpoint does not cover the declared node fleet");
|
|
663
|
+
}
|
|
664
|
+
for (const node of output.resources.nodes) {
|
|
665
|
+
const expected = coordinates.nodes.find((candidate) => candidate.nodeName === node.nodeName);
|
|
666
|
+
if (!expected || node.hostname !== expected.hostname || node.service !== expected.service || node.tunnelName !== expected.tunnelName || !UUID.test(node.tunnelId)) {
|
|
667
|
+
throw new Error("Cloudflare acceptance checkpoint has an unbound node resource");
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const nodes = await Promise.all(output.resources.nodes.map(async ({ nodeName, hostname }) => ({
|
|
671
|
+
nodeName,
|
|
672
|
+
hostname,
|
|
673
|
+
status: await acceptanceFetch(`https://${hostname}/api/health`, `Cloudflare origin ${nodeName}`, fetcher)
|
|
674
|
+
})));
|
|
675
|
+
return {
|
|
676
|
+
format: 1,
|
|
677
|
+
kind: "forgezero-cloudflare-bootstrap-acceptance",
|
|
678
|
+
checkpointFile: absolute,
|
|
679
|
+
verifiedAt: new Date().toISOString(),
|
|
680
|
+
nodes
|
|
681
|
+
};
|
|
682
|
+
}
|
|
1185
683
|
|
|
1186
684
|
// src/bootstrap.ts
|
|
1187
|
-
import { createHmac, randomBytes } from "crypto";
|
|
685
|
+
import { createHash, createHmac, randomBytes } from "crypto";
|
|
1188
686
|
import {
|
|
1189
687
|
chmodSync,
|
|
1190
688
|
existsSync,
|
|
@@ -1214,7 +712,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
|
|
|
1214
712
|
var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
|
|
1215
713
|
|
|
1216
714
|
// src/version.ts
|
|
1217
|
-
var VERSION = "0.1.
|
|
715
|
+
var VERSION = "0.1.40";
|
|
1218
716
|
|
|
1219
717
|
// src/software.ts
|
|
1220
718
|
var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
|
|
@@ -1233,8 +731,8 @@ var UBUNTU_2604_X64 = [
|
|
|
1233
731
|
},
|
|
1234
732
|
{
|
|
1235
733
|
requirement: { id: "arangodb", version: "3.11.14" },
|
|
1236
|
-
check: `arangod --version 2>/dev/null | head -1 | grep -q '3.11.14'`,
|
|
1237
|
-
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`
|
|
1238
736
|
},
|
|
1239
737
|
{
|
|
1240
738
|
requirement: { id: "cloudflared", version: "2026.7.3" },
|
|
@@ -1320,7 +818,7 @@ function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
|
|
|
1320
818
|
}
|
|
1321
819
|
|
|
1322
820
|
// src/provision.ts
|
|
1323
|
-
import { isIP as
|
|
821
|
+
import { isIP as isIP2 } from "net";
|
|
1324
822
|
function atLeast(version, floor) {
|
|
1325
823
|
const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
|
|
1326
824
|
const got = parse(version);
|
|
@@ -1777,7 +1275,7 @@ function agentUnit(options) {
|
|
|
1777
1275
|
} catch {
|
|
1778
1276
|
throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL");
|
|
1779
1277
|
}
|
|
1780
|
-
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"))
|
|
1781
1279
|
throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL without credentials, query or fragment");
|
|
1782
1280
|
telemetryEndpoint = endpoint2.toString().replace(/\/$/, "");
|
|
1783
1281
|
}
|
|
@@ -2458,6 +1956,8 @@ function renderPlatformApiUnits(input) {
|
|
|
2458
1956
|
throw new Error("Blue and green ports must differ.");
|
|
2459
1957
|
const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
|
|
2460
1958
|
`);
|
|
1959
|
+
const capacityEnvironment = input.capacityEnvironmentFile ? `EnvironmentFile=-${input.capacityEnvironmentFile}
|
|
1960
|
+
` : "";
|
|
2461
1961
|
const template = `[Unit]
|
|
2462
1962
|
Description=ForgeZero (%i slot)
|
|
2463
1963
|
After=network-online.target ${input.collectorUnit}
|
|
@@ -2470,7 +1970,7 @@ WorkingDirectory=${input.slotsDirectory}/%i
|
|
|
2470
1970
|
Environment=NODE_ENV=production
|
|
2471
1971
|
Environment=FZ_SLOT=%i
|
|
2472
1972
|
EnvironmentFile=${input.sharedEnvironmentFile}
|
|
2473
|
-
${credentials}
|
|
1973
|
+
${capacityEnvironment}${credentials}
|
|
2474
1974
|
ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
|
|
2475
1975
|
Restart=always
|
|
2476
1976
|
RestartSec=2
|
|
@@ -2506,16 +2006,24 @@ Environment=PORT=${input.greenPort}
|
|
|
2506
2006
|
function renderPlatformNginx(input) {
|
|
2507
2007
|
boundedInteger("publicPort", input.publicPort, 1024, 65535);
|
|
2508
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);
|
|
2509
2011
|
if (input.publicPort === input.initialSlotPort)
|
|
2510
2012
|
throw new Error("Edge and slot ports must differ.");
|
|
2511
2013
|
return {
|
|
2512
2014
|
upstream: `upstream forgezero { server 127.0.0.1:${input.initialSlotPort}; }
|
|
2513
2015
|
`,
|
|
2514
|
-
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 {
|
|
2515
2020
|
listen 127.0.0.1:${input.publicPort};
|
|
2516
2021
|
server_name _;
|
|
2517
|
-
|
|
2518
|
-
|
|
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; }
|
|
2519
2027
|
location / { return 404; }
|
|
2520
2028
|
}
|
|
2521
2029
|
`
|
|
@@ -2531,6 +2039,7 @@ function renderPlatformActivationFiles(input) {
|
|
|
2531
2039
|
if (input.bluePort === input.greenPort)
|
|
2532
2040
|
throw new Error("Activation slot ports must differ.");
|
|
2533
2041
|
boundedInteger("keepReleases", input.keepReleases, 2, 100);
|
|
2042
|
+
const drainDeadlineMs = boundedInteger("drainDeadlineMs", input.drainDeadlineMs ?? 35000, 1000, 300000);
|
|
2534
2043
|
if (!/^\/[A-Za-z0-9/_-]{1,128}$/.test(input.healthPath) || input.healthPath.includes("..")) {
|
|
2535
2044
|
throw new Error("Activation health path is malformed.");
|
|
2536
2045
|
}
|
|
@@ -2540,7 +2049,8 @@ function renderPlatformActivationFiles(input) {
|
|
|
2540
2049
|
`FZ_BLUE_PORT=${input.bluePort}`,
|
|
2541
2050
|
`FZ_GREEN_PORT=${input.greenPort}`,
|
|
2542
2051
|
`FZ_HEALTH_PATH=${input.healthPath}`,
|
|
2543
|
-
`FZ_KEEP_RELEASES=${input.keepReleases}
|
|
2052
|
+
`FZ_KEEP_RELEASES=${input.keepReleases}`,
|
|
2053
|
+
`FZ_DRAIN_DEADLINE_MS=${drainDeadlineMs}`
|
|
2544
2054
|
].join(`
|
|
2545
2055
|
`) + `
|
|
2546
2056
|
`;
|
|
@@ -2562,7 +2072,14 @@ if (( ! healthy )); then systemctl stop "forgezero@\${target}.service" || true;
|
|
|
2562
2072
|
upstream=/etc/nginx/conf.d/forgezero-upstream.conf; backup="$(mktemp -p /run forgezero-upstream.XXXXXX)"; [[ -f "$upstream" ]] && cp "$upstream" "$backup" || : >"$backup"
|
|
2563
2073
|
printf 'upstream forgezero { server 127.0.0.1:%s; }\\n' "$port" >"$upstream"
|
|
2564
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
|
|
2565
|
-
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
|
|
2566
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-)
|
|
2567
2084
|
for path in "\${old[@]}"; do [[ "$path" == "$release" ]] || rm -rf -- "$path"; done
|
|
2568
2085
|
printf 'promoted %s on %s\\n' "$release" "$target"
|
|
@@ -2617,7 +2134,15 @@ var PLATFORM_BOOTSTRAP_PROFILES = [
|
|
|
2617
2134
|
"platform-db-api",
|
|
2618
2135
|
"platform-api"
|
|
2619
2136
|
];
|
|
2620
|
-
|
|
2137
|
+
function resolveInstalledBootstrapKind(states) {
|
|
2138
|
+
if (states.compute && states.metal) {
|
|
2139
|
+
throw new Error("host has both metal and compute bootstrap state; refusing an ambiguous operation");
|
|
2140
|
+
}
|
|
2141
|
+
return states.metal ? "metal" : states.compute ? "compute" : undefined;
|
|
2142
|
+
}
|
|
2143
|
+
var BOOTSTRAP_STATE_PATH = "/var/lib/forgezero/bootstrap.json";
|
|
2144
|
+
var STATE_PATH = BOOTSTRAP_STATE_PATH;
|
|
2145
|
+
var INTENT_PATH = "/var/lib/forgezero/bootstrap.intent.json";
|
|
2621
2146
|
var CREDS = "/etc/forgezero/creds";
|
|
2622
2147
|
var JWT_CREDENTIAL = `${CREDS}/arangodb-jwt.cred`;
|
|
2623
2148
|
var ENROL_CREDENTIAL = `${CREDS}/enrol-token.cred`;
|
|
@@ -2627,9 +2152,13 @@ var BACKUP_RECOVERY_CREDENTIAL = `${CREDS}/backup-recovery-root.cred`;
|
|
|
2627
2152
|
var WARP_CLIENT_ID_CREDENTIAL = `${CREDS}/warp-auth-client-id.cred`;
|
|
2628
2153
|
var WARP_CLIENT_SECRET_CREDENTIAL = `${CREDS}/warp-auth-client-secret.cred`;
|
|
2629
2154
|
var BOOTSTRAP_SSH_CREDENTIAL = `${CREDS}/bootstrap-ssh-key.cred`;
|
|
2155
|
+
var GIT_PUBLIC_KEY = "/etc/forgezero/git/deploy.pub";
|
|
2630
2156
|
var PLATFORM_ENROL_SOURCE = "/run/forgezero-platform-enrol-token";
|
|
2631
2157
|
var DB_MODE_EVIDENCE = "/var/lib/forgezero-cluster/server-mode.json";
|
|
2632
2158
|
var LIFECYCLE_PROFILE = "/etc/forgezero/lifecycle.json";
|
|
2159
|
+
var CONTROL_SOCKET = "/run/forgezero/control.sock";
|
|
2160
|
+
var CLOUDFLARED_METRICS_ADDRESS = "127.0.0.1:20241";
|
|
2161
|
+
var CLOUDFLARED_DIAGNOSTICS_URL = `http://${CLOUDFLARED_METRICS_ADDRESS}/diag/tunnel`;
|
|
2633
2162
|
var PACKAGED_AGENT_BIN = fileURLToPath(new URL("./fz-agent.js", import.meta.url));
|
|
2634
2163
|
var privateOrigin = (value) => {
|
|
2635
2164
|
let url;
|
|
@@ -2681,6 +2210,18 @@ function validateBootstrapConfig(value) {
|
|
|
2681
2210
|
if (telemetry.protocol !== "https:" || telemetry.port && telemetry.port !== "443" || telemetry.username || telemetry.password || telemetry.search || telemetry.hash) {
|
|
2682
2211
|
throw new Error("telemetry endpoint must be public HTTPS on port 443 without credentials, query or fragment");
|
|
2683
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
|
+
}
|
|
2684
2225
|
if (value.kind === "tenant") {
|
|
2685
2226
|
if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(value.realm))
|
|
2686
2227
|
throw new Error("tenant realm is malformed");
|
|
@@ -2722,12 +2263,16 @@ function validateBootstrapConfig(value) {
|
|
|
2722
2263
|
if (expected.role && value.database.role !== expected.role || value.database.serverMode !== expected.mode || value.profile === "platform-db-api" && !["master", "joiner"].includes(value.database.role)) {
|
|
2723
2264
|
throw new Error("platform profile, database role and Coordinator mode disagree");
|
|
2724
2265
|
}
|
|
2266
|
+
if (!["member", "none"].includes(value.database.agency) || value.database.role === "master" && value.database.agency !== "member" || value.database.role === "none" && value.database.agency !== "none") {
|
|
2267
|
+
throw new Error("database role and Agency participation disagree");
|
|
2268
|
+
}
|
|
2725
2269
|
if (value.database.role !== "none" && !value.database.address)
|
|
2726
2270
|
throw new Error("database nodes require a private address");
|
|
2727
2271
|
if (value.database.role === "joiner" && !value.database.master)
|
|
2728
2272
|
throw new Error("database joiners require the master starter address");
|
|
2729
|
-
if (!/^
|
|
2273
|
+
if (!/^[a-z0-9](?:[a-z0-9:_-]{0,126}[a-z0-9])?$/.test(value.computeReference)) {
|
|
2730
2274
|
throw new Error("platform compute reference is malformed");
|
|
2275
|
+
}
|
|
2731
2276
|
if (!value.database.bootstrapSecretFile)
|
|
2732
2277
|
throw new Error("platform bootstrap requires the shared cluster bootstrap-code file");
|
|
2733
2278
|
const write = value.database.coordinators.map(privateOrigin);
|
|
@@ -2753,11 +2298,8 @@ function validateBootstrapConfig(value) {
|
|
|
2753
2298
|
if (value.firewall.enabled && (value.firewall.privateCidrs.length < 1 || new Set(value.firewall.privateCidrs).size !== value.firewall.privateCidrs.length)) {
|
|
2754
2299
|
throw new Error("enabled firewall requires unique private cluster CIDRs");
|
|
2755
2300
|
}
|
|
2756
|
-
if (
|
|
2757
|
-
throw new Error("
|
|
2758
|
-
}
|
|
2759
|
-
if (value.cloudflareHandoff && (!value.installCloudflared || !value.cloudflareHandoff.checkpointFile || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(value.cloudflareHandoff.nodeName))) {
|
|
2760
|
-
throw new Error("Cloudflare handoff requires cloudflared installation, a checkpoint and a valid node name");
|
|
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) {
|
|
2302
|
+
throw new Error("platform enrolment source and token file disagree");
|
|
2761
2303
|
}
|
|
2762
2304
|
value.runtime.environment = runtime;
|
|
2763
2305
|
return value;
|
|
@@ -2812,8 +2354,9 @@ var unitEscape = (value) => {
|
|
|
2812
2354
|
function databaseUnit(config) {
|
|
2813
2355
|
const db = config.database;
|
|
2814
2356
|
const join2 = db.role === "joiner" ? ` --starter.join=${unitEscape(db.master)}` : "";
|
|
2357
|
+
const agency = db.agency === "none" ? " --cluster.start-agent=false --cluster.start-coordinator=true --cluster.start-dbserver=true" : "";
|
|
2815
2358
|
return `[Unit]
|
|
2816
|
-
Description=ForgeZero ArangoDB Community 3.11.14 cluster (${db.role})
|
|
2359
|
+
Description=ForgeZero ArangoDB Community 3.11.14 cluster (${db.role}; agency=${db.agency})
|
|
2817
2360
|
After=network-online.target
|
|
2818
2361
|
Wants=network-online.target
|
|
2819
2362
|
|
|
@@ -2822,7 +2365,7 @@ Type=simple
|
|
|
2822
2365
|
User=arangodb
|
|
2823
2366
|
Group=arangodb
|
|
2824
2367
|
LoadCredentialEncrypted=arangodb-jwt:${JWT_CREDENTIAL}
|
|
2825
|
-
ExecStart=/usr/bin/arangodb --starter.mode=cluster --starter.address=${unitEscape(db.address)} --starter.host=${unitEscape(db.address)} --starter.data-dir=/var/lib/forgezero-cluster --auth.jwt-secret=%d/arangodb-jwt${join2}
|
|
2368
|
+
ExecStart=/usr/bin/arangodb --starter.mode=cluster --starter.address=${unitEscape(db.address)} --starter.host=${unitEscape(db.address)} --starter.data-dir=/var/lib/forgezero-cluster --auth.jwt-secret=%d/arangodb-jwt${join2}${agency}
|
|
2826
2369
|
Restart=always
|
|
2827
2370
|
RestartSec=5
|
|
2828
2371
|
UMask=0077
|
|
@@ -2878,7 +2421,7 @@ Wants=network-online.target
|
|
|
2878
2421
|
Type=simple
|
|
2879
2422
|
DynamicUser=yes
|
|
2880
2423
|
LoadCredentialEncrypted=cloudflared-token:${TUNNEL_CREDENTIAL}
|
|
2881
|
-
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate run --token-file %d/cloudflared-token
|
|
2424
|
+
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate --metrics ${CLOUDFLARED_METRICS_ADDRESS} run --token-file %d/cloudflared-token
|
|
2882
2425
|
Restart=always
|
|
2883
2426
|
RestartSec=5
|
|
2884
2427
|
NoNewPrivileges=true
|
|
@@ -2890,6 +2433,60 @@ ProtectHome=true
|
|
|
2890
2433
|
WantedBy=multi-user.target
|
|
2891
2434
|
`;
|
|
2892
2435
|
}
|
|
2436
|
+
function parseCloudflaredTunnelDiagnostics(raw, expectedTunnelId) {
|
|
2437
|
+
if (raw.length > 64 * 1024)
|
|
2438
|
+
throw new Error("cloudflared tunnel diagnostics exceed 64 KiB");
|
|
2439
|
+
let value;
|
|
2440
|
+
try {
|
|
2441
|
+
value = JSON.parse(raw);
|
|
2442
|
+
} catch {
|
|
2443
|
+
throw new Error("cloudflared tunnel diagnostics are not JSON");
|
|
2444
|
+
}
|
|
2445
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
2446
|
+
throw new Error("cloudflared tunnel diagnostics are malformed");
|
|
2447
|
+
}
|
|
2448
|
+
const diagnostics = value;
|
|
2449
|
+
if (diagnostics.tunnelID !== expectedTunnelId)
|
|
2450
|
+
throw new Error("cloudflared is connected to the wrong tunnel");
|
|
2451
|
+
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(diagnostics.connectorID ?? "")) {
|
|
2452
|
+
throw new Error("cloudflared connector identity is missing");
|
|
2453
|
+
}
|
|
2454
|
+
if (!Array.isArray(diagnostics.connections) || diagnostics.connections.length !== 4 || diagnostics.connections.some((connection) => !connection || typeof connection !== "object" || connection.isConnected !== true)) {
|
|
2455
|
+
throw new Error("cloudflared does not have four connected edge sessions");
|
|
2456
|
+
}
|
|
2457
|
+
return diagnostics;
|
|
2458
|
+
}
|
|
2459
|
+
async function inspectCloudflaredTunnel(host, expectedTunnelId) {
|
|
2460
|
+
const result = await host.exec([
|
|
2461
|
+
"curl",
|
|
2462
|
+
"--fail",
|
|
2463
|
+
"--silent",
|
|
2464
|
+
"--show-error",
|
|
2465
|
+
"--max-time",
|
|
2466
|
+
"5",
|
|
2467
|
+
CLOUDFLARED_DIAGNOSTICS_URL
|
|
2468
|
+
]);
|
|
2469
|
+
if (result.exitCode !== 0)
|
|
2470
|
+
return { healthy: false, problem: "cloudflared diagnostics endpoint is unreachable" };
|
|
2471
|
+
try {
|
|
2472
|
+
parseCloudflaredTunnelDiagnostics(result.output, expectedTunnelId);
|
|
2473
|
+
return { healthy: true };
|
|
2474
|
+
} catch (cause) {
|
|
2475
|
+
return { healthy: false, problem: cause.message };
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
async function waitForCloudflaredTunnel(host, expectedTunnelId) {
|
|
2479
|
+
let lastProblem = "cloudflared tunnel is not ready";
|
|
2480
|
+
for (let attempt = 0;attempt < 30; attempt += 1) {
|
|
2481
|
+
const evidence = await inspectCloudflaredTunnel(host, expectedTunnelId);
|
|
2482
|
+
if (evidence.healthy)
|
|
2483
|
+
return;
|
|
2484
|
+
lastProblem = evidence.problem;
|
|
2485
|
+
if (attempt < 29)
|
|
2486
|
+
await (host.sleep?.(1000) ?? Bun.sleep(1000));
|
|
2487
|
+
}
|
|
2488
|
+
throw new Error(`cloudflared connector readiness failed: ${lastProblem}`);
|
|
2489
|
+
}
|
|
2893
2490
|
var derive = (root, label) => {
|
|
2894
2491
|
if (!/^[a-f0-9]{64}$/i.test(root))
|
|
2895
2492
|
throw new Error("cluster bootstrap code must contain exactly 64 hexadecimal characters");
|
|
@@ -2902,23 +2499,163 @@ async function seal(host, name, destination, value) {
|
|
|
2902
2499
|
if (result.exitCode !== 0)
|
|
2903
2500
|
throw new Error(`could not seal ${name}: ${result.output.trim()}`);
|
|
2904
2501
|
}
|
|
2905
|
-
function
|
|
2502
|
+
function bootstrapIdentity(config) {
|
|
2503
|
+
if (config.kind === "tenant")
|
|
2504
|
+
return {
|
|
2505
|
+
kind: config.kind,
|
|
2506
|
+
apiUrl: config.apiUrl,
|
|
2507
|
+
realm: config.realm,
|
|
2508
|
+
nodeHostname: config.nodeHostname,
|
|
2509
|
+
telemetryEndpoint: config.telemetryEndpoint,
|
|
2510
|
+
repository: config.repository ?? null,
|
|
2511
|
+
branch: config.branch ?? null,
|
|
2512
|
+
profile: config.profile ?? "tenant-managed",
|
|
2513
|
+
deployRoot: config.deployRoot ?? "/opt/forgezero",
|
|
2514
|
+
software: (config.software ?? []).map(({ id, version }) => ({ id, version })),
|
|
2515
|
+
installCloudflared: Boolean(config.installCloudflared),
|
|
2516
|
+
bootstrapRunner: config.bootstrapRunner ? { targetTelemetryEndpoint: config.bootstrapRunner.targetTelemetryEndpoint } : null
|
|
2517
|
+
};
|
|
2518
|
+
const environment = config.runtime.environment;
|
|
2519
|
+
return {
|
|
2520
|
+
kind: config.kind,
|
|
2521
|
+
environment: config.environment,
|
|
2522
|
+
profile: config.profile,
|
|
2523
|
+
computeReference: config.computeReference,
|
|
2524
|
+
nodeHostname: config.nodeHostname,
|
|
2525
|
+
apiUrl: config.apiUrl,
|
|
2526
|
+
repository: config.repository,
|
|
2527
|
+
branch: config.branch,
|
|
2528
|
+
deployRoot: config.deployRoot ?? "/opt/forgezero",
|
|
2529
|
+
telemetryEndpoint: config.telemetryEndpoint,
|
|
2530
|
+
database: {
|
|
2531
|
+
role: config.database.role,
|
|
2532
|
+
serverMode: config.database.serverMode,
|
|
2533
|
+
agency: config.database.agency,
|
|
2534
|
+
address: config.database.address ?? null,
|
|
2535
|
+
master: config.database.master ?? null,
|
|
2536
|
+
coordinators: config.database.coordinators
|
|
2537
|
+
},
|
|
2538
|
+
enrolment: { source: config.enrolment.source },
|
|
2539
|
+
runtime: {
|
|
2540
|
+
serviceUser: config.runtime.serviceUser,
|
|
2541
|
+
sharedDirectory: environment.sharedDirectory,
|
|
2542
|
+
slotsDirectory: config.runtime.slotsDirectory,
|
|
2543
|
+
bluePort: config.runtime.bluePort,
|
|
2544
|
+
greenPort: config.runtime.greenPort,
|
|
2545
|
+
publicApiPort: environment.publicApiPort,
|
|
2546
|
+
healthPath: config.runtime.healthPath,
|
|
2547
|
+
keepReleases: config.runtime.keepReleases,
|
|
2548
|
+
environment
|
|
2549
|
+
},
|
|
2550
|
+
firewall: config.firewall,
|
|
2551
|
+
installCloudflared: Boolean(config.installCloudflared)
|
|
2552
|
+
};
|
|
2553
|
+
}
|
|
2554
|
+
function bootstrapIdentityDigest(config) {
|
|
2555
|
+
return createHash("sha256").update(JSON.stringify(bootstrapIdentity(config))).digest("hex");
|
|
2556
|
+
}
|
|
2557
|
+
function parseStoredState(raw) {
|
|
2558
|
+
let value;
|
|
2559
|
+
try {
|
|
2560
|
+
value = JSON.parse(raw);
|
|
2561
|
+
} catch {
|
|
2562
|
+
throw new Error("bootstrap state is malformed");
|
|
2563
|
+
}
|
|
2564
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
2565
|
+
throw new Error("bootstrap state is malformed");
|
|
2566
|
+
const state = value;
|
|
2567
|
+
if (state.format !== 2 || !["platform", "tenant"].includes(state.kind ?? "") || !/^[a-f0-9]{64}$/.test(state.identityDigest ?? "") || typeof state.profile !== "string" || typeof state.nodeHostname !== "string" || typeof state.apiUrl !== "string" || state.kind === "platform" && !["member", "none"].includes(state.databaseAgency ?? "")) {
|
|
2568
|
+
throw new Error("bootstrap state is legacy or incomplete; refusing an unbound repair");
|
|
2569
|
+
}
|
|
2570
|
+
return state;
|
|
2571
|
+
}
|
|
2572
|
+
function parseStoredIntent(raw) {
|
|
2573
|
+
let value;
|
|
2574
|
+
try {
|
|
2575
|
+
value = JSON.parse(raw);
|
|
2576
|
+
} catch {
|
|
2577
|
+
throw new Error("bootstrap intent is malformed");
|
|
2578
|
+
}
|
|
2579
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
2580
|
+
throw new Error("bootstrap intent is malformed");
|
|
2581
|
+
const intent = value;
|
|
2582
|
+
if (intent.format !== 1 || !["platform", "tenant"].includes(intent.kind ?? "") || !/^[a-f0-9]{64}$/.test(intent.identityDigest ?? "") || Number.isNaN(Date.parse(intent.createdAt ?? ""))) {
|
|
2583
|
+
throw new Error("bootstrap intent is incomplete; refusing an unbound resume");
|
|
2584
|
+
}
|
|
2585
|
+
return intent;
|
|
2586
|
+
}
|
|
2587
|
+
function bindBootstrapIntent(host, config) {
|
|
2588
|
+
const identityDigest = bootstrapIdentityDigest(config);
|
|
2589
|
+
if (host.exists(INTENT_PATH)) {
|
|
2590
|
+
const intent = parseStoredIntent(host.read(INTENT_PATH));
|
|
2591
|
+
if (intent.kind !== config.kind || intent.identityDigest !== identityDigest) {
|
|
2592
|
+
throw new Error("bootstrap resume coordinates do not match the interrupted host intent");
|
|
2593
|
+
}
|
|
2594
|
+
} else if (!host.exists(STATE_PATH)) {
|
|
2595
|
+
host.write(INTENT_PATH, `${JSON.stringify({
|
|
2596
|
+
format: 1,
|
|
2597
|
+
kind: config.kind,
|
|
2598
|
+
identityDigest,
|
|
2599
|
+
createdAt: new Date().toISOString()
|
|
2600
|
+
}, null, 2)}
|
|
2601
|
+
`, 384);
|
|
2602
|
+
}
|
|
2603
|
+
return identityDigest;
|
|
2604
|
+
}
|
|
2605
|
+
async function preparePlatformBootstrap(input, host = localBootstrapHost()) {
|
|
2606
|
+
const config = validateBootstrapConfig(structuredClone(input));
|
|
2607
|
+
if (config.kind !== "platform")
|
|
2608
|
+
throw new Error("platform preparation requires a platform bootstrap config");
|
|
2609
|
+
if (host.uid() !== 0)
|
|
2610
|
+
throw new Error("fz bootstrap platform prepare --apply must run as root");
|
|
2611
|
+
if (host.exists(STATE_PATH)) {
|
|
2612
|
+
const installed = parseStoredState(host.read(STATE_PATH));
|
|
2613
|
+
if (installed.kind !== "platform" || installed.identityDigest !== bootstrapIdentityDigest(config)) {
|
|
2614
|
+
throw new Error("platform preparation coordinates do not match the installed host identity");
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
const identityDigest = bindBootstrapIntent(host, config);
|
|
2618
|
+
await host.installAgent(config);
|
|
2619
|
+
if (!host.exists(GIT_PUBLIC_KEY))
|
|
2620
|
+
throw new Error("Agent installation did not produce its public Git deploy key");
|
|
2621
|
+
const gitPublicKey = host.read(GIT_PUBLIC_KEY).trim();
|
|
2622
|
+
if (!/^ssh-(?:ed25519|rsa) [A-Za-z0-9+/]+={0,3}(?: [^\r\n]+)?$/.test(gitPublicKey)) {
|
|
2623
|
+
throw new Error("Agent public Git deploy key is malformed");
|
|
2624
|
+
}
|
|
2625
|
+
return {
|
|
2626
|
+
kind: "platform",
|
|
2627
|
+
prepared: true,
|
|
2628
|
+
identityDigest,
|
|
2629
|
+
gitPublicKey,
|
|
2630
|
+
next: "register this read-only deploy key, then run --apply concurrently on all three genesis Agency members"
|
|
2631
|
+
};
|
|
2632
|
+
}
|
|
2633
|
+
function stateFor(config, cloudflare, previousCloudflareTunnelId) {
|
|
2906
2634
|
return `${JSON.stringify({
|
|
2907
|
-
format:
|
|
2635
|
+
format: 2,
|
|
2908
2636
|
kind: config.kind,
|
|
2909
2637
|
profile: config.kind === "platform" ? config.profile : config.profile ?? "tenant-managed",
|
|
2910
2638
|
nodeHostname: config.nodeHostname,
|
|
2911
2639
|
apiUrl: config.apiUrl,
|
|
2640
|
+
identityDigest: bootstrapIdentityDigest(config),
|
|
2912
2641
|
...config.kind === "platform" ? {
|
|
2913
2642
|
environment: config.environment,
|
|
2914
2643
|
databaseRole: config.database.role,
|
|
2644
|
+
databaseAgency: config.database.agency,
|
|
2915
2645
|
databaseServerMode: config.database.serverMode,
|
|
2916
2646
|
databaseAddress: config.database.address,
|
|
2917
2647
|
databaseCoordinators: config.database.coordinators,
|
|
2918
2648
|
databaseModeEvidence: config.database.role === "none" ? undefined : DB_MODE_EVIDENCE,
|
|
2919
2649
|
collectorUnit: config.runtime.environment.otlpCollectorUnit,
|
|
2650
|
+
publicApiPort: config.runtime.environment.publicApiPort,
|
|
2651
|
+
healthPath: config.runtime.healthPath,
|
|
2652
|
+
cloudflare: config.runtime.environment.cloudflare,
|
|
2920
2653
|
cloudflared: Boolean(config.cloudflareHandoff)
|
|
2921
|
-
} : {
|
|
2654
|
+
} : {
|
|
2655
|
+
realm: config.realm,
|
|
2656
|
+
cloudflared: Boolean(config.cloudflareHandoff),
|
|
2657
|
+
cloudflareTunnelId: cloudflare?.tunnelId ?? previousCloudflareTunnelId
|
|
2658
|
+
}
|
|
2922
2659
|
}, null, 2)}
|
|
2923
2660
|
`;
|
|
2924
2661
|
}
|
|
@@ -2927,16 +2664,16 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
2927
2664
|
return { initialized: false, services: {}, problems: ["bootstrap state is missing"] };
|
|
2928
2665
|
let state;
|
|
2929
2666
|
try {
|
|
2930
|
-
state =
|
|
2931
|
-
} catch {
|
|
2932
|
-
return { initialized: false, services: {}, problems: [
|
|
2667
|
+
state = parseStoredState(host.read(STATE_PATH));
|
|
2668
|
+
} catch (cause) {
|
|
2669
|
+
return { initialized: false, services: {}, problems: [cause.message] };
|
|
2933
2670
|
}
|
|
2934
2671
|
const units = ["forgezero-agent.service", "forgezero-agent.socket"];
|
|
2935
2672
|
if (state.kind === "platform")
|
|
2936
2673
|
units.push("nginx.service");
|
|
2937
2674
|
if (state.kind === "platform" && state.collectorUnit)
|
|
2938
2675
|
units.push(state.collectorUnit);
|
|
2939
|
-
if (state.
|
|
2676
|
+
if (state.cloudflared)
|
|
2940
2677
|
units.push("cloudflared.service");
|
|
2941
2678
|
if (state.kind === "platform" && state.databaseRole !== "none")
|
|
2942
2679
|
units.push("forgezero-db.service", "forgezero-db-verify.service");
|
|
@@ -2948,7 +2685,42 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
2948
2685
|
if (result.exitCode !== 0)
|
|
2949
2686
|
problems.push(`${unit} is not active`);
|
|
2950
2687
|
}
|
|
2688
|
+
for (const socket of [DEFAULT_SOCKET2, CONTROL_SOCKET]) {
|
|
2689
|
+
services[socket] = host.exists(socket);
|
|
2690
|
+
if (!services[socket])
|
|
2691
|
+
problems.push(`${socket} is missing`);
|
|
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
|
+
}
|
|
2951
2710
|
if (state.kind === "platform") {
|
|
2711
|
+
if (state.cloudflared) {
|
|
2712
|
+
if (state.cloudflare?.warp) {
|
|
2713
|
+
for (const credential of [
|
|
2714
|
+
`${CREDS}/cloudflare-network-token.cred`,
|
|
2715
|
+
WARP_CLIENT_ID_CREDENTIAL,
|
|
2716
|
+
WARP_CLIENT_SECRET_CREDENTIAL
|
|
2717
|
+
]) {
|
|
2718
|
+
services[credential] = host.exists(credential);
|
|
2719
|
+
if (!services[credential])
|
|
2720
|
+
problems.push(`${credential} is missing`);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2952
2724
|
const [blue, green] = await Promise.all([
|
|
2953
2725
|
host.exec(["systemctl", "is-active", "--quiet", "forgezero@blue.service"]),
|
|
2954
2726
|
host.exec(["systemctl", "is-active", "--quiet", "forgezero@green.service"])
|
|
@@ -2956,6 +2728,48 @@ async function bootstrapStatus(host = localBootstrapHost()) {
|
|
|
2956
2728
|
services["forgezero@active.service"] = blue.exitCode === 0 || green.exitCode === 0;
|
|
2957
2729
|
if (!services["forgezero@active.service"])
|
|
2958
2730
|
problems.push("neither API slot is active");
|
|
2731
|
+
if (!Number.isSafeInteger(state.publicApiPort) || !state.healthPath) {
|
|
2732
|
+
problems.push("platform health coordinates are missing from bootstrap state");
|
|
2733
|
+
} else {
|
|
2734
|
+
const health = await host.exec([
|
|
2735
|
+
"curl",
|
|
2736
|
+
"--fail",
|
|
2737
|
+
"--silent",
|
|
2738
|
+
"--show-error",
|
|
2739
|
+
"--max-time",
|
|
2740
|
+
"5",
|
|
2741
|
+
`http://127.0.0.1:${state.publicApiPort}${state.healthPath}`
|
|
2742
|
+
]);
|
|
2743
|
+
services["platform-api-health"] = health.exitCode === 0;
|
|
2744
|
+
if (health.exitCode !== 0)
|
|
2745
|
+
problems.push("platform API health check failed");
|
|
2746
|
+
}
|
|
2747
|
+
const nginx = await host.exec(["nginx", "-t"]);
|
|
2748
|
+
services["nginx-config"] = nginx.exitCode === 0;
|
|
2749
|
+
if (nginx.exitCode !== 0)
|
|
2750
|
+
problems.push("nginx configuration is invalid");
|
|
2751
|
+
if (state.databaseRole !== "none") {
|
|
2752
|
+
const unitPath = "/etc/systemd/system/forgezero-db.service";
|
|
2753
|
+
const expectsNoAgency = state.databaseAgency === "none";
|
|
2754
|
+
const unitHasNoAgency = host.exists(unitPath) && host.read(unitPath).includes("--cluster.start-agent=false");
|
|
2755
|
+
services["database-agency-profile"] = expectsNoAgency === unitHasNoAgency;
|
|
2756
|
+
if (!services["database-agency-profile"])
|
|
2757
|
+
problems.push("database Agency participation disagrees with the installed unit");
|
|
2758
|
+
if (!state.databaseModeEvidence || !host.exists(state.databaseModeEvidence)) {
|
|
2759
|
+
problems.push("database Coordinator-mode evidence is missing");
|
|
2760
|
+
} else {
|
|
2761
|
+
try {
|
|
2762
|
+
const evidence = JSON.parse(host.read(state.databaseModeEvidence));
|
|
2763
|
+
if (evidence.expectedMode !== "default" || evidence.role !== "COORDINATOR" || evidence.agency !== state.databaseAgency || evidence.unit !== "forgezero-db-verify.service" || Number.isNaN(Date.parse(String(evidence.verifiedAt)))) {
|
|
2764
|
+
throw new Error("invalid evidence");
|
|
2765
|
+
}
|
|
2766
|
+
services["database-mode-evidence"] = true;
|
|
2767
|
+
} catch {
|
|
2768
|
+
services["database-mode-evidence"] = false;
|
|
2769
|
+
problems.push("database Coordinator-mode evidence is malformed");
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2959
2773
|
}
|
|
2960
2774
|
if (!host.exists("/var/lib/forgezero/enrolment.json"))
|
|
2961
2775
|
problems.push("durable Agent enrolment state is missing");
|
|
@@ -2970,14 +2784,27 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
2970
2784
|
if (!/^fze_[A-Za-z0-9_-]{32,128}$/.test(token))
|
|
2971
2785
|
throw new Error("tenant enrolment token is malformed");
|
|
2972
2786
|
}
|
|
2787
|
+
let installed;
|
|
2788
|
+
if (host.exists(STATE_PATH))
|
|
2789
|
+
installed = parseStoredState(host.read(STATE_PATH));
|
|
2973
2790
|
let cloudflare;
|
|
2974
|
-
if (config.
|
|
2975
|
-
|
|
2791
|
+
if (config.cloudflareHandoff) {
|
|
2792
|
+
if (host.exists(config.cloudflareHandoff.handoffFile)) {
|
|
2793
|
+
cloudflare = await readCloudflareHostHandoff(config.cloudflareHandoff.handoffFile, config.cloudflareHandoff.nodeName);
|
|
2794
|
+
} else if (config.kind === "platform" && installed?.cloudflare) {
|
|
2795
|
+
config.runtime.environment.cloudflare = installed.cloudflare;
|
|
2796
|
+
config.runtime.environment = validatePlatformSharedEnvironment(config.runtime.environment);
|
|
2797
|
+
} else if (!(config.kind === "tenant" && installed?.cloudflareTunnelId)) {
|
|
2798
|
+
throw new Error("node-specific Cloudflare host handoff is missing before credential sealing");
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
if (cloudflare && cloudflare.hostname !== config.nodeHostname) {
|
|
2802
|
+
throw new Error("Cloudflare handoff hostname disagrees with node hostname");
|
|
2803
|
+
}
|
|
2804
|
+
if (cloudflare && config.kind === "platform") {
|
|
2976
2805
|
const expected = config.runtime.environment.cloudflare;
|
|
2977
|
-
if (cloudflare.hostname !== config.nodeHostname)
|
|
2978
|
-
throw new Error("Cloudflare checkpoint hostname disagrees with platform node hostname");
|
|
2979
2806
|
if (expected && (cloudflare.service !== expected.tunnelService || cloudflare.accountId !== expected.accountId || cloudflare.zoneId !== expected.zoneId || cloudflare.kvNamespaceId !== expected.kvNamespaceId || cloudflare.tunnelId !== expected.tunnelId)) {
|
|
2980
|
-
throw new Error("Cloudflare
|
|
2807
|
+
throw new Error("Cloudflare handoff disagrees with immutable platform runtime coordinates");
|
|
2981
2808
|
}
|
|
2982
2809
|
const discovered = {
|
|
2983
2810
|
accountId: cloudflare.accountId,
|
|
@@ -2992,13 +2819,19 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
2992
2819
|
} } : {}
|
|
2993
2820
|
};
|
|
2994
2821
|
if (expected && JSON.stringify(expected) !== JSON.stringify(discovered)) {
|
|
2995
|
-
throw new Error("Cloudflare
|
|
2822
|
+
throw new Error("Cloudflare handoff disagrees with immutable WARP/runtime coordinates");
|
|
2996
2823
|
}
|
|
2997
2824
|
config.runtime.environment.cloudflare = expected ?? discovered;
|
|
2998
2825
|
if (config.runtime.environment.databaseNetworkMode === "cloudflare-warp" !== Boolean(cloudflare.warp)) {
|
|
2999
|
-
throw new Error("Cloudflare
|
|
2826
|
+
throw new Error("Cloudflare handoff private-network mode disagrees with the platform database network mode");
|
|
3000
2827
|
}
|
|
3001
2828
|
}
|
|
2829
|
+
if (installed) {
|
|
2830
|
+
if (installed.kind !== config.kind || installed.identityDigest !== bootstrapIdentityDigest(config)) {
|
|
2831
|
+
throw new Error("bootstrap repair coordinates do not match the installed host identity");
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
bindBootstrapIntent(host, config);
|
|
3002
2835
|
const plan = planBootstrap(config, host.exists(STATE_PATH));
|
|
3003
2836
|
const alreadyEnrolled = host.exists("/var/lib/forgezero/enrolment.json");
|
|
3004
2837
|
host.mkdir(CREDS, 448);
|
|
@@ -3011,7 +2844,10 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3011
2844
|
await seal(host, "warp-auth-client-id", WARP_CLIENT_ID_CREDENTIAL, cloudflare.warp.clientId);
|
|
3012
2845
|
await seal(host, "warp-auth-client-secret", WARP_CLIENT_SECRET_CREDENTIAL, cloudflare.warp.clientSecret);
|
|
3013
2846
|
}
|
|
3014
|
-
|
|
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
|
+
}
|
|
2850
|
+
await host.installAgent(config);
|
|
3015
2851
|
if (config.kind === "platform" && config.firewall.enabled) {
|
|
3016
2852
|
await host.ensureSoftware(plan.software.filter(({ id }) => id === "ufw"));
|
|
3017
2853
|
} else
|
|
@@ -3050,16 +2886,17 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3050
2886
|
}
|
|
3051
2887
|
}
|
|
3052
2888
|
if (cloudflare) {
|
|
3053
|
-
await seal(host, "cloudflare-kv-token", `${CREDS}/cloudflare-kv-token.cred`, cloudflare.kvRuntimeToken);
|
|
3054
2889
|
if (cloudflare.privateNetworkRuntimeToken)
|
|
3055
2890
|
await seal(host, "cloudflare-network-token", `${CREDS}/cloudflare-network-token.cred`, cloudflare.privateNetworkRuntimeToken);
|
|
3056
2891
|
}
|
|
3057
2892
|
const runtime = config.runtime;
|
|
2893
|
+
const cloudflareConfigured = Boolean(runtime.environment.cloudflare);
|
|
2894
|
+
const cloudflareNetworkConfigured = Boolean(runtime.environment.cloudflare?.warp);
|
|
3058
2895
|
const envPath = `${runtime.environment.sharedDirectory}/.env`;
|
|
3059
2896
|
const credentials = platformApiCredentialSpecs({
|
|
3060
2897
|
smtp: Boolean(credentialFiles.smtpPassword),
|
|
3061
|
-
cloudflareKv:
|
|
3062
|
-
cloudflareNetwork:
|
|
2898
|
+
cloudflareKv: cloudflareConfigured,
|
|
2899
|
+
cloudflareNetwork: cloudflareNetworkConfigured
|
|
3063
2900
|
});
|
|
3064
2901
|
const units = renderPlatformApiUnits({
|
|
3065
2902
|
serviceUser: runtime.serviceUser,
|
|
@@ -3069,16 +2906,23 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3069
2906
|
bluePort: runtime.bluePort,
|
|
3070
2907
|
greenPort: runtime.greenPort,
|
|
3071
2908
|
collectorUnit: runtime.environment.otlpCollectorUnit,
|
|
3072
|
-
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)
|
|
3073
2917
|
});
|
|
3074
|
-
const edge = renderPlatformNginx({ publicPort: runtime.environment.publicApiPort, initialSlotPort: runtime.bluePort });
|
|
3075
2918
|
const activation = renderPlatformActivationFiles({
|
|
3076
2919
|
root: config.deployRoot ?? "/opt/forgezero",
|
|
3077
2920
|
serviceUser: runtime.serviceUser,
|
|
3078
2921
|
bluePort: runtime.bluePort,
|
|
3079
2922
|
greenPort: runtime.greenPort,
|
|
3080
2923
|
healthPath: runtime.healthPath,
|
|
3081
|
-
keepReleases: runtime.keepReleases
|
|
2924
|
+
keepReleases: runtime.keepReleases,
|
|
2925
|
+
drainDeadlineMs: runtime.environment.drainDeadlineMs
|
|
3082
2926
|
});
|
|
3083
2927
|
await checked(host, ["useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", runtime.serviceUser], "API service account").catch(async () => {
|
|
3084
2928
|
await checked(host, ["id", runtime.serviceUser], "existing API service account");
|
|
@@ -3086,6 +2930,10 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3086
2930
|
host.mkdir(runtime.environment.sharedDirectory, 488);
|
|
3087
2931
|
host.mkdir(runtime.slotsDirectory, 493);
|
|
3088
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
|
+
}
|
|
3089
2937
|
await checked(host, ["chown", `root:${runtime.serviceUser}`, runtime.environment.sharedDirectory, envPath], "runtime ownership");
|
|
3090
2938
|
host.write("/etc/systemd/system/forgezero@.service", units.template, 420);
|
|
3091
2939
|
host.write("/etc/systemd/system/forgezero@blue.service.d/port.conf", units.dropIns.blue, 420);
|
|
@@ -3122,6 +2970,7 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3122
2970
|
const evidence = {
|
|
3123
2971
|
expectedMode: "default",
|
|
3124
2972
|
role: "COORDINATOR",
|
|
2973
|
+
agency: config.database.agency,
|
|
3125
2974
|
unit: "forgezero-db-verify.service",
|
|
3126
2975
|
verifiedAt: new Date().toISOString()
|
|
3127
2976
|
};
|
|
@@ -3129,7 +2978,7 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3129
2978
|
`, 384);
|
|
3130
2979
|
}
|
|
3131
2980
|
if (!alreadyEnrolled) {
|
|
3132
|
-
const enrolToken = config.
|
|
2981
|
+
const enrolToken = config.enrolment.source === "api-token" ? privateFile(host, config.enrolment.tokenFile, "platform enrolment token") : `fze_${derive(derive(root, "forgezero/cluster/arangodb-jwt/v1"), `forgezero/platform-enrolment/v1/${config.computeReference}`)}`;
|
|
3133
2982
|
if (!/^fze_[A-Za-z0-9_-]{32,128}$/.test(enrolToken))
|
|
3134
2983
|
throw new Error("platform enrolment token is malformed");
|
|
3135
2984
|
host.write(PLATFORM_ENROL_SOURCE, `${enrolToken}
|
|
@@ -3146,22 +2995,32 @@ async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
|
3146
2995
|
], "initial Agent deployment");
|
|
3147
2996
|
if (!alreadyEnrolled) {
|
|
3148
2997
|
await host.installAgent(config, PLATFORM_ENROL_SOURCE);
|
|
3149
|
-
if (config.
|
|
3150
|
-
host.remove(config.
|
|
2998
|
+
if (config.enrolment.source === "api-token")
|
|
2999
|
+
host.remove(config.enrolment.tokenFile);
|
|
3151
3000
|
}
|
|
3152
3001
|
}
|
|
3153
|
-
if (config.
|
|
3154
|
-
if (!host.exists(TUNNEL_CREDENTIAL)) {
|
|
3002
|
+
if (config.cloudflareHandoff) {
|
|
3003
|
+
if (!host.exists(TUNNEL_CREDENTIAL) && cloudflare) {
|
|
3155
3004
|
await seal(host, "cloudflared-token", TUNNEL_CREDENTIAL, cloudflare.connectorToken);
|
|
3156
3005
|
}
|
|
3006
|
+
if (!host.exists(TUNNEL_CREDENTIAL))
|
|
3007
|
+
throw new Error("sealed cloudflared connector credential is missing");
|
|
3157
3008
|
host.write("/etc/systemd/system/cloudflared.service", tunnelUnit(), 420);
|
|
3158
3009
|
await checked(host, ["systemctl", "daemon-reload"], "cloudflared unit reload");
|
|
3159
3010
|
await checked(host, ["systemctl", "enable", "--now", "cloudflared.service"], "cloudflared connector supervision");
|
|
3011
|
+
const tunnelId = cloudflare?.tunnelId ?? installed?.cloudflareTunnelId ?? (config.kind === "platform" ? config.runtime.environment.cloudflare?.tunnelId : undefined);
|
|
3012
|
+
if (!tunnelId)
|
|
3013
|
+
throw new Error("Cloudflare tunnel identity is missing after handoff validation");
|
|
3014
|
+
await waitForCloudflaredTunnel(host, tunnelId);
|
|
3160
3015
|
}
|
|
3161
|
-
host.write(STATE_PATH, stateFor(config), 384);
|
|
3016
|
+
host.write(STATE_PATH, stateFor(config, cloudflare, installed?.cloudflareTunnelId), 384);
|
|
3162
3017
|
const status = await bootstrapStatus(host);
|
|
3163
3018
|
if (!status.initialized)
|
|
3164
3019
|
throw new Error(`bootstrap verification failed: ${status.problems.join("; ")}`);
|
|
3020
|
+
host.remove(INTENT_PATH);
|
|
3021
|
+
if (cloudflare && config.cloudflareHandoff) {
|
|
3022
|
+
host.remove(config.cloudflareHandoff.handoffFile);
|
|
3023
|
+
}
|
|
3165
3024
|
let launch;
|
|
3166
3025
|
if (config.kind === "platform" && config.database.role === "master") {
|
|
3167
3026
|
const invitePath = `${config.runtime.environment.sharedDirectory}/platform-invite.token`;
|
|
@@ -3199,7 +3058,7 @@ function strictBootstrapDocument(value) {
|
|
|
3199
3058
|
"deployRoot",
|
|
3200
3059
|
"telemetryEndpoint",
|
|
3201
3060
|
"database",
|
|
3202
|
-
"
|
|
3061
|
+
"enrolment",
|
|
3203
3062
|
"runtime",
|
|
3204
3063
|
"firewall",
|
|
3205
3064
|
"installCloudflared",
|
|
@@ -3207,13 +3066,15 @@ function strictBootstrapDocument(value) {
|
|
|
3207
3066
|
"realm",
|
|
3208
3067
|
"enrolTokenFile",
|
|
3209
3068
|
"software",
|
|
3069
|
+
"deploymentCredentials",
|
|
3210
3070
|
"bootstrapRunner"
|
|
3211
3071
|
], "bootstrap config");
|
|
3212
3072
|
if (root.kind === "platform") {
|
|
3213
3073
|
exactKeys(root.firewall, ["enabled", "sshPort", "privateCidrs"], "firewall config");
|
|
3214
3074
|
if (root.cloudflareHandoff !== undefined)
|
|
3215
|
-
exactKeys(root.cloudflareHandoff, ["
|
|
3216
|
-
exactKeys(root.database, ["role", "serverMode", "address", "master", "coordinators", "bootstrapSecretFile"], "database config");
|
|
3075
|
+
exactKeys(root.cloudflareHandoff, ["handoffFile", "nodeName"], "Cloudflare handoff");
|
|
3076
|
+
exactKeys(root.database, ["role", "agency", "serverMode", "address", "master", "coordinators", "bootstrapSecretFile"], "database config");
|
|
3077
|
+
exactKeys(root.enrolment, ["source", "tokenFile"], "platform enrolment config");
|
|
3217
3078
|
const runtime = exactKeys(root.runtime, [
|
|
3218
3079
|
"environment",
|
|
3219
3080
|
"serviceUser",
|
|
@@ -3272,9 +3133,11 @@ function strictBootstrapDocument(value) {
|
|
|
3272
3133
|
exactKeys(environment.cloudflare.warp, ["organization", "virtualNetworkId", "deviceProfileId"], "Cloudflare WARP runtime config");
|
|
3273
3134
|
}
|
|
3274
3135
|
} else if (root.kind === "tenant") {
|
|
3136
|
+
if (root.cloudflareHandoff !== undefined)
|
|
3137
|
+
exactKeys(root.cloudflareHandoff, ["handoffFile", "nodeName"], "Cloudflare handoff");
|
|
3275
3138
|
if (root.bootstrapRunner !== undefined)
|
|
3276
3139
|
exactKeys(root.bootstrapRunner, ["sshPrivateKeyFile", "targetTelemetryEndpoint"], "bootstrap runner config");
|
|
3277
|
-
for (const key of ["environment", "profile", "computeReference", "database", "
|
|
3140
|
+
for (const key of ["environment", "profile", "computeReference", "database", "enrolment", "runtime"]) {
|
|
3278
3141
|
if (root[key] !== undefined && key !== "profile")
|
|
3279
3142
|
throw new Error(`tenant bootstrap cannot contain ${key}`);
|
|
3280
3143
|
}
|
|
@@ -3321,6 +3184,7 @@ function localBootstrapHost() {
|
|
|
3321
3184
|
return { regular: value.isFile(), symbolic: value.isSymbolicLink(), uid: value.uid, mode: value.mode, links: value.nlink, size: value.size };
|
|
3322
3185
|
},
|
|
3323
3186
|
exec: execute,
|
|
3187
|
+
sleep: (milliseconds) => Bun.sleep(milliseconds),
|
|
3324
3188
|
async ensureSoftware(requirements) {
|
|
3325
3189
|
const result = await execute([
|
|
3326
3190
|
"runuser",
|
|
@@ -3362,6 +3226,7 @@ function localBootstrapHost() {
|
|
|
3362
3226
|
branch: config.branch,
|
|
3363
3227
|
profile: config.kind === "platform" ? config.profile : config.profile,
|
|
3364
3228
|
deployRoot,
|
|
3229
|
+
deploymentCredentials: config.deploymentCredentials,
|
|
3365
3230
|
publicApiUrl: config.apiUrl,
|
|
3366
3231
|
gitCredentialPath: "/etc/forgezero/creds/git-deploy-key.cred",
|
|
3367
3232
|
gitPublicKeyPath: "/etc/forgezero/git/deploy.pub",
|
|
@@ -3407,10 +3272,14 @@ function localBootstrapHost() {
|
|
|
3407
3272
|
}
|
|
3408
3273
|
export {
|
|
3409
3274
|
validateBootstrapConfig,
|
|
3275
|
+
resolveInstalledBootstrapKind,
|
|
3410
3276
|
readBootstrapConfig,
|
|
3277
|
+
preparePlatformBootstrap,
|
|
3411
3278
|
planBootstrap,
|
|
3412
3279
|
localBootstrapHost,
|
|
3413
3280
|
bootstrapStatus,
|
|
3281
|
+
bootstrapIdentityDigest,
|
|
3414
3282
|
applyBootstrap,
|
|
3415
|
-
PLATFORM_BOOTSTRAP_PROFILES
|
|
3283
|
+
PLATFORM_BOOTSTRAP_PROFILES,
|
|
3284
|
+
BOOTSTRAP_STATE_PATH
|
|
3416
3285
|
};
|