@forgezero/agent 0.1.57 → 0.1.59
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 +17 -15
- package/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap.d.ts +22 -11
- package/dist/bootstrap.js +257 -159
- package/dist/cli/cloudflare-bootstrap.d.ts +14 -6
- package/dist/cloudflare-bootstrap.d.ts +10 -29
- package/dist/cloudflare-bootstrap.js +114 -81
- package/dist/cloudflare-edge.d.ts +28 -0
- package/dist/cloudflare-edge.js +78 -3
- package/dist/credential-schema.d.ts +9 -9
- package/dist/credential-schema.js +7 -7
- package/dist/fz-agent.js +27 -22
- package/dist/fz.js +392 -278
- package/dist/metal-bootstrap.js +1 -1
- package/dist/operator-bootstrap.d.ts +4 -1
- package/dist/operator-bootstrap.js +274 -179
- package/dist/platform-bootstrap-runtime.d.ts +1 -1
- package/dist/platform-bootstrap-runtime.js +3 -2
- package/dist/platform-fleet-verification.js +21 -14
- package/dist/provision.js +12 -6
- package/dist/recovery-host.js +2 -2
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
import { finalizeCloudflareBootstrapAcceptance, verifyCloudflareBootstrapAcceptance, type AttendedCloudflareBootstrapRequest, type CloudflareBootstrapEvidence, type CloudflareBootstrapAcceptanceEvidence, type CloudflareBootstrapFinalizeRequest, type CloudflareBootstrapPhaseRunner, type
|
|
1
|
+
import { finalizeCloudflareBootstrapAcceptance, verifyCloudflareBootstrapAcceptance, type AttendedCloudflareBootstrapRequest, type CloudflareBootstrapEvidence, type CloudflareBootstrapAcceptanceEvidence, type CloudflareBootstrapFinalizeRequest, type CloudflareBootstrapPhaseRunner, type CloudflareBootstrapTokens } from '../cloudflare-bootstrap';
|
|
2
|
+
import { discoverCloudflareBootstrapResources, type CloudflareBootstrapDiscovery } from '../cloudflare-edge';
|
|
2
3
|
export interface CloudflareBootstrapCommandConfig {
|
|
3
4
|
format: 1;
|
|
4
5
|
kind: 'forgezero-cloudflare-bootstrap-request';
|
|
5
6
|
checkpointPath: string;
|
|
6
7
|
coordinates: AttendedCloudflareBootstrapRequest['coordinates'];
|
|
7
|
-
tokenFiles: CloudflareBootstrapTokenFiles;
|
|
8
8
|
}
|
|
9
9
|
export interface CloudflareBootstrapCommandDependencies {
|
|
10
10
|
run?: CloudflareBootstrapPhaseRunner;
|
|
11
11
|
write?: (text: string) => void;
|
|
12
12
|
}
|
|
13
|
+
export declare function discoverCloudflareBootstrapCommandResources(input: {
|
|
14
|
+
zoneName: string;
|
|
15
|
+
kvNamespaceTitle: string;
|
|
16
|
+
workerScriptName?: string;
|
|
17
|
+
tokens: CloudflareBootstrapTokens;
|
|
18
|
+
}, dependencies?: {
|
|
19
|
+
fetcher?: typeof fetch;
|
|
20
|
+
discover?: typeof discoverCloudflareBootstrapResources;
|
|
21
|
+
}): Promise<CloudflareBootstrapDiscovery>;
|
|
13
22
|
export interface CloudflareBootstrapFinalizeConfig {
|
|
14
23
|
format: 1;
|
|
15
24
|
kind: 'forgezero-cloudflare-bootstrap-finalize';
|
|
@@ -23,14 +32,13 @@ export interface CloudflareBootstrapFinalizeConfig {
|
|
|
23
32
|
export declare function createCloudflareBootstrapCommandConfig(input: {
|
|
24
33
|
checkpointPath: string;
|
|
25
34
|
coordinates: AttendedCloudflareBootstrapRequest['coordinates'];
|
|
26
|
-
tokenFiles: CloudflareBootstrapTokenFiles;
|
|
27
35
|
}): CloudflareBootstrapCommandConfig;
|
|
28
36
|
/**
|
|
29
|
-
* Read reviewed non-secret coordinates
|
|
30
|
-
*
|
|
37
|
+
* Read reviewed non-secret coordinates for the attended operator phase. Raw
|
|
38
|
+
* token values and a persisted mode are not schema fields.
|
|
31
39
|
*/
|
|
32
40
|
export declare function readCloudflareBootstrapCommandConfig(path: string, mode: 'plan' | 'apply'): AttendedCloudflareBootstrapRequest;
|
|
33
|
-
export declare function runCloudflareBootstrapCommand(configPath: string, apply: boolean, dependencies?: CloudflareBootstrapCommandDependencies): Promise<CloudflareBootstrapEvidence>;
|
|
41
|
+
export declare function runCloudflareBootstrapCommand(configPath: string, apply: boolean, tokens?: CloudflareBootstrapTokens, dependencies?: CloudflareBootstrapCommandDependencies): Promise<CloudflareBootstrapEvidence>;
|
|
34
42
|
export declare function runCloudflareBootstrapVerificationCommand(checkpointPath: string, dependencies?: {
|
|
35
43
|
verify?: typeof verifyCloudflareBootstrapAcceptance;
|
|
36
44
|
write?: (text: string) => void;
|
|
@@ -33,32 +33,18 @@ export interface CloudflareBootstrapCoordinates {
|
|
|
33
33
|
realtime?: CloudflareRealtimeCoordinates;
|
|
34
34
|
nodes: CloudflareBootstrapNodeCoordinates[];
|
|
35
35
|
}
|
|
36
|
-
export interface CloudflareBootstrapTokenFiles {
|
|
37
|
-
/** CF_TUNNEL_TOKEN: Tunnel, DNS, Mesh connector/network and device-profile writes. */
|
|
38
|
-
tunnelTokenFile: string;
|
|
39
|
-
/** Exact-account Workers KV Storage Write plus existing-Worker Scripts Write. */
|
|
40
|
-
apiTokenFile: string;
|
|
41
|
-
}
|
|
42
36
|
export interface CloudflareBootstrapTokens {
|
|
43
37
|
tunnelToken: string;
|
|
44
38
|
apiToken: string;
|
|
45
39
|
}
|
|
46
40
|
interface CloudflareBootstrapNodeResource extends CloudflareBootstrapNodeCoordinates {
|
|
47
41
|
tunnelId: string;
|
|
48
|
-
connectorToken: string;
|
|
49
42
|
mesh?: CloudflareMeshCoordinates & {
|
|
50
43
|
connectorId: string;
|
|
51
|
-
connectorToken: string;
|
|
52
44
|
};
|
|
53
45
|
}
|
|
54
46
|
interface CloudflareBootstrapResources {
|
|
55
47
|
kvNamespaceId: string;
|
|
56
|
-
/** Owner-supplied CF_API_TOKEN, sealed into each host handoff. */
|
|
57
|
-
apiToken: string;
|
|
58
|
-
realtime?: {
|
|
59
|
-
publishSecret: string;
|
|
60
|
-
ticketSecret: string;
|
|
61
|
-
};
|
|
62
48
|
nodes: CloudflareBootstrapNodeResource[];
|
|
63
49
|
}
|
|
64
50
|
export interface CloudflareBootstrapOutput {
|
|
@@ -79,7 +65,7 @@ export interface CloudflareBootstrapOutput {
|
|
|
79
65
|
export interface CloudflareBootstrapPlan {
|
|
80
66
|
format: 1;
|
|
81
67
|
kind: 'forgezero-cloudflare-bootstrap-plan';
|
|
82
|
-
mode: 'attended-
|
|
68
|
+
mode: 'attended-hidden-input';
|
|
83
69
|
outputFile: string;
|
|
84
70
|
coordinates: CloudflareBootstrapCoordinates;
|
|
85
71
|
operations: readonly string[];
|
|
@@ -88,7 +74,7 @@ export interface CloudflareBootstrapPlan {
|
|
|
88
74
|
export interface AttendedCloudflareBootstrapRequest {
|
|
89
75
|
mode: 'plan' | 'apply';
|
|
90
76
|
coordinates: CloudflareBootstrapCoordinates;
|
|
91
|
-
|
|
77
|
+
tokens?: CloudflareBootstrapTokens;
|
|
92
78
|
checkpointPath: string;
|
|
93
79
|
}
|
|
94
80
|
/** Secret-free evidence safe to embed in the platform bootstrap journal. */
|
|
@@ -128,9 +114,7 @@ export interface CloudflareBootstrapDependencies {
|
|
|
128
114
|
fetcher?: typeof fetch;
|
|
129
115
|
}
|
|
130
116
|
export type CloudflareBootstrapPhaseRunner = (request: AttendedCloudflareBootstrapRequest) => Promise<CloudflareBootstrapEvidence>;
|
|
131
|
-
|
|
132
|
-
export declare function readOwnerApiToken(path: string): Promise<string>;
|
|
133
|
-
export declare function readCloudflareBootstrapTokens(files: CloudflareBootstrapTokenFiles): Promise<CloudflareBootstrapTokens>;
|
|
117
|
+
export declare function validateCloudflareBootstrapTokens(input: unknown): CloudflareBootstrapTokens;
|
|
134
118
|
export declare function validateCloudflareBootstrapCoordinates(input: CloudflareBootstrapCoordinates): CloudflareBootstrapCoordinates;
|
|
135
119
|
export declare function planCloudflareBootstrap(input: CloudflareBootstrapCoordinates, outputPath: string): CloudflareBootstrapPlan;
|
|
136
120
|
export interface CloudflareConnectorHandoff {
|
|
@@ -138,10 +122,8 @@ export interface CloudflareConnectorHandoff {
|
|
|
138
122
|
hostname: string;
|
|
139
123
|
service: string;
|
|
140
124
|
tunnelId: string;
|
|
141
|
-
connectorToken: string;
|
|
142
125
|
mesh?: {
|
|
143
126
|
connectorId: string;
|
|
144
|
-
connectorToken: string;
|
|
145
127
|
routes: readonly string[];
|
|
146
128
|
};
|
|
147
129
|
}
|
|
@@ -149,30 +131,29 @@ export interface CloudflareHostHandoff extends CloudflareConnectorHandoff {
|
|
|
149
131
|
accountId: string;
|
|
150
132
|
zoneId: string;
|
|
151
133
|
kvNamespaceId: string;
|
|
152
|
-
|
|
153
|
-
apiToken: string;
|
|
154
|
-
realtime?: CloudflareRealtimeCoordinates & {
|
|
155
|
-
publishSecret: string;
|
|
156
|
-
ticketSecret: string;
|
|
157
|
-
};
|
|
134
|
+
realtime?: CloudflareRealtimeCoordinates;
|
|
158
135
|
}
|
|
159
136
|
export declare function cloudflareHostHandoffPath(checkpointPath: string, nodeName: string): string;
|
|
160
137
|
export declare function readCloudflareConnectorHandoff(checkpointPath: string, nodeName: string): Promise<CloudflareConnectorHandoff>;
|
|
161
138
|
export declare function readCloudflareHostHandoff(handoffPath: string, nodeName: string): Promise<CloudflareHostHandoff>;
|
|
162
139
|
export declare function writeOwnerBootstrapOutput(path: string, output: CloudflareBootstrapOutput): Promise<void>;
|
|
140
|
+
export declare const deriveCloudflareRealtimeSecrets: (apiToken: string) => {
|
|
141
|
+
publishSecret: string;
|
|
142
|
+
ticketSecret: string;
|
|
143
|
+
};
|
|
163
144
|
/** Attended, resumable Tunnel/DNS/KV handoff bootstrap. */
|
|
164
145
|
export declare function applyCloudflareBootstrap(input: CloudflareBootstrapCoordinates, tokens: CloudflareBootstrapTokens, outputPath: string, fetcher?: typeof fetch): Promise<CloudflareBootstrapOutput>;
|
|
165
146
|
export declare function runAttendedCloudflareBootstrap(request: AttendedCloudflareBootstrapRequest, dependencies?: CloudflareBootstrapDependencies): Promise<CloudflareBootstrapEvidence>;
|
|
166
147
|
export declare function verifyCloudflareBootstrapAcceptance(checkpointPath: string, fetcher?: typeof fetch): Promise<CloudflareBootstrapAcceptanceEvidence>;
|
|
167
148
|
/**
|
|
168
|
-
* Read finalized, secret-free Cloudflare acceptance after the
|
|
149
|
+
* Read finalized, secret-free Cloudflare acceptance after the working
|
|
169
150
|
* checkpoint has been removed. This is the durable input consumed by the fleet
|
|
170
151
|
* rehearsal; it never attempts to reopen the deleted handoff.
|
|
171
152
|
*/
|
|
172
153
|
export declare function readCloudflareBootstrapAcceptanceEvidence(acceptancePath: string, expectedNodes?: readonly CloudflareBootstrapAcceptedNode[]): Promise<CloudflareBootstrapAcceptanceEvidence>;
|
|
173
154
|
/**
|
|
174
155
|
* Prove every public node, persist secret-free acceptance evidence, then remove
|
|
175
|
-
* the resumable checkpoint and node handoffs
|
|
156
|
+
* the resumable secret-free checkpoint and node handoffs.
|
|
176
157
|
*/
|
|
177
158
|
export declare function finalizeCloudflareBootstrapAcceptance(request: CloudflareBootstrapFinalizeRequest, fetcher?: typeof fetch): Promise<CloudflareBootstrapAcceptanceEvidence>;
|
|
178
159
|
export {};
|
|
@@ -22,7 +22,7 @@ function privateDatabaseHostRoute(value) {
|
|
|
22
22
|
return `${address}/${isIP(address) === 4 ? 32 : 128}`;
|
|
23
23
|
}
|
|
24
24
|
var endpoint = "https://api.cloudflare.com/client/v4";
|
|
25
|
-
async function
|
|
25
|
+
async function cfEnvelope(config, path, init = {}, fetcher = fetch) {
|
|
26
26
|
const response = await fetcher(`${endpoint}${path}`, {
|
|
27
27
|
...init,
|
|
28
28
|
headers: {
|
|
@@ -35,7 +35,68 @@ async function cf(config, path, init = {}, fetcher = fetch) {
|
|
|
35
35
|
if (!response.ok || body.success !== true) {
|
|
36
36
|
throw new Error(body.errors?.map(({ message }) => message).filter(Boolean).join("; ") || `Cloudflare returned HTTP ${response.status}`);
|
|
37
37
|
}
|
|
38
|
-
return body
|
|
38
|
+
return body;
|
|
39
|
+
}
|
|
40
|
+
async function cf(config, path, init = {}, fetcher = fetch) {
|
|
41
|
+
return (await cfEnvelope(config, path, init, fetcher)).result;
|
|
42
|
+
}
|
|
43
|
+
async function cfPages(config, path, perPage, fetcher) {
|
|
44
|
+
const output = [];
|
|
45
|
+
for (let page = 1;page <= 100; page += 1) {
|
|
46
|
+
const separator = path.includes("?") ? "&" : "?";
|
|
47
|
+
const envelope = await cfEnvelope(config, `${path}${separator}page=${page}&per_page=${perPage}`, {}, fetcher);
|
|
48
|
+
const result = Array.isArray(envelope.result) ? envelope.result : [];
|
|
49
|
+
output.push(...result);
|
|
50
|
+
const totalPages = envelope.result_info?.total_pages;
|
|
51
|
+
if (Number.isInteger(totalPages) ? page >= totalPages : result.length < perPage)
|
|
52
|
+
return output;
|
|
53
|
+
}
|
|
54
|
+
throw new Error("Cloudflare pagination exceeded the reviewed 100-page bound");
|
|
55
|
+
}
|
|
56
|
+
var exactHexId = (value, label) => {
|
|
57
|
+
const normalized = String(value ?? "").trim().toLowerCase();
|
|
58
|
+
if (!/^[a-f0-9]{32}$/.test(normalized))
|
|
59
|
+
throw new Error(`${label} is malformed`);
|
|
60
|
+
return normalized;
|
|
61
|
+
};
|
|
62
|
+
async function discoverCloudflareBootstrapResources(config, fetcher = fetch) {
|
|
63
|
+
const zoneName = config.zoneName.trim().toLowerCase().replace(/\.$/, "");
|
|
64
|
+
const kvNamespaceTitle = config.kvNamespaceTitle.trim();
|
|
65
|
+
if (!/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(zoneName)) {
|
|
66
|
+
throw new Error("Cloudflare zone name is invalid");
|
|
67
|
+
}
|
|
68
|
+
if (!kvNamespaceTitle || kvNamespaceTitle.length > 512) {
|
|
69
|
+
throw new Error("Cloudflare KV namespace title is invalid");
|
|
70
|
+
}
|
|
71
|
+
const [managementStatus, runtimeStatus] = await Promise.all([
|
|
72
|
+
cf({ apiToken: config.tunnelToken }, "/user/tokens/verify", {}, fetcher),
|
|
73
|
+
cf({ apiToken: config.apiToken }, "/user/tokens/verify", {}, fetcher)
|
|
74
|
+
]);
|
|
75
|
+
if (managementStatus.status !== "active")
|
|
76
|
+
throw new Error("CF_TUNNEL_TOKEN is not active");
|
|
77
|
+
if (runtimeStatus.status !== "active")
|
|
78
|
+
throw new Error("CF_API_TOKEN is not active");
|
|
79
|
+
const zones = await cfPages({ apiToken: config.tunnelToken }, `/zones?name=${encodeURIComponent(zoneName)}&match=all&status=active`, 50, fetcher);
|
|
80
|
+
const matchingZones = zones.filter(({ name }) => name?.trim().toLowerCase() === zoneName);
|
|
81
|
+
if (matchingZones.length !== 1) {
|
|
82
|
+
throw new Error(`Cloudflare zone ${zoneName} must resolve to exactly one active zone`);
|
|
83
|
+
}
|
|
84
|
+
const zoneId = exactHexId(matchingZones[0].id, "Cloudflare zone id");
|
|
85
|
+
const accountId = exactHexId(matchingZones[0].account?.id, "Cloudflare account id");
|
|
86
|
+
const namespaces = await cfPages({ apiToken: config.apiToken }, `/accounts/${accountId}/storage/kv/namespaces?order=title&direction=asc`, 1000, fetcher);
|
|
87
|
+
const matchingNamespaces = namespaces.filter(({ title }) => title === kvNamespaceTitle);
|
|
88
|
+
if (matchingNamespaces.length !== 1) {
|
|
89
|
+
throw new Error(`Cloudflare KV namespace ${kvNamespaceTitle} must resolve to exactly one namespace`);
|
|
90
|
+
}
|
|
91
|
+
const kvNamespaceId = exactHexId(matchingNamespaces[0].id, "Cloudflare KV namespace id");
|
|
92
|
+
if (config.workerScriptName) {
|
|
93
|
+
await verifyCloudflareWorkerDurableObjects({
|
|
94
|
+
accountId,
|
|
95
|
+
scriptName: config.workerScriptName,
|
|
96
|
+
apiToken: config.apiToken
|
|
97
|
+
}, fetcher);
|
|
98
|
+
}
|
|
99
|
+
return { accountId, zoneId, kvNamespaceId };
|
|
39
100
|
}
|
|
40
101
|
async function ensureCloudflarePrivateRoute(config, fetcher = fetch) {
|
|
41
102
|
const [address, prefixText, ...extra] = config.network.split("/");
|
|
@@ -211,7 +272,7 @@ async function verifyCloudflareWorkerDurableObjects(config, fetcher = fetch) {
|
|
|
211
272
|
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/.test(config.scriptName)) {
|
|
212
273
|
throw new Error("Cloudflare Worker script name is invalid");
|
|
213
274
|
}
|
|
214
|
-
const namespaces = await
|
|
275
|
+
const namespaces = await cfPages(config, `/accounts/${config.accountId}/workers/durable_objects/namespaces`, 1000, fetcher);
|
|
215
276
|
const owned = namespaces.filter(({ script }) => script === config.scriptName);
|
|
216
277
|
if (!owned.length)
|
|
217
278
|
throw new Error(`Cloudflare Worker ${config.scriptName} has no Durable Object namespace`);
|
|
@@ -283,18 +344,28 @@ async function ensureCloudflareTunnel(config, fetcher = fetch) {
|
|
|
283
344
|
}
|
|
284
345
|
return { tunnel, connectorToken, created };
|
|
285
346
|
}
|
|
347
|
+
async function retrieveCloudflareConnectorTokens(config, fetcher = fetch) {
|
|
348
|
+
if (!/^[a-f0-9]{32}$/i.test(config.accountId) || !/^[0-9a-f-]{36}$/i.test(config.tunnelId) || config.meshConnectorId && !/^[0-9a-f-]{36}$/i.test(config.meshConnectorId)) {
|
|
349
|
+
throw new Error("Cloudflare connector retrieval coordinates are invalid");
|
|
350
|
+
}
|
|
351
|
+
const connectorToken = await cf(config, `/accounts/${config.accountId}/cfd_tunnel/${encodeURIComponent(config.tunnelId)}/token`, {}, fetcher);
|
|
352
|
+
const meshConnectorToken = config.meshConnectorId ? await cf(config, `/accounts/${config.accountId}/warp_connector/${encodeURIComponent(config.meshConnectorId)}/token`, {}, fetcher) : undefined;
|
|
353
|
+
for (const value of [connectorToken, meshConnectorToken]) {
|
|
354
|
+
if (value !== undefined && (!value || value.length > 16384))
|
|
355
|
+
throw new Error("Cloudflare returned an invalid connector token");
|
|
356
|
+
}
|
|
357
|
+
return { connectorToken, ...meshConnectorToken ? { meshConnectorToken } : {} };
|
|
358
|
+
}
|
|
286
359
|
|
|
287
360
|
// src/cloudflare-bootstrap.ts
|
|
288
361
|
import { constants } from "fs";
|
|
289
|
-
import {
|
|
362
|
+
import { createHmac, randomUUID } from "crypto";
|
|
290
363
|
import { chmod, lstat, mkdir, open, readdir, rename, rmdir, stat, unlink } from "fs/promises";
|
|
291
364
|
import { dirname, join, resolve } from "path";
|
|
292
365
|
import { isIP as isIP2 } from "net";
|
|
293
366
|
var TOKEN = /^[A-Za-z0-9._-]{40,80}$/;
|
|
294
|
-
var CONNECTOR_TOKEN = /^[A-Za-z0-9._-]{40,16384}$/;
|
|
295
367
|
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;
|
|
296
368
|
var HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
297
|
-
var REALTIME_SECRET = /^[A-Za-z0-9_-]{64,128}$/;
|
|
298
369
|
var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
299
370
|
async function assertOwnerOnlyHandle(path, handle, maximumBytes) {
|
|
300
371
|
const metadata = await handle.stat();
|
|
@@ -328,26 +399,17 @@ async function readOwnerOnlyFile(path, maximumBytes) {
|
|
|
328
399
|
await handle?.close();
|
|
329
400
|
}
|
|
330
401
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
async function readCloudflareBootstrapTokens(files) {
|
|
338
|
-
const unsupported = Object.keys(files).filter((key) => ![
|
|
339
|
-
"tunnelTokenFile",
|
|
340
|
-
"apiTokenFile"
|
|
341
|
-
].includes(key));
|
|
402
|
+
function validateCloudflareBootstrapTokens(input) {
|
|
403
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
404
|
+
throw new Error("Cloudflare bootstrap tokens must be an object");
|
|
405
|
+
const source = input;
|
|
406
|
+
const unsupported = Object.keys(source).filter((key) => !["tunnelToken", "apiToken"].includes(key));
|
|
342
407
|
if (unsupported.length)
|
|
343
|
-
throw new Error(`Cloudflare bootstrap
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
readOwnerApiToken(files.tunnelTokenFile),
|
|
349
|
-
readOwnerApiToken(files.apiTokenFile)
|
|
350
|
-
]);
|
|
408
|
+
throw new Error(`Cloudflare bootstrap tokens contain unsupported field ${unsupported[0]}`);
|
|
409
|
+
const tunnelToken = typeof source.tunnelToken === "string" ? source.tunnelToken.trim() : "";
|
|
410
|
+
const apiToken = typeof source.apiToken === "string" ? source.apiToken.trim() : "";
|
|
411
|
+
if (!TOKEN.test(tunnelToken) || !TOKEN.test(apiToken))
|
|
412
|
+
throw new Error("Cloudflare bootstrap requires two valid API tokens");
|
|
351
413
|
if (tunnelToken === apiToken) {
|
|
352
414
|
throw new Error("CF_TUNNEL_TOKEN and CF_API_TOKEN must be distinct least-privilege tokens");
|
|
353
415
|
}
|
|
@@ -523,7 +585,7 @@ function planCloudflareBootstrap(input, outputPath) {
|
|
|
523
585
|
return {
|
|
524
586
|
format: 1,
|
|
525
587
|
kind: "forgezero-cloudflare-bootstrap-plan",
|
|
526
|
-
mode: "attended-
|
|
588
|
+
mode: "attended-hidden-input",
|
|
527
589
|
outputFile: resolve(outputPath),
|
|
528
590
|
coordinates,
|
|
529
591
|
operations: [
|
|
@@ -531,19 +593,19 @@ function planCloudflareBootstrap(input, outputPath) {
|
|
|
531
593
|
...coordinates.realtime ? [
|
|
532
594
|
"prove the existing Worker owns a Durable Object namespace and install its publish/ticket secrets with CF_API_TOKEN"
|
|
533
595
|
] : [],
|
|
534
|
-
"create or reuse one remotely-managed Tunnel per node and checkpoint
|
|
596
|
+
"create or reuse one remotely-managed Tunnel per node and checkpoint only its non-secret id",
|
|
535
597
|
...coordinates.nodes.some(({ mesh }) => mesh) ? [
|
|
536
|
-
"create or reuse one Mesh/WARP Connector per declared private-network node and checkpoint its
|
|
598
|
+
"create or reuse one Mesh/WARP Connector per declared private-network node and checkpoint only its non-secret id",
|
|
537
599
|
"reconcile every unique private CIDR to its Mesh connector and include all declared CIDRs in the dedicated Mesh device profile"
|
|
538
600
|
] : [],
|
|
539
601
|
"preflight each exact DNS hostname, refuse ambiguous or incompatible records, and update its existing CNAME or create it only when absent",
|
|
540
602
|
"reconcile each Tunnel public-hostname ingress rule to the declared loopback API service",
|
|
541
|
-
"write one
|
|
603
|
+
"write one secret-free node handoff containing only bound Cloudflare ids and public coordinates"
|
|
542
604
|
],
|
|
543
605
|
secrets: [
|
|
544
|
-
"API tokens are
|
|
545
|
-
"
|
|
546
|
-
"the
|
|
606
|
+
"API tokens are accepted only from a hidden prompt and are never placed in JSON, files, argv or stdout",
|
|
607
|
+
"checkpoint and host handoffs contain no connector, management, runtime or realtime secret",
|
|
608
|
+
"the target Agent retrieves its connector tokens and seals both Vault fallback tokens directly"
|
|
547
609
|
]
|
|
548
610
|
};
|
|
549
611
|
}
|
|
@@ -608,25 +670,15 @@ async function readExistingOutput(path) {
|
|
|
608
670
|
const resources = output.resources;
|
|
609
671
|
const unsupportedResource = Object.keys(resources).filter((key) => ![
|
|
610
672
|
"kvNamespaceId",
|
|
611
|
-
"apiToken",
|
|
612
|
-
"realtime",
|
|
613
673
|
"nodes"
|
|
614
674
|
].includes(key));
|
|
615
675
|
if (unsupportedResource.length) {
|
|
616
676
|
throw new Error(`${resolve(path)} resources contain unsupported field ${unsupportedResource[0]}`);
|
|
617
677
|
}
|
|
618
678
|
const coordinates = validateCloudflareBootstrapCoordinates(output.coordinates);
|
|
619
|
-
if (resources.kvNamespaceId !== coordinates.kvNamespaceId || !
|
|
679
|
+
if (resources.kvNamespaceId !== coordinates.kvNamespaceId || !Array.isArray(resources.nodes) || resources.nodes.length > coordinates.nodes.length) {
|
|
620
680
|
throw new Error(`${resolve(path)} has malformed Cloudflare bootstrap resources`);
|
|
621
681
|
}
|
|
622
|
-
if (coordinates.realtime) {
|
|
623
|
-
const realtime = resources.realtime;
|
|
624
|
-
if (!realtime || !REALTIME_SECRET.test(String(realtime.publishSecret ?? "")) || !REALTIME_SECRET.test(String(realtime.ticketSecret ?? "")) || realtime.publishSecret === realtime.ticketSecret) {
|
|
625
|
-
throw new Error(`${resolve(path)} has malformed Cloudflare realtime resources`);
|
|
626
|
-
}
|
|
627
|
-
} else if (resources.realtime !== undefined) {
|
|
628
|
-
throw new Error(`${resolve(path)} contains undeclared Cloudflare realtime resources`);
|
|
629
|
-
}
|
|
630
682
|
const seen = new Set;
|
|
631
683
|
for (const item of resources.nodes) {
|
|
632
684
|
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
@@ -639,16 +691,15 @@ async function readExistingOutput(path) {
|
|
|
639
691
|
"service",
|
|
640
692
|
"tunnelName",
|
|
641
693
|
"tunnelId",
|
|
642
|
-
"connectorToken",
|
|
643
694
|
"mesh"
|
|
644
695
|
].includes(key));
|
|
645
696
|
const expected = coordinates.nodes.find((candidate) => candidate.nodeName === node.nodeName);
|
|
646
|
-
if (unknownNode.length || !expected || seen.has(expected.nodeName) || node.hostname !== expected.hostname || node.service !== expected.service || node.tunnelName !== expected.tunnelName || !UUID.test(String(node.tunnelId ?? ""))
|
|
697
|
+
if (unknownNode.length || !expected || seen.has(expected.nodeName) || node.hostname !== expected.hostname || node.service !== expected.service || node.tunnelName !== expected.tunnelName || !UUID.test(String(node.tunnelId ?? ""))) {
|
|
647
698
|
throw new Error(`${resolve(path)} has a malformed or unbound Cloudflare node resource`);
|
|
648
699
|
}
|
|
649
700
|
if (expected.mesh) {
|
|
650
701
|
const mesh = node.mesh;
|
|
651
|
-
if (!mesh || mesh.connectorName !== expected.mesh.connectorName || JSON.stringify(mesh.routes) !== JSON.stringify(expected.mesh.routes) || mesh.highAvailability !== expected.mesh.highAvailability || !UUID.test(String(mesh.connectorId ?? ""))
|
|
702
|
+
if (!mesh || mesh.connectorName !== expected.mesh.connectorName || JSON.stringify(mesh.routes) !== JSON.stringify(expected.mesh.routes) || mesh.highAvailability !== expected.mesh.highAvailability || !UUID.test(String(mesh.connectorId ?? ""))) {
|
|
652
703
|
throw new Error(`${resolve(path)} has a malformed or unbound Cloudflare Mesh resource`);
|
|
653
704
|
}
|
|
654
705
|
} else if (node.mesh !== undefined) {
|
|
@@ -689,7 +740,7 @@ async function readCloudflareConnectorHandoff(checkpointPath, nodeName) {
|
|
|
689
740
|
throw new Error(`Cloudflare connector handoff has no unique completed node ${normalizedNodeName}`);
|
|
690
741
|
}
|
|
691
742
|
const resource = matches[0];
|
|
692
|
-
if (resource.hostname !== expected.hostname || resource.service !== expected.service || resource.tunnelName !== expected.tunnelName || !UUID.test(resource.tunnelId)
|
|
743
|
+
if (resource.hostname !== expected.hostname || resource.service !== expected.service || resource.tunnelName !== expected.tunnelName || !UUID.test(resource.tunnelId)) {
|
|
693
744
|
throw new Error(`Cloudflare connector handoff for ${normalizedNodeName} is malformed or incomplete`);
|
|
694
745
|
}
|
|
695
746
|
return {
|
|
@@ -697,10 +748,8 @@ async function readCloudflareConnectorHandoff(checkpointPath, nodeName) {
|
|
|
697
748
|
hostname: resource.hostname,
|
|
698
749
|
service: resource.service,
|
|
699
750
|
tunnelId: resource.tunnelId,
|
|
700
|
-
connectorToken: resource.connectorToken,
|
|
701
751
|
...resource.mesh ? { mesh: {
|
|
702
752
|
connectorId: resource.mesh.connectorId,
|
|
703
|
-
connectorToken: resource.mesh.connectorToken,
|
|
704
753
|
routes: resource.mesh.routes
|
|
705
754
|
} } : {}
|
|
706
755
|
};
|
|
@@ -724,11 +773,9 @@ async function readCloudflareHostHandoff(handoffPath, nodeName) {
|
|
|
724
773
|
"hostname",
|
|
725
774
|
"service",
|
|
726
775
|
"tunnelId",
|
|
727
|
-
"connectorToken",
|
|
728
776
|
"accountId",
|
|
729
777
|
"zoneId",
|
|
730
778
|
"kvNamespaceId",
|
|
731
|
-
"apiToken",
|
|
732
779
|
"mesh",
|
|
733
780
|
"realtime"
|
|
734
781
|
].includes(key));
|
|
@@ -739,11 +786,11 @@ async function readCloudflareHostHandoff(handoffPath, nodeName) {
|
|
|
739
786
|
try {
|
|
740
787
|
service = normalizeService(output.service ?? "");
|
|
741
788
|
} catch {}
|
|
742
|
-
if (output.format !== 1 || output.kind !== "forgezero-cloudflare-host-handoff" || output.nodeName !== normalizedNodeName || !HOSTNAME.test(output.hostname ?? "") || !service || !UUID.test(output.tunnelId ?? "") ||
|
|
789
|
+
if (output.format !== 1 || output.kind !== "forgezero-cloudflare-host-handoff" || output.nodeName !== normalizedNodeName || !HOSTNAME.test(output.hostname ?? "") || !service || !UUID.test(output.tunnelId ?? "") || !/^[a-f0-9]{32}$/i.test(output.accountId ?? "") || !/^[a-f0-9]{32}$/i.test(output.zoneId ?? "") || !/^[a-f0-9]{32}$/i.test(output.kvNamespaceId ?? "")) {
|
|
743
790
|
throw new Error("Cloudflare host handoff is malformed or belongs to another node");
|
|
744
791
|
}
|
|
745
792
|
if (output.mesh !== undefined) {
|
|
746
|
-
if (!output.mesh || typeof output.mesh !== "object" || !UUID.test(output.mesh.connectorId) || !
|
|
793
|
+
if (!output.mesh || typeof output.mesh !== "object" || !UUID.test(output.mesh.connectorId) || !Array.isArray(output.mesh.routes) || output.mesh.routes.length < 1 || output.mesh.routes.length > 64 || output.mesh.routes.some((route) => {
|
|
747
794
|
try {
|
|
748
795
|
return privateMeshCidr(route) !== route;
|
|
749
796
|
} catch {
|
|
@@ -772,7 +819,7 @@ async function readCloudflareHostHandoff(handoffPath, nodeName) {
|
|
|
772
819
|
} catch {
|
|
773
820
|
throw new Error("Cloudflare host handoff contains malformed realtime coordinates");
|
|
774
821
|
}
|
|
775
|
-
if (
|
|
822
|
+
if (realtime.workerScriptName !== output.realtime.workerScriptName || realtime.endpoint !== output.realtime.endpoint || realtime.producer !== output.realtime.producer) {
|
|
776
823
|
throw new Error("Cloudflare host handoff contains malformed realtime credentials");
|
|
777
824
|
}
|
|
778
825
|
}
|
|
@@ -837,18 +884,12 @@ async function writeCloudflareHostHandoffs(checkpointPath, output) {
|
|
|
837
884
|
hostname: node.hostname,
|
|
838
885
|
service: node.service,
|
|
839
886
|
tunnelId: node.tunnelId,
|
|
840
|
-
connectorToken: node.connectorToken,
|
|
841
887
|
accountId: output.coordinates.accountId,
|
|
842
888
|
zoneId: output.coordinates.zoneId,
|
|
843
889
|
kvNamespaceId: output.resources.kvNamespaceId,
|
|
844
|
-
|
|
845
|
-
...output.coordinates.realtime && output.resources.realtime ? { realtime: {
|
|
846
|
-
...output.coordinates.realtime,
|
|
847
|
-
...output.resources.realtime
|
|
848
|
-
} } : {},
|
|
890
|
+
...output.coordinates.realtime ? { realtime: output.coordinates.realtime } : {},
|
|
849
891
|
...node.mesh ? { mesh: {
|
|
850
892
|
connectorId: node.mesh.connectorId,
|
|
851
|
-
connectorToken: node.mesh.connectorToken,
|
|
852
893
|
routes: node.mesh.routes
|
|
853
894
|
} } : {}
|
|
854
895
|
};
|
|
@@ -856,7 +897,10 @@ async function writeCloudflareHostHandoffs(checkpointPath, output) {
|
|
|
856
897
|
}
|
|
857
898
|
}
|
|
858
899
|
var sameCoordinates = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
859
|
-
var
|
|
900
|
+
var deriveCloudflareRealtimeSecrets = (apiToken) => ({
|
|
901
|
+
publishSecret: createHmac("sha512", apiToken).update("forgezero/realtime/publish/v1").digest("base64url"),
|
|
902
|
+
ticketSecret: createHmac("sha512", apiToken).update("forgezero/realtime/ticket/v1").digest("base64url")
|
|
903
|
+
});
|
|
860
904
|
async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fetch) {
|
|
861
905
|
const coordinates = validateCloudflareBootstrapCoordinates(input);
|
|
862
906
|
const absoluteOutput = resolve(outputPath);
|
|
@@ -874,10 +918,7 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
874
918
|
throw new Error("CF_TUNNEL_TOKEN and CF_API_TOKEN must be distinct least-privilege capabilities");
|
|
875
919
|
}
|
|
876
920
|
await preflightCloudflareKvRuntime(coordinates, apiToken, fetcher);
|
|
877
|
-
const realtime = coordinates.realtime ?
|
|
878
|
-
publishSecret: newRealtimeSecret(),
|
|
879
|
-
ticketSecret: newRealtimeSecret()
|
|
880
|
-
} : undefined;
|
|
921
|
+
const realtime = coordinates.realtime ? deriveCloudflareRealtimeSecrets(apiToken) : undefined;
|
|
881
922
|
if (coordinates.realtime && realtime) {
|
|
882
923
|
await verifyCloudflareWorkerDurableObjects({
|
|
883
924
|
accountId: coordinates.accountId,
|
|
@@ -892,8 +933,6 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
892
933
|
coordinates,
|
|
893
934
|
resources: {
|
|
894
935
|
kvNamespaceId: coordinates.kvNamespaceId,
|
|
895
|
-
apiToken,
|
|
896
|
-
realtime,
|
|
897
936
|
nodes: existing?.resources.nodes ?? []
|
|
898
937
|
}
|
|
899
938
|
});
|
|
@@ -912,7 +951,7 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
912
951
|
let resource;
|
|
913
952
|
let created = false;
|
|
914
953
|
if (checkpointed) {
|
|
915
|
-
if (checkpointed.hostname !== node.hostname || checkpointed.service !== node.service || checkpointed.tunnelName !== node.tunnelName || !UUID.test(checkpointed.tunnelId)
|
|
954
|
+
if (checkpointed.hostname !== node.hostname || checkpointed.service !== node.service || checkpointed.tunnelName !== node.tunnelName || !UUID.test(checkpointed.tunnelId)) {
|
|
916
955
|
throw new Error(`checkpointed Cloudflare node ${node.nodeName} is malformed`);
|
|
917
956
|
}
|
|
918
957
|
resource = checkpointed;
|
|
@@ -933,15 +972,13 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
933
972
|
}, fetcher);
|
|
934
973
|
mesh = {
|
|
935
974
|
...node.mesh,
|
|
936
|
-
connectorId: ensured.connector.id
|
|
937
|
-
connectorToken: ensured.connectorToken
|
|
975
|
+
connectorId: ensured.connector.id
|
|
938
976
|
};
|
|
939
977
|
}
|
|
940
978
|
const { mesh: _declaredMesh, ...publicNode } = node;
|
|
941
979
|
resource = {
|
|
942
980
|
...publicNode,
|
|
943
981
|
tunnelId: tunnel.tunnel.id,
|
|
944
|
-
connectorToken: tunnel.connectorToken,
|
|
945
982
|
...mesh ? { mesh } : {}
|
|
946
983
|
};
|
|
947
984
|
}
|
|
@@ -955,8 +992,6 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
955
992
|
coordinates,
|
|
956
993
|
resources: {
|
|
957
994
|
kvNamespaceId: coordinates.kvNamespaceId,
|
|
958
|
-
apiToken,
|
|
959
|
-
...realtime ? { realtime } : {},
|
|
960
995
|
nodes: [...nodeResources]
|
|
961
996
|
}
|
|
962
997
|
});
|
|
@@ -1000,8 +1035,6 @@ async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fet
|
|
|
1000
1035
|
coordinates,
|
|
1001
1036
|
resources: {
|
|
1002
1037
|
kvNamespaceId: coordinates.kvNamespaceId,
|
|
1003
|
-
apiToken,
|
|
1004
|
-
...realtime ? { realtime } : {},
|
|
1005
1038
|
nodes: nodeResources
|
|
1006
1039
|
},
|
|
1007
1040
|
created: {
|
|
@@ -1023,10 +1056,10 @@ async function runAttendedCloudflareBootstrap(request, dependencies = {}) {
|
|
|
1023
1056
|
nodes: plan.coordinates.nodes.map(({ nodeName, hostname }) => ({ nodeName, hostname }))
|
|
1024
1057
|
};
|
|
1025
1058
|
}
|
|
1026
|
-
if (!request.
|
|
1027
|
-
throw new Error("Cloudflare apply requires exactly two
|
|
1059
|
+
if (!request.tokens) {
|
|
1060
|
+
throw new Error("Cloudflare apply requires exactly two attended API tokens");
|
|
1028
1061
|
}
|
|
1029
|
-
const tokens =
|
|
1062
|
+
const tokens = validateCloudflareBootstrapTokens(request.tokens);
|
|
1030
1063
|
const output = await applyCloudflareBootstrap(plan.coordinates, tokens, plan.outputFile, dependencies.fetcher ?? fetch);
|
|
1031
1064
|
return {
|
|
1032
1065
|
format: 1,
|
|
@@ -1178,15 +1211,15 @@ async function finalizeCloudflareBootstrapAcceptance(request, fetcher = fetch) {
|
|
|
1178
1211
|
export {
|
|
1179
1212
|
writeOwnerBootstrapOutput,
|
|
1180
1213
|
verifyCloudflareBootstrapAcceptance,
|
|
1214
|
+
validateCloudflareBootstrapTokens,
|
|
1181
1215
|
validateCloudflareBootstrapCoordinates,
|
|
1182
1216
|
runAttendedCloudflareBootstrap,
|
|
1183
|
-
readOwnerApiToken,
|
|
1184
1217
|
readCloudflareHostHandoff,
|
|
1185
1218
|
readCloudflareConnectorHandoff,
|
|
1186
|
-
readCloudflareBootstrapTokens,
|
|
1187
1219
|
readCloudflareBootstrapAcceptanceEvidence,
|
|
1188
1220
|
planCloudflareBootstrap,
|
|
1189
1221
|
finalizeCloudflareBootstrapAcceptance,
|
|
1222
|
+
deriveCloudflareRealtimeSecrets,
|
|
1190
1223
|
cloudflareHostHandoffPath,
|
|
1191
1224
|
applyCloudflareBootstrap
|
|
1192
1225
|
};
|
|
@@ -7,6 +7,24 @@ export interface CloudflareEdgeConfig {
|
|
|
7
7
|
/** The single attended management capability used for both Tunnel and DNS writes. */
|
|
8
8
|
apiToken: string;
|
|
9
9
|
}
|
|
10
|
+
export interface CloudflareBootstrapDiscovery {
|
|
11
|
+
accountId: string;
|
|
12
|
+
zoneId: string;
|
|
13
|
+
kvNamespaceId: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve stable Cloudflare ids from owner-recognizable names without ever
|
|
17
|
+
* persisting or printing either token. The management token is used only for
|
|
18
|
+
* the exact zone lookup; the runtime token is used only for the existing KV
|
|
19
|
+
* namespace and optional Worker/DO ownership proof.
|
|
20
|
+
*/
|
|
21
|
+
export declare function discoverCloudflareBootstrapResources(config: {
|
|
22
|
+
zoneName: string;
|
|
23
|
+
kvNamespaceTitle: string;
|
|
24
|
+
workerScriptName?: string;
|
|
25
|
+
tunnelToken: string;
|
|
26
|
+
apiToken: string;
|
|
27
|
+
}, fetcher?: typeof fetch): Promise<CloudflareBootstrapDiscovery>;
|
|
10
28
|
export interface CloudflarePrivateRouteConfig {
|
|
11
29
|
accountId: string;
|
|
12
30
|
tunnelId: string;
|
|
@@ -166,4 +184,14 @@ export declare function ensureCloudflareTunnel(config: {
|
|
|
166
184
|
connectorToken: string;
|
|
167
185
|
created: boolean;
|
|
168
186
|
}>;
|
|
187
|
+
/** Retrieve node-scoped connector capabilities only in the target Agent process. */
|
|
188
|
+
export declare function retrieveCloudflareConnectorTokens(config: {
|
|
189
|
+
accountId: string;
|
|
190
|
+
tunnelId: string;
|
|
191
|
+
meshConnectorId?: string;
|
|
192
|
+
apiToken: string;
|
|
193
|
+
}, fetcher?: typeof fetch): Promise<{
|
|
194
|
+
connectorToken: string;
|
|
195
|
+
meshConnectorToken?: string;
|
|
196
|
+
}>;
|
|
169
197
|
export {};
|