@forgezero/agent 0.1.34 → 0.1.36
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 +50 -0
- package/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap.d.ts +133 -0
- package/dist/bootstrap.js +3416 -0
- package/dist/cli/agent-install.d.ts +3 -0
- package/dist/cli/cloudflare-bootstrap.d.ts +11 -0
- package/dist/cloudflare-bootstrap.d.ts +218 -0
- package/dist/cloudflare-bootstrap.js +1196 -0
- package/dist/cloudflare-edge.d.ts +265 -0
- package/dist/cloudflare-edge.js +424 -0
- package/dist/definition.js +9 -2
- package/dist/deploy-file.js +9 -2
- package/dist/fz-agent.js +492 -150
- package/dist/fz.js +3833 -262
- package/dist/index.d.ts +2 -0
- package/dist/metal-bootstrap.d.ts +52 -0
- package/dist/metal-bootstrap.js +942 -0
- package/dist/platform-bootstrap-runtime.d.ts +161 -0
- package/dist/platform-bootstrap-runtime.js +462 -0
- package/dist/provision.d.ts +4 -0
- package/dist/provision.js +34 -6
- package/dist/software-helper.js +9 -2
- package/dist/software.d.ts +3 -1
- package/dist/software.js +12 -3
- package/dist/ssh-bootstrap.d.ts +72 -0
- package/dist/version.d.ts +1 -1
- package/package.json +22 -2
- package/schema/deploy-v2.json +1 -1
|
@@ -0,0 +1,1196 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/cloudflare-edge.ts
|
|
3
|
+
import { isIP } from "net";
|
|
4
|
+
function isPrivateDatabaseAddress(value) {
|
|
5
|
+
const address = value.trim().toLowerCase();
|
|
6
|
+
const family = isIP(address);
|
|
7
|
+
if (family === 4) {
|
|
8
|
+
const [a, b] = address.split(".").map(Number);
|
|
9
|
+
return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
|
|
10
|
+
}
|
|
11
|
+
if (family === 6) {
|
|
12
|
+
const first = Number.parseInt(address.split(":", 1)[0], 16);
|
|
13
|
+
return Number.isFinite(first) && (first & 65024) === 64512;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
function privateDatabaseHostRoute(value) {
|
|
18
|
+
const address = value.trim().toLowerCase();
|
|
19
|
+
if (!isPrivateDatabaseAddress(address)) {
|
|
20
|
+
throw new Error("database address must be an RFC 1918 IPv4 or unique-local IPv6 address");
|
|
21
|
+
}
|
|
22
|
+
return `${address}/${isIP(address) === 4 ? 32 : 128}`;
|
|
23
|
+
}
|
|
24
|
+
var endpoint = "https://api.cloudflare.com/client/v4";
|
|
25
|
+
async function cf(config, path, init = {}, fetcher = fetch) {
|
|
26
|
+
const response = await fetcher(`${endpoint}${path}`, {
|
|
27
|
+
...init,
|
|
28
|
+
headers: {
|
|
29
|
+
authorization: `Bearer ${config.apiToken}`,
|
|
30
|
+
"content-type": "application/json",
|
|
31
|
+
...init.headers
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
const body = await response.json();
|
|
35
|
+
if (!response.ok || body.success !== true) {
|
|
36
|
+
throw new Error(body.errors?.map(({ message }) => message).filter(Boolean).join("; ") || `Cloudflare returned HTTP ${response.status}`);
|
|
37
|
+
}
|
|
38
|
+
return body.result;
|
|
39
|
+
}
|
|
40
|
+
async function ensureCloudflarePrivateRoute(config, fetcher = fetch) {
|
|
41
|
+
const [address, prefixText, ...extra] = config.network.split("/");
|
|
42
|
+
const family = isIP(address ?? "");
|
|
43
|
+
const prefix = Number(prefixText);
|
|
44
|
+
if (extra.length > 0 || !family || !Number.isInteger(prefix) || prefix < 0 || prefix > (family === 4 ? 32 : 128)) {
|
|
45
|
+
throw new Error("Cloudflare private route must be an explicit IPv4 or IPv6 CIDR");
|
|
46
|
+
}
|
|
47
|
+
const path = `/accounts/${config.accountId}/teamnet/routes`;
|
|
48
|
+
const routes = await cf(config, path, {}, fetcher);
|
|
49
|
+
const current = routes.find((route2) => !route2.deleted_at && route2.network === config.network && (route2.virtual_network_id ?? "") === (config.virtualNetworkId ?? ""));
|
|
50
|
+
if (current) {
|
|
51
|
+
if (current.tunnel_id !== config.tunnelId) {
|
|
52
|
+
throw new Error(`private route ${config.network} already belongs to another Tunnel`);
|
|
53
|
+
}
|
|
54
|
+
return { route: current, created: false };
|
|
55
|
+
}
|
|
56
|
+
const route = await cf(config, path, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
network: config.network,
|
|
60
|
+
tunnel_id: config.tunnelId,
|
|
61
|
+
comment: config.comment.slice(0, 100),
|
|
62
|
+
...config.virtualNetworkId ? { virtual_network_id: config.virtualNetworkId } : {}
|
|
63
|
+
})
|
|
64
|
+
}, fetcher);
|
|
65
|
+
return { route, created: true };
|
|
66
|
+
}
|
|
67
|
+
async function ensureCloudflarePrivateDatabaseRoute(config, fetcher = fetch) {
|
|
68
|
+
return ensureCloudflarePrivateRoute({
|
|
69
|
+
...config,
|
|
70
|
+
network: privateDatabaseHostRoute(config.privateAddress)
|
|
71
|
+
}, fetcher);
|
|
72
|
+
}
|
|
73
|
+
async function removeCloudflarePrivateRoute(config, fetcher = fetch) {
|
|
74
|
+
const [address, prefixText, ...extra] = config.network.split("/");
|
|
75
|
+
const family = isIP(address ?? "");
|
|
76
|
+
const prefix = Number(prefixText);
|
|
77
|
+
if (extra.length > 0 || !family || !Number.isInteger(prefix) || prefix < 0 || prefix > (family === 4 ? 32 : 128)) {
|
|
78
|
+
throw new Error("Cloudflare private route must be an explicit IPv4 or IPv6 CIDR");
|
|
79
|
+
}
|
|
80
|
+
const path = `/accounts/${config.accountId}/teamnet/routes`;
|
|
81
|
+
const routes = await cf(config, path, {}, fetcher);
|
|
82
|
+
const current = routes.find((route) => !route.deleted_at && route.network === config.network && (route.virtual_network_id ?? "") === (config.virtualNetworkId ?? ""));
|
|
83
|
+
if (!current)
|
|
84
|
+
return { removed: false };
|
|
85
|
+
if (current.tunnel_id !== config.tunnelId) {
|
|
86
|
+
throw new Error(`private route ${config.network} belongs to another Tunnel`);
|
|
87
|
+
}
|
|
88
|
+
await cf(config, `${path}/${encodeURIComponent(current.id)}`, { method: "DELETE" }, fetcher);
|
|
89
|
+
return { removed: true, route: current };
|
|
90
|
+
}
|
|
91
|
+
async function removeCloudflarePrivateDatabaseRoute(config, fetcher = fetch) {
|
|
92
|
+
return removeCloudflarePrivateRoute({
|
|
93
|
+
...config,
|
|
94
|
+
network: privateDatabaseHostRoute(config.privateAddress)
|
|
95
|
+
}, fetcher);
|
|
96
|
+
}
|
|
97
|
+
async function ensureCloudflareWarpDatabaseInclude(config, fetcher = fetch) {
|
|
98
|
+
if (config.policyId && !/^[A-Za-z0-9-]{1,64}$/.test(config.policyId))
|
|
99
|
+
throw new Error("Cloudflare WARP policy id is invalid");
|
|
100
|
+
const route = privateDatabaseHostRoute(config.privateAddress);
|
|
101
|
+
const policy = config.policyId ? `/${config.policyId}` : "";
|
|
102
|
+
const path = `/accounts/${config.accountId}/devices/policy${policy}/include`;
|
|
103
|
+
const entries = await cf(config, path, {}, fetcher);
|
|
104
|
+
if (entries.some((entry) => entry.address === route))
|
|
105
|
+
return { entries, created: false };
|
|
106
|
+
const next = [...entries, { address: route, description: config.description.slice(0, 100) }];
|
|
107
|
+
const updated = await cf(config, path, {
|
|
108
|
+
method: "PUT",
|
|
109
|
+
body: JSON.stringify(next)
|
|
110
|
+
}, fetcher);
|
|
111
|
+
return { entries: updated, created: true };
|
|
112
|
+
}
|
|
113
|
+
async function removeCloudflareWarpDatabaseInclude(config, fetcher = fetch) {
|
|
114
|
+
if (config.policyId && !/^[A-Za-z0-9-]{1,64}$/.test(config.policyId))
|
|
115
|
+
throw new Error("Cloudflare WARP policy id is invalid");
|
|
116
|
+
const route = privateDatabaseHostRoute(config.privateAddress);
|
|
117
|
+
const policy = config.policyId ? `/${config.policyId}` : "";
|
|
118
|
+
const path = `/accounts/${config.accountId}/devices/policy${policy}/include`;
|
|
119
|
+
const entries = await cf(config, path, {}, fetcher);
|
|
120
|
+
const next = entries.filter((entry) => entry.address !== route);
|
|
121
|
+
if (next.length === entries.length)
|
|
122
|
+
return { entries, removed: false };
|
|
123
|
+
const updated = await cf(config, path, {
|
|
124
|
+
method: "PUT",
|
|
125
|
+
body: JSON.stringify(next)
|
|
126
|
+
}, fetcher);
|
|
127
|
+
return { entries: updated, removed: true };
|
|
128
|
+
}
|
|
129
|
+
async function configureCloudflareEdge(config, fetcher = fetch) {
|
|
130
|
+
const tunnelAuth = { apiToken: config.tunnelApiToken?.trim() || config.apiToken };
|
|
131
|
+
const dnsAuth = { apiToken: config.dnsApiToken?.trim() || config.apiToken };
|
|
132
|
+
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
|
+
const dnsPath = `/zones/${config.zoneId}/dns_records`;
|
|
146
|
+
const records = await cf(dnsAuth, `${dnsPath}?type=CNAME&name=${encodeURIComponent(config.hostname)}&per_page=1000`, {}, fetcher);
|
|
147
|
+
if (records.length > 1) {
|
|
148
|
+
throw new Error(`Cloudflare DNS record for ${config.hostname} is ambiguous`);
|
|
149
|
+
}
|
|
150
|
+
const record = {
|
|
151
|
+
type: "CNAME",
|
|
152
|
+
name: config.hostname,
|
|
153
|
+
content: `${config.tunnelId}.cfargotunnel.com`,
|
|
154
|
+
proxied: true,
|
|
155
|
+
ttl: 1
|
|
156
|
+
};
|
|
157
|
+
await cf(dnsAuth, records[0] ? `${dnsPath}/${records[0].id}` : dnsPath, {
|
|
158
|
+
method: records[0] ? "PUT" : "POST",
|
|
159
|
+
body: JSON.stringify(record)
|
|
160
|
+
}, fetcher);
|
|
161
|
+
}
|
|
162
|
+
var exactAccountPermissionGroup = async (config, name, fetcher) => {
|
|
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");
|
|
371
|
+
}
|
|
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
|
+
}
|
|
385
|
+
async function ensureCloudflareTunnel(config, fetcher = fetch) {
|
|
386
|
+
const name = config.name.trim();
|
|
387
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(name)) {
|
|
388
|
+
throw new Error("Cloudflare Tunnel name is invalid");
|
|
389
|
+
}
|
|
390
|
+
const path = `/accounts/${config.accountId}/cfd_tunnel`;
|
|
391
|
+
const tunnels = await cf(config, `${path}?is_deleted=false&name=${encodeURIComponent(name)}&per_page=1000`, {}, fetcher);
|
|
392
|
+
const matches = tunnels.filter((tunnel2) => tunnel2.name === name && !tunnel2.deleted_at);
|
|
393
|
+
if (matches.length > 1)
|
|
394
|
+
throw new Error(`Cloudflare Tunnel ${name} is ambiguous`);
|
|
395
|
+
const created = !matches[0];
|
|
396
|
+
const tunnel = matches[0] ?? await cf(config, path, {
|
|
397
|
+
method: "POST",
|
|
398
|
+
body: JSON.stringify({ name, config_src: "cloudflare" })
|
|
399
|
+
}, fetcher);
|
|
400
|
+
const connectorToken = await cf(config, `${path}/${encodeURIComponent(tunnel.id)}/token`, {}, fetcher);
|
|
401
|
+
if (!connectorToken || connectorToken.length > 16384) {
|
|
402
|
+
throw new Error("Cloudflare returned an invalid Tunnel connector token");
|
|
403
|
+
}
|
|
404
|
+
return { tunnel, connectorToken, created };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// src/cloudflare-bootstrap.ts
|
|
408
|
+
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
|
+
import { randomUUID } from "crypto";
|
|
413
|
+
import { isIP as isIP2 } from "net";
|
|
414
|
+
var ownerUid = () => typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
415
|
+
async function assertOwnerOnlyHandle(path, handle, maximumBytes) {
|
|
416
|
+
const metadata = await handle.stat();
|
|
417
|
+
if (!metadata.isFile())
|
|
418
|
+
throw new Error(`${path} must be a regular file`);
|
|
419
|
+
if (metadata.nlink !== 1)
|
|
420
|
+
throw new Error(`${path} must not have multiple hard links`);
|
|
421
|
+
const uid = ownerUid();
|
|
422
|
+
if (uid !== undefined && uid !== 0 && metadata.uid !== uid)
|
|
423
|
+
throw new Error(`${path} must be owned by the current operator`);
|
|
424
|
+
if ((metadata.mode & 63) !== 0)
|
|
425
|
+
throw new Error(`${path} must not be accessible by group or other users`);
|
|
426
|
+
if ((metadata.mode & 256) === 0)
|
|
427
|
+
throw new Error(`${path} must be readable by its owner`);
|
|
428
|
+
if (metadata.size < 1 || metadata.size > maximumBytes)
|
|
429
|
+
throw new Error(`${path} has an invalid size`);
|
|
430
|
+
}
|
|
431
|
+
async function readOwnerOnlyFile(path, maximumBytes) {
|
|
432
|
+
const absolute = resolve(path);
|
|
433
|
+
let handle;
|
|
434
|
+
try {
|
|
435
|
+
handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
436
|
+
await assertOwnerOnlyHandle(absolute, handle, maximumBytes);
|
|
437
|
+
return await handle.readFile({ encoding: "utf8" });
|
|
438
|
+
} catch (cause) {
|
|
439
|
+
if (cause instanceof Error && cause.message.startsWith(absolute))
|
|
440
|
+
throw cause;
|
|
441
|
+
throw new Error(`cannot securely read owner-only file ${absolute}`);
|
|
442
|
+
} finally {
|
|
443
|
+
await handle?.close();
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
async function readOwnerApiToken(path) {
|
|
447
|
+
const token = (await readOwnerOnlyFile(path, 4096)).trim();
|
|
448
|
+
if (!/^[A-Za-z0-9._-]{40,80}$/.test(token)) {
|
|
449
|
+
throw new Error(`${resolve(path)} must contain exactly one Cloudflare API token`);
|
|
450
|
+
}
|
|
451
|
+
return token;
|
|
452
|
+
}
|
|
453
|
+
async function readCloudflareBootstrapTokens(files) {
|
|
454
|
+
const entries = await Promise.all([
|
|
455
|
+
["apiToken", files.apiTokenFile],
|
|
456
|
+
["tunnelApiToken", files.tunnelApiTokenFile],
|
|
457
|
+
["dnsApiToken", files.dnsApiTokenFile],
|
|
458
|
+
["kvApiToken", files.kvApiTokenFile],
|
|
459
|
+
["accessApiToken", files.accessApiTokenFile],
|
|
460
|
+
["workerApiToken", files.workerApiTokenFile]
|
|
461
|
+
].map(async ([key, path]) => [key, path ? await readOwnerApiToken(path) : undefined]));
|
|
462
|
+
const tokens = Object.fromEntries(entries.filter(([, value]) => value !== undefined));
|
|
463
|
+
const unified = tokens.apiToken;
|
|
464
|
+
for (const key of ["tunnelApiToken", "dnsApiToken", "kvApiToken", "accessApiToken", "workerApiToken"]) {
|
|
465
|
+
if (!tokens[key] && !unified)
|
|
466
|
+
throw new Error(`Cloudflare ${key} file is required when --token-file is omitted`);
|
|
467
|
+
}
|
|
468
|
+
return tokens;
|
|
469
|
+
}
|
|
470
|
+
var validateId = (value, label) => {
|
|
471
|
+
const normalized = value.trim().toLowerCase();
|
|
472
|
+
if (!/^[a-f0-9]{32}$/.test(normalized))
|
|
473
|
+
throw new Error(`${label} must be a 32-character hexadecimal id`);
|
|
474
|
+
return normalized;
|
|
475
|
+
};
|
|
476
|
+
var validateName = (value, label, maximum, allowSpaces = true) => {
|
|
477
|
+
const normalized = value.trim();
|
|
478
|
+
const pattern = allowSpaces ? /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/ : /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
479
|
+
if (!normalized || normalized.length > maximum || !pattern.test(normalized)) {
|
|
480
|
+
throw new Error(`${label} is invalid`);
|
|
481
|
+
}
|
|
482
|
+
return normalized;
|
|
483
|
+
};
|
|
484
|
+
var privateAddress = (value) => {
|
|
485
|
+
const address = value.trim().toLowerCase();
|
|
486
|
+
const family = isIP2(address);
|
|
487
|
+
if (family === 4) {
|
|
488
|
+
const [a, b] = address.split(".").map(Number);
|
|
489
|
+
if (a === 10 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31)
|
|
490
|
+
return address;
|
|
491
|
+
}
|
|
492
|
+
if (family === 6) {
|
|
493
|
+
const first = Number.parseInt(address.split(":", 1)[0], 16);
|
|
494
|
+
if (Number.isFinite(first) && (first & 65024) === 64512)
|
|
495
|
+
return address;
|
|
496
|
+
}
|
|
497
|
+
throw new Error("Cloudflare private database address must be RFC 1918 IPv4 or unique-local IPv6");
|
|
498
|
+
};
|
|
499
|
+
function validateCloudflareBootstrapCoordinates(input) {
|
|
500
|
+
const nodeInputs = input.nodes?.length ? input.nodes : [{
|
|
501
|
+
nodeName: input.tunnelName,
|
|
502
|
+
hostname: input.hostname,
|
|
503
|
+
service: input.service,
|
|
504
|
+
tunnelName: input.tunnelName,
|
|
505
|
+
applicationName: input.applicationName
|
|
506
|
+
}];
|
|
507
|
+
if (nodeInputs.length < 1 || nodeInputs.length > 32) {
|
|
508
|
+
throw new Error("Cloudflare bootstrap requires between 1 and 32 explicit nodes");
|
|
509
|
+
}
|
|
510
|
+
const nodes = nodeInputs.map((node) => {
|
|
511
|
+
const nodeName = node.nodeName.trim().toLowerCase();
|
|
512
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(nodeName))
|
|
513
|
+
throw new Error("Cloudflare node name is invalid");
|
|
514
|
+
const hostname = node.hostname.trim().toLowerCase();
|
|
515
|
+
if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname)) {
|
|
516
|
+
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
|
+
return {
|
|
523
|
+
nodeName,
|
|
524
|
+
hostname,
|
|
525
|
+
service: serviceUrl.toString().replace(/\/$/, ""),
|
|
526
|
+
tunnelName: validateName(node.tunnelName, "Cloudflare Tunnel name", 100, false),
|
|
527
|
+
applicationName: validateName(node.applicationName, "Cloudflare Access application name", 100),
|
|
528
|
+
...node.privateAddress ? { privateAddress: privateAddress(node.privateAddress) } : {}
|
|
529
|
+
};
|
|
530
|
+
});
|
|
531
|
+
for (const [label, values] of [
|
|
532
|
+
["node name", nodes.map(({ nodeName }) => nodeName)],
|
|
533
|
+
["hostname", nodes.map(({ hostname }) => hostname)],
|
|
534
|
+
["Tunnel name", nodes.map(({ tunnelName }) => tunnelName)],
|
|
535
|
+
["Access application name", nodes.map(({ applicationName }) => applicationName)]
|
|
536
|
+
]) {
|
|
537
|
+
if (new Set(values).size !== values.length)
|
|
538
|
+
throw new Error(`Cloudflare fleet ${label} must be unique`);
|
|
539
|
+
}
|
|
540
|
+
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
|
+
return {
|
|
578
|
+
accountId: validateId(input.accountId, "Cloudflare account id"),
|
|
579
|
+
zoneId: validateId(input.zoneId, "Cloudflare zone id"),
|
|
580
|
+
hostname: first.hostname,
|
|
581
|
+
service: first.service,
|
|
582
|
+
tunnelName: first.tunnelName,
|
|
583
|
+
kvNamespaceTitle: validateName(input.kvNamespaceTitle, "Cloudflare KV namespace title", 128, false),
|
|
584
|
+
workerScriptName,
|
|
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 } : {}
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
function planCloudflareBootstrap(input, outputPath) {
|
|
600
|
+
const coordinates = validateCloudflareBootstrapCoordinates(input);
|
|
601
|
+
return {
|
|
602
|
+
format: 1,
|
|
603
|
+
kind: "forgezero-cloudflare-bootstrap-plan",
|
|
604
|
+
mode: "attended-token-file",
|
|
605
|
+
outputFile: resolve(outputPath),
|
|
606
|
+
coordinates,
|
|
607
|
+
operations: [
|
|
608
|
+
"create or reuse one Workers KV namespace",
|
|
609
|
+
"create or reuse one remotely-managed Tunnel per node and checkpoint every connector token",
|
|
610
|
+
...coordinates.createRuntimeTokens ? [
|
|
611
|
+
"create exact-account least-privilege runtime tokens and checkpoint their one-time values"
|
|
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"
|
|
620
|
+
],
|
|
621
|
+
secrets: [
|
|
622
|
+
"API tokens are read only from owner-only files and are never written to output",
|
|
623
|
+
"the output contains connector, Access and requested runtime credentials and is atomically written with mode 0600",
|
|
624
|
+
"the normal API process does not receive or import the management token files"
|
|
625
|
+
]
|
|
626
|
+
};
|
|
627
|
+
}
|
|
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
|
+
async function readExistingOutput(path) {
|
|
723
|
+
try {
|
|
724
|
+
await lstat(path);
|
|
725
|
+
} catch (cause) {
|
|
726
|
+
if (cause.code === "ENOENT")
|
|
727
|
+
return;
|
|
728
|
+
throw cause;
|
|
729
|
+
}
|
|
730
|
+
const text = await readOwnerOnlyFile(path, 1048576);
|
|
731
|
+
let output;
|
|
732
|
+
try {
|
|
733
|
+
output = JSON.parse(text);
|
|
734
|
+
} catch {
|
|
735
|
+
throw new Error(`${resolve(path)} is not valid bootstrap JSON`);
|
|
736
|
+
}
|
|
737
|
+
if (output.format !== 1 || output.kind !== "forgezero-cloudflare-bootstrap" || !output.resources) {
|
|
738
|
+
throw new Error(`${resolve(path)} is not a ForgeZero Cloudflare bootstrap output`);
|
|
739
|
+
}
|
|
740
|
+
return output;
|
|
741
|
+
}
|
|
742
|
+
async function readCloudflareConnectorHandoff(checkpointPath, nodeName) {
|
|
743
|
+
const output = await readExistingOutput(resolve(checkpointPath));
|
|
744
|
+
if (!output || output.phase !== "complete") {
|
|
745
|
+
throw new Error("Cloudflare connector handoff requires a completed bootstrap checkpoint");
|
|
746
|
+
}
|
|
747
|
+
const normalizedNodeName = nodeName.trim().toLowerCase();
|
|
748
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(normalizedNodeName)) {
|
|
749
|
+
throw new Error("Cloudflare connector handoff node name is invalid");
|
|
750
|
+
}
|
|
751
|
+
const coordinates = validateCloudflareBootstrapCoordinates(output.coordinates);
|
|
752
|
+
const expected = coordinates.nodes.find((node) => node.nodeName === normalizedNodeName);
|
|
753
|
+
const matches = output.resources.nodes?.filter((node) => node.nodeName === normalizedNodeName) ?? [];
|
|
754
|
+
if (!expected || matches.length !== 1) {
|
|
755
|
+
throw new Error(`Cloudflare connector handoff has no unique completed node ${normalizedNodeName}`);
|
|
756
|
+
}
|
|
757
|
+
const resource = matches[0];
|
|
758
|
+
if (resource.hostname !== expected.hostname || resource.service !== expected.service || resource.tunnelName !== expected.tunnelName || !resource.applicationId || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(resource.tunnelId) || !/^[A-Za-z0-9._-]{40,16384}$/.test(resource.connectorToken)) {
|
|
759
|
+
throw new Error(`Cloudflare connector handoff for ${normalizedNodeName} is malformed or incomplete`);
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
nodeName: resource.nodeName,
|
|
763
|
+
hostname: resource.hostname,
|
|
764
|
+
service: resource.service,
|
|
765
|
+
tunnelId: resource.tunnelId,
|
|
766
|
+
connectorToken: resource.connectorToken
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
async function readCloudflareHostHandoff(checkpointPath, nodeName) {
|
|
770
|
+
const connector = await readCloudflareConnectorHandoff(checkpointPath, nodeName);
|
|
771
|
+
const output = await readExistingOutput(resolve(checkpointPath));
|
|
772
|
+
const kv = output?.resources.runtimeTokens?.kv;
|
|
773
|
+
if (!output || output.phase !== "complete" || !/^[a-f0-9]{32}$/i.test(output.coordinates.accountId) || !/^[a-f0-9]{32}$/i.test(output.coordinates.zoneId) || !/^[a-f0-9]{32}$/i.test(output.resources.kvNamespaceId) || !kv || !/^[A-Za-z0-9._-]{40,80}$/.test(kv.value)) {
|
|
774
|
+
throw new Error("Cloudflare host handoff is missing the exact-account KV runtime capability");
|
|
775
|
+
}
|
|
776
|
+
const network = output.resources.runtimeTokens?.privateNetwork?.value;
|
|
777
|
+
if (network !== undefined && !/^[A-Za-z0-9._-]{40,80}$/.test(network)) {
|
|
778
|
+
throw new Error("Cloudflare host handoff private-network capability is malformed");
|
|
779
|
+
}
|
|
780
|
+
const privateNetwork = output.resources.privateNetwork;
|
|
781
|
+
const access = output.resources.access;
|
|
782
|
+
if (Boolean(privateNetwork) !== Boolean(network)) {
|
|
783
|
+
throw new Error("Cloudflare host handoff private-network resources and capability disagree");
|
|
784
|
+
}
|
|
785
|
+
if (privateNetwork && (!access?.clientId || !access.clientSecret || !/^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/.test(privateNetwork.warpOrganization) || !/^[0-9a-f-]{36}$/i.test(privateNetwork.virtualNetworkId) || !privateNetwork.deviceProfileId)) {
|
|
786
|
+
throw new Error("Cloudflare host handoff WARP enrollment is malformed");
|
|
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
|
+
};
|
|
803
|
+
}
|
|
804
|
+
async function prepareOwnerOutputDirectory(absolutePath) {
|
|
805
|
+
const directory = dirname(absolutePath);
|
|
806
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
807
|
+
const directoryMetadata = await stat(directory);
|
|
808
|
+
const uid = ownerUid();
|
|
809
|
+
if (!directoryMetadata.isDirectory() || uid !== undefined && directoryMetadata.uid !== uid || (directoryMetadata.mode & 18) !== 0) {
|
|
810
|
+
throw new Error(`bootstrap output directory ${directory} must be operator-owned and not group/other writable`);
|
|
811
|
+
}
|
|
812
|
+
return directory;
|
|
813
|
+
}
|
|
814
|
+
async function writeOwnerBootstrapOutput(path, output) {
|
|
815
|
+
const absolute = resolve(path);
|
|
816
|
+
const directory = await prepareOwnerOutputDirectory(absolute);
|
|
817
|
+
const temporary = `${absolute}.${randomUUID()}.tmp`;
|
|
818
|
+
let handle;
|
|
819
|
+
try {
|
|
820
|
+
handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
|
|
821
|
+
await handle.writeFile(`${JSON.stringify(output, null, 2)}
|
|
822
|
+
`, { encoding: "utf8" });
|
|
823
|
+
await handle.sync();
|
|
824
|
+
await handle.close();
|
|
825
|
+
handle = undefined;
|
|
826
|
+
await rename(temporary, absolute);
|
|
827
|
+
await chmod(absolute, 384);
|
|
828
|
+
const directoryHandle = await open(directory, constants.O_RDONLY);
|
|
829
|
+
try {
|
|
830
|
+
await directoryHandle.sync();
|
|
831
|
+
} finally {
|
|
832
|
+
await directoryHandle.close();
|
|
833
|
+
}
|
|
834
|
+
} finally {
|
|
835
|
+
await handle?.close();
|
|
836
|
+
await unlink(temporary).catch((cause) => {
|
|
837
|
+
if (cause.code !== "ENOENT")
|
|
838
|
+
throw cause;
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
var tokenFor = (tokens, key) => {
|
|
843
|
+
const token = tokens[key]?.trim() || tokens.apiToken?.trim();
|
|
844
|
+
if (!token)
|
|
845
|
+
throw new Error(`Cloudflare ${key} is not configured`);
|
|
846
|
+
return token;
|
|
847
|
+
};
|
|
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
|
+
var sameCoordinates = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
856
|
+
async function applyCloudflareBootstrap(input, tokens, outputPath, fetcher = fetch, workerRunner = defaultWorkerCommandRunner) {
|
|
857
|
+
const coordinates = validateCloudflareBootstrapCoordinates(input);
|
|
858
|
+
const absoluteOutput = resolve(outputPath);
|
|
859
|
+
const existing = await readExistingOutput(absoluteOutput);
|
|
860
|
+
if (existing && !sameCoordinates(existing.coordinates, coordinates)) {
|
|
861
|
+
throw new Error("bootstrap output belongs to different Cloudflare coordinates; choose a different output file");
|
|
862
|
+
}
|
|
863
|
+
await prepareOwnerOutputDirectory(absoluteOutput);
|
|
864
|
+
const namespace = await ensureCloudflareKvNamespace({
|
|
865
|
+
accountId: coordinates.accountId,
|
|
866
|
+
title: coordinates.kvNamespaceTitle,
|
|
867
|
+
apiToken: tokenFor(tokens, "kvApiToken")
|
|
868
|
+
}, fetcher);
|
|
869
|
+
const nodeResources = [];
|
|
870
|
+
const createdNodes = [];
|
|
871
|
+
let resources;
|
|
872
|
+
for (const node of coordinates.nodes) {
|
|
873
|
+
const checkpointed = existing?.resources.nodes?.find(({ nodeName }) => nodeName === node.nodeName);
|
|
874
|
+
let created = false;
|
|
875
|
+
if (checkpointed) {
|
|
876
|
+
nodeResources.push(checkpointed);
|
|
877
|
+
} else {
|
|
878
|
+
const tunnel = await ensureCloudflareTunnel({
|
|
879
|
+
accountId: coordinates.accountId,
|
|
880
|
+
name: node.tunnelName,
|
|
881
|
+
apiToken: tokenFor(tokens, "tunnelApiToken")
|
|
882
|
+
}, fetcher);
|
|
883
|
+
created = tunnel.created;
|
|
884
|
+
nodeResources.push({
|
|
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 });
|
|
1055
|
+
}
|
|
1056
|
+
resources = {
|
|
1057
|
+
...resources,
|
|
1058
|
+
privateNetwork: {
|
|
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;
|
|
1067
|
+
await writeOwnerBootstrapOutput(absoluteOutput, {
|
|
1068
|
+
format: 1,
|
|
1069
|
+
kind: "forgezero-cloudflare-bootstrap",
|
|
1070
|
+
phase: "access-token-provisioned",
|
|
1071
|
+
updatedAt: new Date().toISOString(),
|
|
1072
|
+
coordinates,
|
|
1073
|
+
resources
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
for (const node of coordinates.nodes) {
|
|
1077
|
+
const application = await ensureCloudflareAccessApplication({
|
|
1078
|
+
accountId: coordinates.accountId,
|
|
1079
|
+
name: node.applicationName,
|
|
1080
|
+
hostname: node.hostname,
|
|
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 } : {}
|
|
1092
|
+
}
|
|
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
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
await configureCloudflareWorkerAccessSecrets({
|
|
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) {
|
|
1112
|
+
await configureCloudflareEdge({
|
|
1113
|
+
accountId: coordinates.accountId,
|
|
1114
|
+
zoneId: coordinates.zoneId,
|
|
1115
|
+
tunnelId: node.tunnelId,
|
|
1116
|
+
hostname: node.hostname,
|
|
1117
|
+
service: node.service,
|
|
1118
|
+
apiToken: tokenFor(tokens, "tunnelApiToken"),
|
|
1119
|
+
tunnelApiToken: tokenFor(tokens, "tunnelApiToken"),
|
|
1120
|
+
dnsApiToken: tokenFor(tokens, "dnsApiToken")
|
|
1121
|
+
}, fetcher);
|
|
1122
|
+
}
|
|
1123
|
+
const output = {
|
|
1124
|
+
format: 1,
|
|
1125
|
+
kind: "forgezero-cloudflare-bootstrap",
|
|
1126
|
+
phase: "complete",
|
|
1127
|
+
updatedAt: new Date().toISOString(),
|
|
1128
|
+
coordinates,
|
|
1129
|
+
resources,
|
|
1130
|
+
created: {
|
|
1131
|
+
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
|
+
nodes: createdNodes
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
1141
|
+
await writeOwnerBootstrapOutput(absoluteOutput, output);
|
|
1142
|
+
return output;
|
|
1143
|
+
}
|
|
1144
|
+
async function runAttendedCloudflareBootstrap(request, dependencies = {}) {
|
|
1145
|
+
const plan = planCloudflareBootstrap(request.coordinates, request.checkpointPath);
|
|
1146
|
+
if (request.mode === "plan") {
|
|
1147
|
+
return {
|
|
1148
|
+
format: 1,
|
|
1149
|
+
kind: "forgezero-cloudflare-bootstrap-evidence",
|
|
1150
|
+
phase: "planned",
|
|
1151
|
+
checkpointFile: plan.outputFile,
|
|
1152
|
+
workerScriptName: plan.coordinates.workerScriptName,
|
|
1153
|
+
publicDomains: plan.coordinates.publicDomains,
|
|
1154
|
+
nodes: plan.coordinates.nodes.map(({ nodeName, hostname }) => ({ nodeName, hostname }))
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
if (!request.tokenFiles || !Object.values(request.tokenFiles).some(Boolean)) {
|
|
1158
|
+
throw new Error("Cloudflare apply requires owner-only management token file paths");
|
|
1159
|
+
}
|
|
1160
|
+
if (plan.coordinates.createRuntimeTokens && !request.tokenFiles.apiTokenFile) {
|
|
1161
|
+
throw new Error("Cloudflare runtime-token creation requires the initial management token file");
|
|
1162
|
+
}
|
|
1163
|
+
const tokens = await readCloudflareBootstrapTokens(request.tokenFiles);
|
|
1164
|
+
const output = await applyCloudflareBootstrap(plan.coordinates, tokens, plan.outputFile, dependencies.fetcher ?? fetch, dependencies.workerRunner);
|
|
1165
|
+
return {
|
|
1166
|
+
format: 1,
|
|
1167
|
+
kind: "forgezero-cloudflare-bootstrap-evidence",
|
|
1168
|
+
phase: "complete",
|
|
1169
|
+
checkpointFile: plan.outputFile,
|
|
1170
|
+
kvNamespaceId: output.resources.kvNamespaceId,
|
|
1171
|
+
workerScriptName: output.coordinates.workerScriptName,
|
|
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 }) => ({
|
|
1178
|
+
nodeName,
|
|
1179
|
+
hostname,
|
|
1180
|
+
tunnelId,
|
|
1181
|
+
applicationId
|
|
1182
|
+
}))
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
export {
|
|
1186
|
+
writeOwnerBootstrapOutput,
|
|
1187
|
+
validateCloudflareBootstrapCoordinates,
|
|
1188
|
+
runAttendedCloudflareBootstrap,
|
|
1189
|
+
readOwnerApiToken,
|
|
1190
|
+
readCloudflareHostHandoff,
|
|
1191
|
+
readCloudflareConnectorHandoff,
|
|
1192
|
+
readCloudflareBootstrapTokens,
|
|
1193
|
+
planCloudflareBootstrap,
|
|
1194
|
+
deployCloudflareWorker,
|
|
1195
|
+
applyCloudflareBootstrap
|
|
1196
|
+
};
|