@forgezero/agent 0.1.33 → 0.1.35
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 +21 -0
- package/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap.d.ts +128 -0
- package/dist/bootstrap.js +3361 -0
- package/dist/capacity-calibration.d.ts +51 -0
- package/dist/capacity-calibration.js +119 -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.d.ts +3 -0
- package/dist/definition.js +174 -2
- package/dist/deploy-file.js +174 -2
- package/dist/deployment.d.ts +11 -0
- package/dist/fz-agent.js +1855 -3370
- package/dist/fz.js +8092 -6437
- 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.js +5 -4
- package/dist/version.d.ts +1 -1
- package/package.json +26 -2
- package/schema/deploy-v2.json +14 -0
|
@@ -0,0 +1,3361 @@
|
|
|
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
|
+
|
|
1186
|
+
// src/bootstrap.ts
|
|
1187
|
+
import { createHmac, randomBytes } from "crypto";
|
|
1188
|
+
import {
|
|
1189
|
+
chmodSync,
|
|
1190
|
+
existsSync,
|
|
1191
|
+
lstatSync,
|
|
1192
|
+
mkdirSync,
|
|
1193
|
+
readFileSync,
|
|
1194
|
+
renameSync,
|
|
1195
|
+
rmSync,
|
|
1196
|
+
writeFileSync
|
|
1197
|
+
} from "fs";
|
|
1198
|
+
import { dirname as dirname2 } from "path";
|
|
1199
|
+
import { fileURLToPath } from "url";
|
|
1200
|
+
|
|
1201
|
+
// src/agent-update-helper.ts
|
|
1202
|
+
import { DEFAULT_SOCKET } from "@forgezero/vault";
|
|
1203
|
+
|
|
1204
|
+
// src/agent-update.ts
|
|
1205
|
+
var DEFAULT_AGENT_RELEASE_ROOT = "/opt/forgezero/agent";
|
|
1206
|
+
var DEFAULT_AGENT_UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
|
|
1207
|
+
var MAX_AGENT_TARBALL_BYTES = 32 * 1024 * 1024;
|
|
1208
|
+
|
|
1209
|
+
// src/agent-update-helper.ts
|
|
1210
|
+
var AGENT_UPDATE_GROUP = "forgezero-update";
|
|
1211
|
+
var AGENT_UPDATE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-agent-update-helper.service";
|
|
1212
|
+
var MAX_REQUEST_BYTES = 8 * 1024;
|
|
1213
|
+
var UPDATE_RETRY_BASE_MS = 5 * 60000;
|
|
1214
|
+
var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
|
|
1215
|
+
|
|
1216
|
+
// src/version.ts
|
|
1217
|
+
var VERSION = "0.1.35";
|
|
1218
|
+
|
|
1219
|
+
// src/software.ts
|
|
1220
|
+
var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
|
|
1221
|
+
var ARANGO_SHA256 = "b5a9197b4343f2ed554e1ebc1ef8e6529c7c39cde0035cdc311a4747a3355066";
|
|
1222
|
+
var CLOUDFLARED_SHA256 = "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17";
|
|
1223
|
+
var UBUNTU_2604_X64 = [
|
|
1224
|
+
{
|
|
1225
|
+
requirement: { id: "bun", version: "1.3.14" },
|
|
1226
|
+
check: 'test "$(/usr/local/bin/bun --version 2>/dev/null)" = 1.3.14',
|
|
1227
|
+
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL https://bun.sh/install -o "$tmp/install"; ` + `echo "${BUN_INSTALLER_SHA256} $tmp/install" | sha256sum -c -; ` + `BUN_INSTALL="$tmp/bun" BUN_VERSION=1.3.14 bash "$tmp/install" >/dev/null; ` + `install -d -m 0755 /usr/local/lib/forgezero/runtime; ` + `install -m 0755 "$tmp/bun/bin/bun" /usr/local/lib/forgezero/runtime/bun.next; ` + `mv -Tf /usr/local/lib/forgezero/runtime/bun.next /usr/local/lib/forgezero/runtime/bun; ` + `ln -sfn /usr/local/lib/forgezero/runtime/bun /usr/local/bin/bun`
|
|
1228
|
+
},
|
|
1229
|
+
{
|
|
1230
|
+
requirement: { id: "nginx", version: "ubuntu-26.04" },
|
|
1231
|
+
check: "command -v nginx >/dev/null && systemctl is-active --quiet nginx",
|
|
1232
|
+
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y nginx && systemctl enable --now nginx"
|
|
1233
|
+
},
|
|
1234
|
+
{
|
|
1235
|
+
requirement: { id: "arangodb", version: "3.11.14" },
|
|
1236
|
+
check: `arangod --version 2>/dev/null | head -1 | grep -q '3.11.14'`,
|
|
1237
|
+
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://download.arangodb.com/arangodb311/DEBIAN/amd64/arangodb3_3.11.14-1_amd64.deb' -o "$tmp/arangodb.deb"; ` + `echo "${ARANGO_SHA256} $tmp/arangodb.deb" | sha256sum -c -; ` + `DEBIAN_FRONTEND=noninteractive dpkg -i "$tmp/arangodb.deb" >/dev/null 2>&1 || ` + `DEBIAN_FRONTEND=noninteractive apt-get -y -f install`
|
|
1238
|
+
},
|
|
1239
|
+
{
|
|
1240
|
+
requirement: { id: "cloudflared", version: "2026.7.3" },
|
|
1241
|
+
check: `cloudflared --version 2>/dev/null | grep -q '2026.7.3'`,
|
|
1242
|
+
install: `tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT; ` + `curl -fsSL 'https://github.com/cloudflare/cloudflared/releases/download/2026.7.3/cloudflared-linux-amd64' -o "$tmp/cloudflared"; ` + `echo "${CLOUDFLARED_SHA256} $tmp/cloudflared" | sha256sum -c -; ` + `install -m 0755 "$tmp/cloudflared" /usr/local/bin/cloudflared`
|
|
1243
|
+
},
|
|
1244
|
+
{
|
|
1245
|
+
requirement: { id: "ufw", version: "ubuntu-26.04" },
|
|
1246
|
+
check: "command -v ufw >/dev/null",
|
|
1247
|
+
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y ufw"
|
|
1248
|
+
}
|
|
1249
|
+
];
|
|
1250
|
+
|
|
1251
|
+
// src/software-helper.ts
|
|
1252
|
+
var DEFAULT_SOFTWARE_HELPER_SOCKET = "/run/forgezero-software/helper.sock";
|
|
1253
|
+
var SOFTWARE_HELPER_GROUP = "forgezero-software";
|
|
1254
|
+
var SOFTWARE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-software-helper.service";
|
|
1255
|
+
var MAX_REQUEST_BYTES2 = 8 * 1024;
|
|
1256
|
+
|
|
1257
|
+
// src/egress-policy.ts
|
|
1258
|
+
var AGENT_EGRESS_TABLE = "forgezero_agent_egress";
|
|
1259
|
+
var SYSTEMD_RESOLVED_ADDRESS = "127.0.0.53";
|
|
1260
|
+
var BLOCKED_IPV4 = [
|
|
1261
|
+
"0.0.0.0/8",
|
|
1262
|
+
"10.0.0.0/8",
|
|
1263
|
+
"100.64.0.0/10",
|
|
1264
|
+
"127.0.0.0/8",
|
|
1265
|
+
"168.63.129.16/32",
|
|
1266
|
+
"169.254.0.0/16",
|
|
1267
|
+
"172.16.0.0/12",
|
|
1268
|
+
"192.0.0.0/24",
|
|
1269
|
+
"192.0.2.0/24",
|
|
1270
|
+
"192.88.99.0/24",
|
|
1271
|
+
"192.168.0.0/16",
|
|
1272
|
+
"198.18.0.0/15",
|
|
1273
|
+
"198.51.100.0/24",
|
|
1274
|
+
"203.0.113.0/24",
|
|
1275
|
+
"224.0.0.0/4",
|
|
1276
|
+
"240.0.0.0/4"
|
|
1277
|
+
];
|
|
1278
|
+
var BLOCKED_IPV6 = [
|
|
1279
|
+
"::/128",
|
|
1280
|
+
"::1/128",
|
|
1281
|
+
"::ffff:0:0/96",
|
|
1282
|
+
"64:ff9b::/96",
|
|
1283
|
+
"64:ff9b:1::/48",
|
|
1284
|
+
"100::/64",
|
|
1285
|
+
"fc00::/7",
|
|
1286
|
+
"fec0::/10",
|
|
1287
|
+
"fe80::/10",
|
|
1288
|
+
"ff00::/8",
|
|
1289
|
+
"2001::/32",
|
|
1290
|
+
"2001:2::/48",
|
|
1291
|
+
"2001:10::/28",
|
|
1292
|
+
"2001:20::/28",
|
|
1293
|
+
"2001:db8::/32",
|
|
1294
|
+
"2002::/16",
|
|
1295
|
+
"3fff::/20"
|
|
1296
|
+
];
|
|
1297
|
+
var normalizeEgressTcpPorts = (ports) => {
|
|
1298
|
+
for (const port of ports) {
|
|
1299
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
1300
|
+
throw new Error("Agent egress policy refuses an invalid loopback TCP port.");
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return [...new Set(ports)].sort((left, right) => left - right);
|
|
1304
|
+
};
|
|
1305
|
+
function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
|
|
1306
|
+
const ports = normalizeEgressTcpPorts(loopbackTcpPorts);
|
|
1307
|
+
return [
|
|
1308
|
+
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6",
|
|
1309
|
+
`IPAddressAllow=${SYSTEMD_RESOLVED_ADDRESS}/32`,
|
|
1310
|
+
...ports.length > 0 ? ["IPAddressAllow=127.0.0.1/32", "IPAddressAllow=::1/128"] : [],
|
|
1311
|
+
...BLOCKED_IPV4.map((network) => `IPAddressDeny=${network}`),
|
|
1312
|
+
...BLOCKED_IPV6.map((network) => `IPAddressDeny=${network}`)
|
|
1313
|
+
].join(`
|
|
1314
|
+
`);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// src/provision.ts
|
|
1318
|
+
import { isIP as isIP3 } from "net";
|
|
1319
|
+
function atLeast(version, floor) {
|
|
1320
|
+
const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
|
|
1321
|
+
const got = parse(version);
|
|
1322
|
+
const want = parse(floor);
|
|
1323
|
+
if (got.length === 0)
|
|
1324
|
+
return false;
|
|
1325
|
+
for (let index = 0;index < want.length; index += 1) {
|
|
1326
|
+
const a = got[index] ?? 0;
|
|
1327
|
+
const b = want[index] ?? 0;
|
|
1328
|
+
if (a > b)
|
|
1329
|
+
return true;
|
|
1330
|
+
if (a < b)
|
|
1331
|
+
return false;
|
|
1332
|
+
}
|
|
1333
|
+
return true;
|
|
1334
|
+
}
|
|
1335
|
+
var CAPABILITY_CHECKS = {
|
|
1336
|
+
snpGuest: {
|
|
1337
|
+
command: "test -e /dev/sev-guest && echo yes || echo no",
|
|
1338
|
+
satisfied: (stdout) => stdout.trim() === "yes",
|
|
1339
|
+
remedy: "Not a confidential guest. The agent will run in `enrolled` mode, which is still stronger than an API key in the application."
|
|
1340
|
+
},
|
|
1341
|
+
systemd: {
|
|
1342
|
+
command: "test -d /run/systemd/system && echo yes || echo no",
|
|
1343
|
+
satisfied: (stdout) => stdout.trim() === "yes",
|
|
1344
|
+
remedy: "systemd is what supervises the agent. On a non-systemd host, run `fz-agent` under whatever supervises services there."
|
|
1345
|
+
},
|
|
1346
|
+
bun: {
|
|
1347
|
+
command: "bun --version 2>/dev/null || echo missing",
|
|
1348
|
+
satisfied: (stdout) => atLeast(stdout, "1.1.0"),
|
|
1349
|
+
remedy: "Install bun: curl -fsSL https://bun.sh/install | bash"
|
|
1350
|
+
},
|
|
1351
|
+
python: {
|
|
1352
|
+
command: "python3 --version 2>/dev/null || echo missing",
|
|
1353
|
+
satisfied: (stdout, exitCode) => exitCode === 0 && /^Python 3\./.test(stdout.trim()),
|
|
1354
|
+
remedy: "Install Python 3. It supplies the standard-library ioctl boundary for SNP reports."
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
|
|
1358
|
+
var reasonFor = (mode) => mode === "attested" ? "SEV-SNP guest device present, so the agent can prove what it is running and the platform can refuse it if the measurement is wrong." : "No SEV-SNP guest device. The agent authenticates with its enrolment token and hybrid Ed25519 + ML-DSA signature \u2014 weaker than attestation, stronger than an API key in the application.";
|
|
1359
|
+
var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
|
|
1360
|
+
var DEPLOYMENT_GROUP = "forgezero-deploy";
|
|
1361
|
+
var VAULT_GROUP = "forgezero-vault";
|
|
1362
|
+
var LIFECYCLE_GROUP = "forgezero-lifecycle";
|
|
1363
|
+
var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
|
|
1364
|
+
var AGENT_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-agent.socket";
|
|
1365
|
+
var AGENT_SOCKET_PROXY_UNIT_PATH = "/etc/systemd/system/forgezero-agent-proxy.service";
|
|
1366
|
+
var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
|
|
1367
|
+
var ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
|
|
1368
|
+
var LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper.service";
|
|
1369
|
+
var LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
|
|
1370
|
+
var WARP_CONFIG_UNIT_PATH = "/etc/systemd/system/forgezero-warp-config.service";
|
|
1371
|
+
var WARP_SERVICE_DROP_IN_PATH = "/etc/systemd/system/warp-svc.service.d/forgezero.conf";
|
|
1372
|
+
var AGENT_EGRESS_UNIT_PATH = "/etc/systemd/system/forgezero-agent-egress.service";
|
|
1373
|
+
var DEFAULT_RUNNER_PUBLIC_TCP_PORTS = [443];
|
|
1374
|
+
function agentEgressUnit(options) {
|
|
1375
|
+
const bin = options.binPath ?? "fz-agent";
|
|
1376
|
+
const user = options.user ?? "forgezero";
|
|
1377
|
+
if (!/^[a-z_][a-z0-9_-]{0,30}$/.test(user))
|
|
1378
|
+
throw new Error("invalid Agent service user");
|
|
1379
|
+
const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
|
|
1380
|
+
const runnerLoopbackPorts = normalizeEgressTcpPorts(options.runnerLoopbackPorts ?? []);
|
|
1381
|
+
const runnerPublicTcpPorts = normalizeEgressTcpPorts(options.runnerPublicTcpPorts ?? DEFAULT_RUNNER_PUBLIC_TCP_PORTS);
|
|
1382
|
+
if (deploymentEnabled && runnerPublicTcpPorts.length < 1) {
|
|
1383
|
+
throw new Error("deployed project runner needs at least one vetted public TCP port");
|
|
1384
|
+
}
|
|
1385
|
+
if (deploymentEnabled)
|
|
1386
|
+
systemdAgentEgressDirectives(runnerLoopbackPorts);
|
|
1387
|
+
const users = [user, ...deploymentEnabled ? [DEPLOYMENT_RUNNER_USER] : []];
|
|
1388
|
+
const runnerGrant = deploymentEnabled ? ` --loopback-user=${DEPLOYMENT_RUNNER_USER}` + runnerLoopbackPorts.map((port) => ` --loopback-tcp-port=${port}`).join("") + runnerPublicTcpPorts.map((port) => ` --public-tcp-port=${port}`).join("") : "";
|
|
1389
|
+
const policyProofs = deploymentEnabled ? [
|
|
1390
|
+
...runnerLoopbackPorts.length > 0 ? [`loopback=.*:${runnerLoopbackPorts.join(",")}`] : [],
|
|
1391
|
+
`public-tcp=${runnerPublicTcpPorts.join(",")}`
|
|
1392
|
+
].map((pattern) => `ExecStartPost=/bin/sh -c '/usr/sbin/nft --numeric list table inet ${AGENT_EGRESS_TABLE} | /usr/bin/grep -q "${pattern}"'`).join(`
|
|
1393
|
+
`) : "";
|
|
1394
|
+
return `[Unit]
|
|
1395
|
+
Description=ForgeZero Agent host egress policy
|
|
1396
|
+
Documentation=https://www.forgezero.net/docs/agent
|
|
1397
|
+
After=systemd-resolved.service nftables.service
|
|
1398
|
+
Requires=systemd-resolved.service
|
|
1399
|
+
Before=forgezero-agent-enrol.service forgezero-agent.service
|
|
1400
|
+
|
|
1401
|
+
[Service]
|
|
1402
|
+
Type=notify
|
|
1403
|
+
NotifyAccess=all
|
|
1404
|
+
User=root
|
|
1405
|
+
Group=root
|
|
1406
|
+
ExecStart=${bin} egress-policy ${users.map((name) => `--user=${name}`).join(" ")}${runnerGrant}
|
|
1407
|
+
${policyProofs}
|
|
1408
|
+
Restart=on-failure
|
|
1409
|
+
RestartSec=2
|
|
1410
|
+
LimitCORE=0
|
|
1411
|
+
NoNewPrivileges=true
|
|
1412
|
+
PrivateTmp=true
|
|
1413
|
+
ProtectSystem=strict
|
|
1414
|
+
ProtectHome=true
|
|
1415
|
+
ProtectKernelTunables=true
|
|
1416
|
+
ProtectKernelModules=true
|
|
1417
|
+
ProtectControlGroups=true
|
|
1418
|
+
RestrictSUIDSGID=true
|
|
1419
|
+
RestrictRealtime=true
|
|
1420
|
+
MemoryDenyWriteExecute=true
|
|
1421
|
+
LockPersonality=true
|
|
1422
|
+
CapabilityBoundingSet=CAP_NET_ADMIN
|
|
1423
|
+
RestrictAddressFamilies=AF_UNIX AF_NETLINK
|
|
1424
|
+
|
|
1425
|
+
[Install]
|
|
1426
|
+
WantedBy=multi-user.target
|
|
1427
|
+
`;
|
|
1428
|
+
}
|
|
1429
|
+
function softwareHelperUnit(options) {
|
|
1430
|
+
const bin = options.binPath ?? "fz-agent";
|
|
1431
|
+
return `[Unit]
|
|
1432
|
+
Description=ForgeZero declarative software strategy helper
|
|
1433
|
+
Documentation=https://www.forgezero.net/docs/agent
|
|
1434
|
+
After=network-online.target
|
|
1435
|
+
Wants=network-online.target
|
|
1436
|
+
|
|
1437
|
+
[Service]
|
|
1438
|
+
Type=simple
|
|
1439
|
+
User=root
|
|
1440
|
+
Group=${SOFTWARE_HELPER_GROUP}
|
|
1441
|
+
Environment=FZ_SOFTWARE_HELPER_SOCKET=${DEFAULT_SOFTWARE_HELPER_SOCKET}
|
|
1442
|
+
ExecStart=${bin} software-helper
|
|
1443
|
+
Restart=always
|
|
1444
|
+
RestartSec=2
|
|
1445
|
+
RuntimeDirectory=forgezero-software
|
|
1446
|
+
RuntimeDirectoryMode=0750
|
|
1447
|
+
UMask=0007
|
|
1448
|
+
LimitCORE=0
|
|
1449
|
+
PrivateTmp=true
|
|
1450
|
+
ProtectHome=true
|
|
1451
|
+
ProtectKernelTunables=true
|
|
1452
|
+
ProtectKernelModules=true
|
|
1453
|
+
ProtectControlGroups=true
|
|
1454
|
+
RestrictRealtime=true
|
|
1455
|
+
LockPersonality=true
|
|
1456
|
+
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
|
1457
|
+
|
|
1458
|
+
[Install]
|
|
1459
|
+
WantedBy=multi-user.target
|
|
1460
|
+
`;
|
|
1461
|
+
}
|
|
1462
|
+
function agentUpdateHelperUnit(options) {
|
|
1463
|
+
const bin = options.binPath ?? "fz-agent";
|
|
1464
|
+
return `[Unit]
|
|
1465
|
+
Description=ForgeZero verified Agent update helper
|
|
1466
|
+
Documentation=https://www.forgezero.net/docs/agent
|
|
1467
|
+
After=network-online.target
|
|
1468
|
+
Wants=network-online.target
|
|
1469
|
+
|
|
1470
|
+
[Service]
|
|
1471
|
+
Type=simple
|
|
1472
|
+
User=root
|
|
1473
|
+
Group=${AGENT_UPDATE_GROUP}
|
|
1474
|
+
Environment=FZ_AGENT_UPDATE_SOCKET=${DEFAULT_AGENT_UPDATE_SOCKET}
|
|
1475
|
+
ExecStart=${bin} update-helper
|
|
1476
|
+
Restart=always
|
|
1477
|
+
RestartSec=2
|
|
1478
|
+
RuntimeDirectory=forgezero-update
|
|
1479
|
+
RuntimeDirectoryMode=0750
|
|
1480
|
+
UMask=0007
|
|
1481
|
+
LimitCORE=0
|
|
1482
|
+
NoNewPrivileges=true
|
|
1483
|
+
PrivateTmp=true
|
|
1484
|
+
ProtectSystem=strict
|
|
1485
|
+
ProtectHome=true
|
|
1486
|
+
ProtectKernelTunables=true
|
|
1487
|
+
ProtectKernelModules=true
|
|
1488
|
+
ProtectControlGroups=true
|
|
1489
|
+
RestrictSUIDSGID=true
|
|
1490
|
+
RestrictRealtime=true
|
|
1491
|
+
LockPersonality=true
|
|
1492
|
+
ReadWritePaths=${DEFAULT_AGENT_RELEASE_ROOT} /var/lib/forgezero
|
|
1493
|
+
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
|
1494
|
+
|
|
1495
|
+
[Install]
|
|
1496
|
+
WantedBy=multi-user.target
|
|
1497
|
+
`;
|
|
1498
|
+
}
|
|
1499
|
+
function agentSocketUnit(options) {
|
|
1500
|
+
const socket = systemdPath(options.socketPath, "agent socket");
|
|
1501
|
+
return `[Unit]
|
|
1502
|
+
Description=ForgeZero application Vault socket
|
|
1503
|
+
Documentation=https://www.forgezero.net/docs/agent
|
|
1504
|
+
|
|
1505
|
+
[Socket]
|
|
1506
|
+
ListenStream=${socket}
|
|
1507
|
+
SocketUser=root
|
|
1508
|
+
SocketGroup=${VAULT_GROUP}
|
|
1509
|
+
SocketMode=0660
|
|
1510
|
+
DirectoryMode=0750
|
|
1511
|
+
RemoveOnStop=true
|
|
1512
|
+
Service=forgezero-agent-proxy.service
|
|
1513
|
+
|
|
1514
|
+
[Install]
|
|
1515
|
+
WantedBy=sockets.target
|
|
1516
|
+
`;
|
|
1517
|
+
}
|
|
1518
|
+
function agentSocketProxyUnit(options) {
|
|
1519
|
+
const backend = agentBackendSocketPath(options.socketPath);
|
|
1520
|
+
const user = options.user ?? "forgezero";
|
|
1521
|
+
return `[Unit]
|
|
1522
|
+
Description=ForgeZero application Vault socket proxy
|
|
1523
|
+
Documentation=https://www.forgezero.net/docs/agent
|
|
1524
|
+
Requires=forgezero-agent.service
|
|
1525
|
+
After=forgezero-agent.service
|
|
1526
|
+
|
|
1527
|
+
[Service]
|
|
1528
|
+
User=${user}
|
|
1529
|
+
Group=${VAULT_GROUP}
|
|
1530
|
+
ExecStart=/usr/lib/systemd/systemd-socket-proxyd ${backend}
|
|
1531
|
+
NoNewPrivileges=true
|
|
1532
|
+
PrivateTmp=true
|
|
1533
|
+
ProtectSystem=strict
|
|
1534
|
+
ProtectHome=true
|
|
1535
|
+
ProtectKernelTunables=true
|
|
1536
|
+
ProtectKernelModules=true
|
|
1537
|
+
ProtectControlGroups=true
|
|
1538
|
+
RestrictSUIDSGID=true
|
|
1539
|
+
RestrictRealtime=true
|
|
1540
|
+
MemoryDenyWriteExecute=true
|
|
1541
|
+
LockPersonality=true
|
|
1542
|
+
RestrictAddressFamilies=AF_UNIX
|
|
1543
|
+
`;
|
|
1544
|
+
}
|
|
1545
|
+
function agentBackendSocketPath(publicSocketPath) {
|
|
1546
|
+
const socket = systemdPath(publicSocketPath, "agent socket");
|
|
1547
|
+
const backend = `${socket}.backend`;
|
|
1548
|
+
if (Buffer.byteLength(backend) > 100)
|
|
1549
|
+
throw new Error("agent socket path is too long for a Unix socket");
|
|
1550
|
+
return backend;
|
|
1551
|
+
}
|
|
1552
|
+
var systemdPath = (value, label) => {
|
|
1553
|
+
if (!value || !/^\/[A-Za-z0-9._@/-]+$/.test(value))
|
|
1554
|
+
throw new Error(`invalid ${label} path`);
|
|
1555
|
+
return value;
|
|
1556
|
+
};
|
|
1557
|
+
var awaitSocketCommand = (path) => {
|
|
1558
|
+
const socket = systemdPath(path, "readiness socket");
|
|
1559
|
+
return `for attempt in $(seq 1 100); do test -S ${socket} && exit 0; sleep 0.1; done; exit 1`;
|
|
1560
|
+
};
|
|
1561
|
+
var validNodeHostname = (value) => !value || value.length <= 253 && value === value.toLowerCase() && value.split(".").length >= 3 && value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
|
|
1562
|
+
function warpConfigUnit(options) {
|
|
1563
|
+
if (!options.warpOrganization || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.warpOrganization)) {
|
|
1564
|
+
throw new Error("WARP organization is invalid");
|
|
1565
|
+
}
|
|
1566
|
+
if (!options.warpClientIdCredentialPath || !options.warpClientSecretCredentialPath) {
|
|
1567
|
+
throw new Error("WARP service-token credential paths are required");
|
|
1568
|
+
}
|
|
1569
|
+
const clientIdPath = systemdPath(options.warpClientIdCredentialPath, "WARP client-id credential");
|
|
1570
|
+
const clientSecretPath = systemdPath(options.warpClientSecretCredentialPath, "WARP client-secret credential");
|
|
1571
|
+
const bin = options.binPath ?? "fz-agent";
|
|
1572
|
+
return `[Unit]
|
|
1573
|
+
Description=Materialize Cloudflare One enrollment in tmpfs
|
|
1574
|
+
Documentation=https://www.forgezero.net/docs/agent
|
|
1575
|
+
Before=warp-svc.service
|
|
1576
|
+
|
|
1577
|
+
[Service]
|
|
1578
|
+
Type=oneshot
|
|
1579
|
+
RemainAfterExit=yes
|
|
1580
|
+
LoadCredentialEncrypted=warp-auth-client-id:${clientIdPath}
|
|
1581
|
+
LoadCredentialEncrypted=warp-auth-client-secret:${clientSecretPath}
|
|
1582
|
+
Environment=FZ_WARP_CLIENT_ID_CREDENTIAL=warp-auth-client-id
|
|
1583
|
+
Environment=FZ_WARP_CLIENT_SECRET_CREDENTIAL=warp-auth-client-secret
|
|
1584
|
+
ExecStart=${bin} warp-config --organization=${options.warpOrganization}
|
|
1585
|
+
RuntimeDirectory=forgezero-warp
|
|
1586
|
+
RuntimeDirectoryMode=0700
|
|
1587
|
+
RuntimeDirectoryPreserve=yes
|
|
1588
|
+
UMask=0077
|
|
1589
|
+
LimitCORE=0
|
|
1590
|
+
NoNewPrivileges=true
|
|
1591
|
+
PrivateTmp=true
|
|
1592
|
+
ProtectSystem=strict
|
|
1593
|
+
ProtectHome=true
|
|
1594
|
+
ReadWritePaths=/var/lib/cloudflare-warp
|
|
1595
|
+
|
|
1596
|
+
[Install]
|
|
1597
|
+
WantedBy=multi-user.target
|
|
1598
|
+
`;
|
|
1599
|
+
}
|
|
1600
|
+
function warpServiceDropIn() {
|
|
1601
|
+
return `[Unit]
|
|
1602
|
+
Requires=forgezero-warp-config.service
|
|
1603
|
+
After=forgezero-warp-config.service
|
|
1604
|
+
`;
|
|
1605
|
+
}
|
|
1606
|
+
function lifecycleHelperUnit(options) {
|
|
1607
|
+
if (!options.lifecycleProfilePath)
|
|
1608
|
+
throw new Error("lifecycle helper needs a root-owned profile");
|
|
1609
|
+
const bin = options.binPath ?? "fz-agent";
|
|
1610
|
+
const profile = systemdPath(options.lifecycleProfilePath, "lifecycle profile");
|
|
1611
|
+
const socket = systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket");
|
|
1612
|
+
return `[Unit]
|
|
1613
|
+
Description=ForgeZero fixed-operation compute lifecycle helper
|
|
1614
|
+
Documentation=https://www.forgezero.net/docs/agent
|
|
1615
|
+
After=network-online.target
|
|
1616
|
+
Wants=network-online.target
|
|
1617
|
+
|
|
1618
|
+
[Service]
|
|
1619
|
+
Type=simple
|
|
1620
|
+
User=root
|
|
1621
|
+
Group=${LIFECYCLE_GROUP}
|
|
1622
|
+
Environment=FZ_LIFECYCLE_HELPER_SOCKET=${socket}
|
|
1623
|
+
ExecStart=${bin} lifecycle-helper --profile=${profile}
|
|
1624
|
+
Restart=always
|
|
1625
|
+
RestartSec=2
|
|
1626
|
+
RuntimeDirectory=forgezero-lifecycle
|
|
1627
|
+
RuntimeDirectoryMode=0750
|
|
1628
|
+
UMask=0007
|
|
1629
|
+
LimitCORE=0
|
|
1630
|
+
NoNewPrivileges=true
|
|
1631
|
+
PrivateTmp=true
|
|
1632
|
+
ProtectSystem=strict
|
|
1633
|
+
ProtectHome=true
|
|
1634
|
+
ProtectKernelTunables=true
|
|
1635
|
+
ProtectKernelModules=true
|
|
1636
|
+
ProtectControlGroups=true
|
|
1637
|
+
RestrictSUIDSGID=true
|
|
1638
|
+
RestrictRealtime=true
|
|
1639
|
+
MemoryDenyWriteExecute=true
|
|
1640
|
+
LockPersonality=true
|
|
1641
|
+
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
|
1642
|
+
|
|
1643
|
+
[Install]
|
|
1644
|
+
WantedBy=multi-user.target
|
|
1645
|
+
`;
|
|
1646
|
+
}
|
|
1647
|
+
function agentEnrolmentUnit(options) {
|
|
1648
|
+
if (!options.apiUrl || !options.enrolTokenCredentialPath || !options.enrolStatePath) {
|
|
1649
|
+
throw new Error("direct enrolment needs API, credential and state paths");
|
|
1650
|
+
}
|
|
1651
|
+
if (!validNodeHostname(options.nodeHostname))
|
|
1652
|
+
throw new Error("node hostname is invalid");
|
|
1653
|
+
const bin = options.binPath ?? "fz-agent";
|
|
1654
|
+
const user = options.user ?? "forgezero";
|
|
1655
|
+
const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
|
|
1656
|
+
const label = options.nodeLabel ? `Environment=FZ_NODE_LABEL=${options.nodeLabel}
|
|
1657
|
+
` : "";
|
|
1658
|
+
const hostname = options.nodeHostname ? `Environment=FZ_NODE_HOSTNAME=${options.nodeHostname}
|
|
1659
|
+
` : "";
|
|
1660
|
+
const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
|
|
1661
|
+
` : "";
|
|
1662
|
+
const networkAttachment = [
|
|
1663
|
+
options.cloudflareAccountId ? `Environment=FZ_CF_ACCOUNT_ID=${options.cloudflareAccountId}
|
|
1664
|
+
` : "",
|
|
1665
|
+
options.cloudflareTunnelId ? `Environment=FZ_CF_TUNNEL_ID=${options.cloudflareTunnelId}
|
|
1666
|
+
` : "",
|
|
1667
|
+
options.cloudflareVirtualNetworkId ? `Environment=FZ_CF_VIRTUAL_NETWORK_ID=${options.cloudflareVirtualNetworkId}
|
|
1668
|
+
` : "",
|
|
1669
|
+
options.cloudflareWarpPolicyId ? `Environment=FZ_CF_WARP_POLICY_ID=${options.cloudflareWarpPolicyId}
|
|
1670
|
+
` : ""
|
|
1671
|
+
].join("");
|
|
1672
|
+
const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
|
|
1673
|
+
const egressDependency = options.enforceEgress ? `After=forgezero-agent-egress.service
|
|
1674
|
+
Requires=forgezero-agent-egress.service
|
|
1675
|
+
BindsTo=forgezero-agent-egress.service
|
|
1676
|
+
` : "";
|
|
1677
|
+
const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives() : "";
|
|
1678
|
+
return `[Unit]
|
|
1679
|
+
Description=Bind this machine to its ForgeZero compute
|
|
1680
|
+
After=network-online.target
|
|
1681
|
+
Wants=network-online.target
|
|
1682
|
+
${egressDependency}Before=forgezero-agent.service
|
|
1683
|
+
ConditionPathExists=!${options.enrolStatePath}
|
|
1684
|
+
|
|
1685
|
+
[Service]
|
|
1686
|
+
Type=oneshot
|
|
1687
|
+
User=${user}
|
|
1688
|
+
Group=${user}
|
|
1689
|
+
LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
|
|
1690
|
+
LoadCredentialEncrypted=enrol-token:${options.enrolTokenCredentialPath}
|
|
1691
|
+
Environment=FZ_SEED_CREDENTIAL=agent-seed
|
|
1692
|
+
Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
|
|
1693
|
+
Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
|
|
1694
|
+
Environment=FZ_API=${options.apiUrl}
|
|
1695
|
+
${label}${hostname}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
|
|
1696
|
+
# A '+' fixed command runs as root solely to remove the host-bound one-time
|
|
1697
|
+
# ciphertext. Tenant code and the agent never receive a privilege boundary.
|
|
1698
|
+
ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
|
|
1699
|
+
NoNewPrivileges=true
|
|
1700
|
+
PrivateTmp=true
|
|
1701
|
+
ProtectSystem=strict
|
|
1702
|
+
ProtectHome=true
|
|
1703
|
+
ReadWritePaths=${stateDir}
|
|
1704
|
+
LimitCORE=0
|
|
1705
|
+
${egressDirectives}
|
|
1706
|
+
|
|
1707
|
+
[Install]
|
|
1708
|
+
WantedBy=multi-user.target
|
|
1709
|
+
`;
|
|
1710
|
+
}
|
|
1711
|
+
function deploymentRunnerUnit(options) {
|
|
1712
|
+
const bin = options.binPath ?? "fz-agent";
|
|
1713
|
+
const root = options.deployRoot ?? "/opt/forgezero";
|
|
1714
|
+
const agentUser = options.user ?? "forgezero-agent";
|
|
1715
|
+
const egressDependency = options.enforceEgress ? `After=forgezero-agent-egress.service
|
|
1716
|
+
Requires=forgezero-agent-egress.service
|
|
1717
|
+
BindsTo=forgezero-agent-egress.service
|
|
1718
|
+
` : "";
|
|
1719
|
+
const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives(options.runnerLoopbackPorts ?? []) : "";
|
|
1720
|
+
return `[Unit]
|
|
1721
|
+
Description=ForgeZero credential-free project command runner
|
|
1722
|
+
Documentation=https://www.forgezero.net/docs/agent
|
|
1723
|
+
${egressDependency}
|
|
1724
|
+
|
|
1725
|
+
[Service]
|
|
1726
|
+
Type=notify
|
|
1727
|
+
NotifyAccess=all
|
|
1728
|
+
User=${DEPLOYMENT_RUNNER_USER}
|
|
1729
|
+
Group=${DEPLOYMENT_GROUP}
|
|
1730
|
+
Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
|
|
1731
|
+
RuntimeDirectory=forgezero-deploy
|
|
1732
|
+
RuntimeDirectoryMode=0710
|
|
1733
|
+
ExecStartPre=+/usr/bin/install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0710 /run/forgezero-deploy
|
|
1734
|
+
ExecStartPre=+/usr/bin/rm -f ${DEPLOYMENT_RUNNER_SOCKET}
|
|
1735
|
+
ExecStart=${bin} deploy-runner --root=${root} --home=${root}/runner-home
|
|
1736
|
+
ExecStartPost=+/usr/bin/chown ${agentUser}:${agentUser} ${DEPLOYMENT_RUNNER_SOCKET}
|
|
1737
|
+
ExecStartPost=+/usr/bin/chmod 0600 ${DEPLOYMENT_RUNNER_SOCKET}
|
|
1738
|
+
ExecStartPost=+/usr/bin/chown root:${VAULT_GROUP} /run/forgezero-deploy
|
|
1739
|
+
ExecStopPost=+/usr/bin/rm -f ${DEPLOYMENT_RUNNER_SOCKET}
|
|
1740
|
+
Restart=always
|
|
1741
|
+
RestartSec=2
|
|
1742
|
+
UMask=0007
|
|
1743
|
+
LimitCORE=0
|
|
1744
|
+
NoNewPrivileges=false
|
|
1745
|
+
PrivateTmp=true
|
|
1746
|
+
ProtectSystem=strict
|
|
1747
|
+
ProtectHome=true
|
|
1748
|
+
ProtectKernelTunables=true
|
|
1749
|
+
ProtectKernelModules=true
|
|
1750
|
+
ProtectControlGroups=true
|
|
1751
|
+
RestrictRealtime=true
|
|
1752
|
+
MemoryDenyWriteExecute=true
|
|
1753
|
+
LockPersonality=true
|
|
1754
|
+
${egressDirectives}
|
|
1755
|
+
ReadWritePaths=${root}/releases ${root}/runner-home
|
|
1756
|
+
|
|
1757
|
+
[Install]
|
|
1758
|
+
WantedBy=multi-user.target
|
|
1759
|
+
`;
|
|
1760
|
+
}
|
|
1761
|
+
function agentUnit(options) {
|
|
1762
|
+
if (!validNodeHostname(options.nodeHostname))
|
|
1763
|
+
throw new Error("node hostname is invalid");
|
|
1764
|
+
if (!options.telemetryEndpoint) {
|
|
1765
|
+
throw new Error("compute Agent provisioning requires OTEL_EXPORTER_OTLP_ENDPOINT as an explicit public HTTPS collector coordinate");
|
|
1766
|
+
}
|
|
1767
|
+
let telemetryEndpoint;
|
|
1768
|
+
{
|
|
1769
|
+
let endpoint2;
|
|
1770
|
+
try {
|
|
1771
|
+
endpoint2 = new URL(options.telemetryEndpoint);
|
|
1772
|
+
} catch {
|
|
1773
|
+
throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL");
|
|
1774
|
+
}
|
|
1775
|
+
if (endpoint2.protocol !== "https:" || endpoint2.username || endpoint2.password || endpoint2.search || endpoint2.hash || isIP3(endpoint2.hostname) !== 0 || !endpoint2.hostname.includes(".") || endpoint2.hostname === "localhost" || endpoint2.hostname.endsWith(".local"))
|
|
1776
|
+
throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL without credentials, query or fragment");
|
|
1777
|
+
telemetryEndpoint = endpoint2.toString().replace(/\/$/, "");
|
|
1778
|
+
}
|
|
1779
|
+
const bin = options.binPath ?? "fz-agent";
|
|
1780
|
+
const user = options.user ?? "forgezero";
|
|
1781
|
+
const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
|
|
1782
|
+
const controlSocketPath = options.controlSocketPath ?? "/run/forgezero/control.sock";
|
|
1783
|
+
const deployRoot = options.deployRoot ?? "/opt/forgezero";
|
|
1784
|
+
const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
|
|
1785
|
+
const lifecycleEnabled = Boolean(options.pullMigrations && options.lifecycleProfilePath);
|
|
1786
|
+
if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
|
|
1787
|
+
throw new Error("migration pull and lifecycle profile must be supplied together");
|
|
1788
|
+
}
|
|
1789
|
+
const lifecycleHelperSocketPath = lifecycleEnabled ? systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket") : undefined;
|
|
1790
|
+
const warpValues = [
|
|
1791
|
+
options.warpOrganization,
|
|
1792
|
+
options.warpClientIdCredentialPath,
|
|
1793
|
+
options.warpClientSecretCredentialPath
|
|
1794
|
+
];
|
|
1795
|
+
const warpEnabled = warpValues.every(Boolean);
|
|
1796
|
+
if (warpValues.some(Boolean) && !warpEnabled)
|
|
1797
|
+
throw new Error("WARP configuration must be supplied together");
|
|
1798
|
+
const networkAttachmentValues = [
|
|
1799
|
+
options.cloudflareAccountId,
|
|
1800
|
+
options.cloudflareTunnelId,
|
|
1801
|
+
options.cloudflareVirtualNetworkId,
|
|
1802
|
+
options.cloudflareWarpPolicyId
|
|
1803
|
+
];
|
|
1804
|
+
if (networkAttachmentValues.some(Boolean)) {
|
|
1805
|
+
if (!options.cloudflareAccountId || !options.cloudflareTunnelId || !options.cloudflareWarpPolicyId) {
|
|
1806
|
+
throw new Error("private-network attachment requires account, Tunnel and WARP policy ids");
|
|
1807
|
+
}
|
|
1808
|
+
const 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;
|
|
1809
|
+
if (!/^[a-f0-9]{32}$/i.test(options.cloudflareAccountId) || !uuid.test(options.cloudflareTunnelId) || options.cloudflareVirtualNetworkId && !uuid.test(options.cloudflareVirtualNetworkId) || !/^[A-Za-z0-9-]{1,64}$/.test(options.cloudflareWarpPolicyId)) {
|
|
1810
|
+
throw new Error("private-network attachment coordinates are invalid");
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
const enrolmentEnabled = Boolean(options.enrolTokenCredentialPath && options.enrolStatePath);
|
|
1814
|
+
const deploymentEnvironment = options.deploymentEnvironment ?? {};
|
|
1815
|
+
const deploymentCredentials = options.deploymentCredentials ?? {};
|
|
1816
|
+
for (const [name, value] of Object.entries(deploymentEnvironment)) {
|
|
1817
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || !/^[A-Za-z0-9._:\/@+-]+$/.test(value)) {
|
|
1818
|
+
throw new Error(`invalid deployment environment entry: ${name}`);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
for (const [name, path] of Object.entries(deploymentCredentials)) {
|
|
1822
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name) || !path.startsWith("/") || /[\r\n:]/.test(path)) {
|
|
1823
|
+
throw new Error(`invalid deployment credential entry: ${name}`);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
const environment = [
|
|
1827
|
+
"NODE_ENV=production",
|
|
1828
|
+
`FZ_SOCKET_PATH=${agentBackendSocketPath(options.socketPath)}`,
|
|
1829
|
+
`FZ_CONTROL_SOCKET=${controlSocketPath}`,
|
|
1830
|
+
`FZ_SEED_CREDENTIAL=agent-seed`,
|
|
1831
|
+
`FZ_AGENT_MODE=${options.mode}`,
|
|
1832
|
+
options.apiUrl ? `FZ_API=${options.apiUrl}` : null,
|
|
1833
|
+
options.project ? `FZ_PROJECT=${options.project}` : null,
|
|
1834
|
+
options.environment ? `FZ_ENVIRONMENT=${options.environment}` : null,
|
|
1835
|
+
options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
|
|
1836
|
+
options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
|
|
1837
|
+
options.nodeHostname ? `FZ_NODE_HOSTNAME=${options.nodeHostname}` : null,
|
|
1838
|
+
`OTEL_EXPORTER_OTLP_ENDPOINT=${telemetryEndpoint}`,
|
|
1839
|
+
"OTEL_SERVICE_NAME=forgezero-agent",
|
|
1840
|
+
options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
|
|
1841
|
+
options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
|
|
1842
|
+
options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
|
|
1843
|
+
options.profile ? `FZ_DEPLOY_PROFILE=${options.profile}` : null,
|
|
1844
|
+
options.repository && options.branch ? `FZ_DEPLOY_KEY=${options.project ?? "platform"}:${options.environment ?? "production"}` : null,
|
|
1845
|
+
deploymentEnabled ? `FZ_DEPLOY_ROOT=${deployRoot}` : null,
|
|
1846
|
+
deploymentEnabled ? `FZ_CAPACITY_EVIDENCE_DIR=${deployRoot}/capacity` : null,
|
|
1847
|
+
deploymentEnabled ? `FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}` : null,
|
|
1848
|
+
deploymentEnabled ? `FZ_SOFTWARE_HELPER_SOCKET=${DEFAULT_SOFTWARE_HELPER_SOCKET}` : null,
|
|
1849
|
+
Object.keys(deploymentCredentials).length > 0 ? `FZ_DEPLOY_SYSTEMD_SECRETS=${Object.keys(deploymentCredentials).join(",")}` : null,
|
|
1850
|
+
Object.keys(deploymentEnvironment).length > 0 ? `FZ_DEPLOY_ENV_NAMES=${Object.keys(deploymentEnvironment).join(",")}` : null,
|
|
1851
|
+
...Object.entries(deploymentEnvironment).map(([name, value]) => `${name}=${value}`),
|
|
1852
|
+
options.publicApiUrl ? `FZ_PUBLIC_API_URL=${options.publicApiUrl}` : null,
|
|
1853
|
+
options.pullDeployments ? "FZ_DEPLOY_PULL=true" : null,
|
|
1854
|
+
options.pullMigrations ? "FZ_MIGRATION_PULL=true" : null,
|
|
1855
|
+
`FZ_AGENT_UPDATE_SOCKET=${DEFAULT_AGENT_UPDATE_SOCKET}`,
|
|
1856
|
+
options.pullMigrations ? `FZ_LIFECYCLE_HELPER_SOCKET=${lifecycleHelperSocketPath}` : null
|
|
1857
|
+
].filter((line) => line !== null);
|
|
1858
|
+
if (deploymentEnabled) {
|
|
1859
|
+
environment.push(`HOME=${deployRoot}/agent-home`, `XDG_CACHE_HOME=${deployRoot}/cache`);
|
|
1860
|
+
}
|
|
1861
|
+
const gitCredential = options.gitCredentialPath ? `LoadCredentialEncrypted=git-deploy-key:${options.gitCredentialPath}
|
|
1862
|
+
` : "";
|
|
1863
|
+
const projectCredentials = Object.entries(deploymentCredentials).map(([name, path]) => `LoadCredentialEncrypted=${name}:${path}`).join(`
|
|
1864
|
+
`);
|
|
1865
|
+
const deploymentWrites = deploymentEnabled ? `ReadWritePaths=${deployRoot}/releases ${deployRoot}/agent-home ${deployRoot}/cache ${deployRoot}/capacity` : "";
|
|
1866
|
+
const supplementaryGroups = [
|
|
1867
|
+
AGENT_UPDATE_GROUP,
|
|
1868
|
+
deploymentEnabled ? DEPLOYMENT_GROUP : null,
|
|
1869
|
+
deploymentEnabled ? SOFTWARE_HELPER_GROUP : null,
|
|
1870
|
+
lifecycleEnabled ? LIFECYCLE_GROUP : null
|
|
1871
|
+
].filter((value) => value !== null);
|
|
1872
|
+
const deploymentGroup = supplementaryGroups.length > 0 ? `SupplementaryGroups=${supplementaryGroups.join(" ")}` : "";
|
|
1873
|
+
const after = [
|
|
1874
|
+
"network-online.target",
|
|
1875
|
+
"forgezero-agent-update-helper.service",
|
|
1876
|
+
options.enforceEgress ? "forgezero-agent-egress.service" : null,
|
|
1877
|
+
deploymentEnabled ? "forgezero-deploy-runner.service" : null,
|
|
1878
|
+
deploymentEnabled ? "forgezero-software-helper.service" : null,
|
|
1879
|
+
lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
|
|
1880
|
+
warpEnabled ? "warp-svc.service" : null,
|
|
1881
|
+
enrolmentEnabled ? "forgezero-agent-enrol.service" : null
|
|
1882
|
+
].filter((value) => value !== null);
|
|
1883
|
+
const requires = [
|
|
1884
|
+
"forgezero-agent-update-helper.service",
|
|
1885
|
+
options.enforceEgress ? "forgezero-agent-egress.service" : null,
|
|
1886
|
+
deploymentEnabled ? "forgezero-deploy-runner.service" : null,
|
|
1887
|
+
deploymentEnabled ? "forgezero-software-helper.service" : null,
|
|
1888
|
+
lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
|
|
1889
|
+
warpEnabled ? "warp-svc.service" : null,
|
|
1890
|
+
enrolmentEnabled ? "forgezero-agent-enrol.service" : null
|
|
1891
|
+
].filter((value) => value !== null);
|
|
1892
|
+
const deploymentDependency = [
|
|
1893
|
+
`After=${after.join(" ")}`,
|
|
1894
|
+
"Wants=network-online.target",
|
|
1895
|
+
requires.length > 0 ? `Requires=${requires.join(" ")}` : null,
|
|
1896
|
+
options.enforceEgress ? "BindsTo=forgezero-agent-egress.service" : null
|
|
1897
|
+
].filter((value) => value !== null).join(`
|
|
1898
|
+
`);
|
|
1899
|
+
const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
|
|
1900
|
+
DeviceAllow=/dev/sev-guest rw` : "";
|
|
1901
|
+
const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
|
|
1902
|
+
ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
|
|
1903
|
+
` : "";
|
|
1904
|
+
const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives() : "";
|
|
1905
|
+
return `[Unit]
|
|
1906
|
+
Description=ForgeZero node agent (${options.mode})
|
|
1907
|
+
Documentation=https://www.forgezero.net/docs/agent
|
|
1908
|
+
${deploymentDependency}
|
|
1909
|
+
|
|
1910
|
+
[Service]
|
|
1911
|
+
Type=simple
|
|
1912
|
+
User=${user}
|
|
1913
|
+
Group=${VAULT_GROUP}
|
|
1914
|
+
${deploymentGroup}
|
|
1915
|
+
LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
|
|
1916
|
+
${gitCredential}${projectCredentials}${projectCredentials ? `
|
|
1917
|
+
` : ""}${snpPrepare}ExecStart=${bin}
|
|
1918
|
+
Restart=always
|
|
1919
|
+
RestartSec=2
|
|
1920
|
+
|
|
1921
|
+
${environment.map((line) => `Environment=${line}`).join(`
|
|
1922
|
+
`)}
|
|
1923
|
+
|
|
1924
|
+
# The node seed and the vault replica live in this process's memory. A core dump
|
|
1925
|
+
# writes both to disk, which is the one artefact this design exists to remove.
|
|
1926
|
+
LimitCORE=0
|
|
1927
|
+
|
|
1928
|
+
# The socket is the entire interface: anything that can read it can read the
|
|
1929
|
+
# scope. So it lives in a directory systemd creates with a known owner rather
|
|
1930
|
+
# than wherever the process happened to have write access.
|
|
1931
|
+
RuntimeDirectory=forgezero
|
|
1932
|
+
RuntimeDirectoryMode=0750
|
|
1933
|
+
RuntimeDirectoryPreserve=yes
|
|
1934
|
+
UMask=0007
|
|
1935
|
+
|
|
1936
|
+
# Tenant-controlled commands execute in forgezero-deploy-runner.service. This
|
|
1937
|
+
# credential-bearing process never needs to cross a privilege boundary.
|
|
1938
|
+
NoNewPrivileges=true
|
|
1939
|
+
PrivateTmp=true
|
|
1940
|
+
ProtectSystem=strict
|
|
1941
|
+
ProtectHome=true
|
|
1942
|
+
ProtectKernelTunables=true
|
|
1943
|
+
ProtectKernelModules=true
|
|
1944
|
+
ProtectControlGroups=true
|
|
1945
|
+
RestrictSUIDSGID=true
|
|
1946
|
+
RestrictRealtime=true
|
|
1947
|
+
MemoryDenyWriteExecute=true
|
|
1948
|
+
LockPersonality=true
|
|
1949
|
+
${egressDirectives}
|
|
1950
|
+
${snpDevice}
|
|
1951
|
+
${deploymentWrites}
|
|
1952
|
+
|
|
1953
|
+
[Install]
|
|
1954
|
+
WantedBy=multi-user.target
|
|
1955
|
+
`;
|
|
1956
|
+
}
|
|
1957
|
+
var UNIT_PATH = "/etc/systemd/system/forgezero-agent.service";
|
|
1958
|
+
function planProvision(options) {
|
|
1959
|
+
const mode = options.mode;
|
|
1960
|
+
const user = options.user ?? "forgezero";
|
|
1961
|
+
const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
|
|
1962
|
+
const credentialDir = seedCredentialPath.replace(/\/[^/]+$/, "");
|
|
1963
|
+
const deployRoot = options.deployRoot ?? "/opt/forgezero";
|
|
1964
|
+
const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
|
|
1965
|
+
const runnerLoopbackPorts = normalizeEgressTcpPorts(options.runnerLoopbackPorts ?? []);
|
|
1966
|
+
const runnerPublicTcpPorts = normalizeEgressTcpPorts(options.runnerPublicTcpPorts ?? DEFAULT_RUNNER_PUBLIC_TCP_PORTS);
|
|
1967
|
+
const lifecycleEnabled = Boolean(options.pullMigrations && options.lifecycleProfilePath);
|
|
1968
|
+
if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
|
|
1969
|
+
throw new Error("migration pull and lifecycle profile must be supplied together");
|
|
1970
|
+
}
|
|
1971
|
+
const warpValues = [
|
|
1972
|
+
options.warpOrganization,
|
|
1973
|
+
options.warpClientIdCredentialPath,
|
|
1974
|
+
options.warpClientSecretCredentialPath
|
|
1975
|
+
];
|
|
1976
|
+
const warpEnabled = warpValues.every(Boolean);
|
|
1977
|
+
if (warpValues.some(Boolean) && !warpEnabled)
|
|
1978
|
+
throw new Error("WARP configuration must be supplied together");
|
|
1979
|
+
const enrolmentEnabled = Boolean(options.enrolTokenSourcePath && options.enrolTokenCredentialPath && options.enrolStatePath);
|
|
1980
|
+
if (Boolean(options.enrolTokenSourcePath) !== Boolean(options.enrolTokenCredentialPath) || Boolean(options.enrolTokenCredentialPath) !== Boolean(options.enrolStatePath))
|
|
1981
|
+
throw new Error("direct enrolment paths must be supplied together");
|
|
1982
|
+
const enrolTokenSourcePath = enrolmentEnabled ? systemdPath(options.enrolTokenSourcePath, "enrolment source") : undefined;
|
|
1983
|
+
const enrolTokenCredentialPath = enrolmentEnabled ? systemdPath(options.enrolTokenCredentialPath, "enrolment credential") : undefined;
|
|
1984
|
+
const enrolStatePath = enrolmentEnabled ? systemdPath(options.enrolStatePath, "enrolment state") : undefined;
|
|
1985
|
+
const enrolStateDir = enrolStatePath?.replace(/\/[^/]+$/, "");
|
|
1986
|
+
const sourceBinPath = options.sourceBinPath ? systemdPath(options.sourceBinPath, "agent source binary") : undefined;
|
|
1987
|
+
const binPath = options.binPath ? systemdPath(options.binPath, "agent binary") : undefined;
|
|
1988
|
+
const gitCredentialPath = options.gitCredentialPath ? systemdPath(options.gitCredentialPath, "Git credential") : undefined;
|
|
1989
|
+
const gitPublicKeyPath = options.gitPublicKeyPath ? systemdPath(options.gitPublicKeyPath, "Git public key") : undefined;
|
|
1990
|
+
if (options.generateGitIdentity && (!gitCredentialPath || !gitPublicKeyPath)) {
|
|
1991
|
+
throw new Error("generated Git identity needs credential and public-key paths");
|
|
1992
|
+
}
|
|
1993
|
+
const gitPublicKeyDir = gitPublicKeyPath?.replace(/\/[^/]+$/, "");
|
|
1994
|
+
const lifecycleProfilePath = lifecycleEnabled ? systemdPath(options.lifecycleProfilePath, "lifecycle profile") : undefined;
|
|
1995
|
+
const lifecycleHelperSocketPath = lifecycleEnabled ? systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket") : undefined;
|
|
1996
|
+
const warpClientIdCredentialPath = warpEnabled ? systemdPath(options.warpClientIdCredentialPath, "WARP client-id credential") : undefined;
|
|
1997
|
+
const warpClientSecretCredentialPath = warpEnabled ? systemdPath(options.warpClientSecretCredentialPath, "WARP client-secret credential") : undefined;
|
|
1998
|
+
return {
|
|
1999
|
+
mode,
|
|
2000
|
+
reason: reasonFor(mode),
|
|
2001
|
+
unitPath: UNIT_PATH,
|
|
2002
|
+
unit: agentUnit({ ...options, mode, lifecycleProfilePath, lifecycleHelperSocketPath }),
|
|
2003
|
+
auxiliaryUnits: [
|
|
2004
|
+
{ path: AGENT_SOCKET_UNIT_PATH, unit: agentSocketUnit(options) },
|
|
2005
|
+
{ path: AGENT_SOCKET_PROXY_UNIT_PATH, unit: agentSocketProxyUnit(options) },
|
|
2006
|
+
{ path: AGENT_UPDATE_HELPER_UNIT_PATH, unit: agentUpdateHelperUnit(options) },
|
|
2007
|
+
...options.enforceEgress ? [
|
|
2008
|
+
{ path: AGENT_EGRESS_UNIT_PATH, unit: agentEgressUnit(options) }
|
|
2009
|
+
] : [],
|
|
2010
|
+
...deploymentEnabled ? [
|
|
2011
|
+
{ path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) },
|
|
2012
|
+
{ path: SOFTWARE_HELPER_UNIT_PATH, unit: softwareHelperUnit(options) }
|
|
2013
|
+
] : [],
|
|
2014
|
+
...enrolmentEnabled ? [
|
|
2015
|
+
{ path: ENROLMENT_UNIT_PATH, unit: agentEnrolmentUnit(options) }
|
|
2016
|
+
] : [],
|
|
2017
|
+
...lifecycleEnabled ? [
|
|
2018
|
+
{ path: LIFECYCLE_HELPER_UNIT_PATH, unit: lifecycleHelperUnit({
|
|
2019
|
+
...options,
|
|
2020
|
+
lifecycleProfilePath,
|
|
2021
|
+
lifecycleHelperSocketPath
|
|
2022
|
+
}) }
|
|
2023
|
+
] : [],
|
|
2024
|
+
...warpEnabled ? [
|
|
2025
|
+
{ path: WARP_CONFIG_UNIT_PATH, unit: warpConfigUnit({
|
|
2026
|
+
...options,
|
|
2027
|
+
warpClientIdCredentialPath,
|
|
2028
|
+
warpClientSecretCredentialPath
|
|
2029
|
+
}) },
|
|
2030
|
+
{ path: WARP_SERVICE_DROP_IN_PATH, unit: warpServiceDropIn() }
|
|
2031
|
+
] : []
|
|
2032
|
+
],
|
|
2033
|
+
socketPath: options.socketPath,
|
|
2034
|
+
user,
|
|
2035
|
+
steps: [
|
|
2036
|
+
...options.enforceEgress ? [{
|
|
2037
|
+
label: "Ubuntu Agent egress prerequisites",
|
|
2038
|
+
command: `. /etc/os-release; test "$ID" = ubuntu; ` + `DEBIAN_FRONTEND=noninteractive apt-get update -qq; ` + `DEBIAN_FRONTEND=noninteractive apt-get install -y nftables; ` + `systemctl enable --now systemd-resolved.service; ` + `test "$(readlink -f /etc/resolv.conf)" = /run/systemd/resolve/stub-resolv.conf; ` + `test -s /run/systemd/resolve/stub-resolv.conf`
|
|
2039
|
+
}] : [],
|
|
2040
|
+
{
|
|
2041
|
+
label: "vault socket access group",
|
|
2042
|
+
command: `groupadd --system ${VAULT_GROUP} || true`
|
|
2043
|
+
},
|
|
2044
|
+
{
|
|
2045
|
+
label: "Agent update helper access group",
|
|
2046
|
+
command: `groupadd --system ${AGENT_UPDATE_GROUP} || true`
|
|
2047
|
+
},
|
|
2048
|
+
...sourceBinPath && binPath ? [{
|
|
2049
|
+
label: "root-owned agent runtime",
|
|
2050
|
+
command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")} ` + `${DEFAULT_AGENT_RELEASE_ROOT}/versions/${VERSION}/dist; ` + `install -o root -g root -m 0755 ${sourceBinPath} ` + `${DEFAULT_AGENT_RELEASE_ROOT}/versions/${VERSION}/dist/fz-agent.js; ` + `ln -sfn versions/${VERSION} ${DEFAULT_AGENT_RELEASE_ROOT}/current.next; ` + `mv -Tf ${DEFAULT_AGENT_RELEASE_ROOT}/current.next ${DEFAULT_AGENT_RELEASE_ROOT}/current; ` + `rm -f ${binPath}; ln -s ${DEFAULT_AGENT_RELEASE_ROOT}/current/dist/fz-agent.js ${binPath}`
|
|
2051
|
+
}] : [],
|
|
2052
|
+
...warpEnabled ? [{
|
|
2053
|
+
label: "Cloudflare One client for Ubuntu 26.04",
|
|
2054
|
+
command: `. /etc/os-release; test "$ID" = ubuntu && test "$VERSION_ID" = 26.04; ` + `install -d -m 0755 /usr/share/keyrings /etc/apt/sources.list.d /etc/systemd/system/warp-svc.service.d; ` + `curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg -o /run/cloudflare-warp-key.gpg; ` + `gpg --batch --yes --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg /run/cloudflare-warp-key.gpg; ` + `rm -f /run/cloudflare-warp-key.gpg; ` + `printf 'deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ %s main\\n' "$VERSION_CODENAME" > /etc/apt/sources.list.d/cloudflare-client.list; ` + `apt-get update -qq; DEBIAN_FRONTEND=noninteractive apt-get install -y cloudflare-warp`
|
|
2055
|
+
}] : [],
|
|
2056
|
+
...deploymentEnabled ? [{
|
|
2057
|
+
label: "deployment isolation group",
|
|
2058
|
+
command: `groupadd --system ${DEPLOYMENT_GROUP} || true; groupadd --system ${SOFTWARE_HELPER_GROUP} || true`
|
|
2059
|
+
}] : [],
|
|
2060
|
+
...lifecycleEnabled ? [{
|
|
2061
|
+
label: "lifecycle helper access group",
|
|
2062
|
+
command: `groupadd --system ${LIFECYCLE_GROUP} || true`
|
|
2063
|
+
}] : [],
|
|
2064
|
+
{
|
|
2065
|
+
label: "service account",
|
|
2066
|
+
command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
|
|
2067
|
+
},
|
|
2068
|
+
{
|
|
2069
|
+
label: "bind service account to vault group",
|
|
2070
|
+
command: `usermod -g ${VAULT_GROUP} ${user}`
|
|
2071
|
+
},
|
|
2072
|
+
{
|
|
2073
|
+
label: "grant verified Agent update access",
|
|
2074
|
+
command: `usermod -a -G ${AGENT_UPDATE_GROUP} ${user}`
|
|
2075
|
+
},
|
|
2076
|
+
...lifecycleEnabled ? [{
|
|
2077
|
+
label: "grant lifecycle helper socket access",
|
|
2078
|
+
command: `usermod -a -G ${LIFECYCLE_GROUP} ${user}`
|
|
2079
|
+
}] : [],
|
|
2080
|
+
...deploymentEnabled ? [{
|
|
2081
|
+
label: "credential-free deployment account",
|
|
2082
|
+
command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP},${SOFTWARE_HELPER_GROUP} ${user}`
|
|
2083
|
+
}] : [],
|
|
2084
|
+
{
|
|
2085
|
+
label: "credential directory",
|
|
2086
|
+
command: `install -d -o root -g root -m 0700 ${credentialDir}`
|
|
2087
|
+
},
|
|
2088
|
+
{
|
|
2089
|
+
label: "Agent state directory",
|
|
2090
|
+
command: "install -d -o root -g root -m 0750 /var/lib/forgezero"
|
|
2091
|
+
},
|
|
2092
|
+
{
|
|
2093
|
+
label: "encrypted node identity",
|
|
2094
|
+
command: `test -s ${seedCredentialPath} || { ` + `openssl rand -base64 32 | tr '+/' '-_' | tr -d '=\\n' | ` + `systemd-creds encrypt --name=agent-seed - ${seedCredentialPath}; ` + `chmod 0400 ${seedCredentialPath}; }`
|
|
2095
|
+
},
|
|
2096
|
+
...options.generateGitIdentity && gitCredentialPath && gitPublicKeyPath ? [
|
|
2097
|
+
{
|
|
2098
|
+
label: "Git deploy identity directory",
|
|
2099
|
+
command: `install -d -o root -g root -m 0755 ${gitPublicKeyDir}`
|
|
2100
|
+
},
|
|
2101
|
+
{
|
|
2102
|
+
label: "unique encrypted Git deploy identity",
|
|
2103
|
+
command: `test -s ${gitCredentialPath} || { ` + `rm -f /run/forgezero-git-deploy-key /run/forgezero-git-deploy-key.pub; ` + `ssh-keygen -q -t ed25519 -N '' -C forgezero-compute -f /run/forgezero-git-deploy-key; ` + `systemd-creds encrypt --name=git-deploy-key /run/forgezero-git-deploy-key ${gitCredentialPath}; ` + `install -o root -g root -m 0444 /run/forgezero-git-deploy-key.pub ${gitPublicKeyPath}; ` + `rm -f /run/forgezero-git-deploy-key /run/forgezero-git-deploy-key.pub; ` + `chmod 0400 ${gitCredentialPath}; }; ` + `test -s ${gitPublicKeyPath} || { ` + `systemd-creds decrypt --name=git-deploy-key ${gitCredentialPath} /run/forgezero-git-deploy-key; ` + `ssh-keygen -y -f /run/forgezero-git-deploy-key | ` + `sed 's/$/ forgezero-compute/' > /run/forgezero-git-deploy-key.pub; ` + `install -o root -g root -m 0444 /run/forgezero-git-deploy-key.pub ${gitPublicKeyPath}; ` + `rm -f /run/forgezero-git-deploy-key /run/forgezero-git-deploy-key.pub; }; ` + `test -s ${gitCredentialPath} && test -s ${gitPublicKeyPath}`
|
|
2104
|
+
}
|
|
2105
|
+
] : [],
|
|
2106
|
+
...enrolmentEnabled ? [
|
|
2107
|
+
{
|
|
2108
|
+
label: "enrolment state directory",
|
|
2109
|
+
command: `install -d -o ${user} -g ${user} -m 0700 ${enrolStateDir}`
|
|
2110
|
+
},
|
|
2111
|
+
{
|
|
2112
|
+
label: "encrypted one-time enrolment capability",
|
|
2113
|
+
command: `test -s ${enrolStatePath} || test -s ${enrolTokenCredentialPath} || { test -r ${enrolTokenSourcePath}; ` + `systemd-creds encrypt --name=enrol-token ${enrolTokenSourcePath} ${enrolTokenCredentialPath}; ` + `chmod 0400 ${enrolTokenCredentialPath}; rm -f ${enrolTokenSourcePath}; }`
|
|
2114
|
+
}
|
|
2115
|
+
] : [],
|
|
2116
|
+
...deploymentEnabled ? [{
|
|
2117
|
+
label: "deployment directories",
|
|
2118
|
+
command: `install -d -o root -g root -m 0755 ${deployRoot} && ` + `install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 ${deployRoot}/releases && ` + `install -d -o ${user} -g ${user} -m 0750 ${deployRoot}/cache && ` + `install -d -o ${user} -g ${user} -m 0700 ${deployRoot}/capacity && ` + `install -d -o ${user} -g ${user} -m 0700 ${deployRoot}/agent-home && ` + `install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 ${deployRoot}/runner-home ${deployRoot}/runner-home/cache`
|
|
2119
|
+
}] : [],
|
|
2120
|
+
{ label: "reload units", command: "systemctl daemon-reload" },
|
|
2121
|
+
...deploymentEnabled ? [{
|
|
2122
|
+
label: "remove unsupported deployment socket activation",
|
|
2123
|
+
command: "systemctl disable --now forgezero-deploy-runner.socket 2>/dev/null || true; rm -f /etc/systemd/system/forgezero-deploy-runner.socket; systemctl daemon-reload"
|
|
2124
|
+
}] : [],
|
|
2125
|
+
{
|
|
2126
|
+
label: "enable and converge services",
|
|
2127
|
+
command: `systemctl enable ${[
|
|
2128
|
+
"forgezero-agent.socket",
|
|
2129
|
+
"forgezero-agent-update-helper.service",
|
|
2130
|
+
...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
|
|
2131
|
+
...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
|
|
2132
|
+
...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
|
|
2133
|
+
...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
|
|
2134
|
+
...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : [],
|
|
2135
|
+
"forgezero-agent.service"
|
|
2136
|
+
].join(" ")}; systemctl reset-failed forgezero-agent.service || true; systemctl restart ${[
|
|
2137
|
+
"forgezero-agent-update-helper.service",
|
|
2138
|
+
...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
|
|
2139
|
+
...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
|
|
2140
|
+
...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
|
|
2141
|
+
...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
|
|
2142
|
+
...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : []
|
|
2143
|
+
].join(" ")}; systemctl restart forgezero-agent.socket; systemctl reset-failed forgezero-agent.service || true; systemctl restart forgezero-agent.service`
|
|
2144
|
+
},
|
|
2145
|
+
...enrolmentEnabled ? [{
|
|
2146
|
+
label: "prove the compute binding is durable",
|
|
2147
|
+
command: `test -s ${enrolStatePath}`
|
|
2148
|
+
}] : [],
|
|
2149
|
+
...options.enforceEgress ? [{
|
|
2150
|
+
label: "prove the Agent egress policy is active",
|
|
2151
|
+
command: "systemctl is-active forgezero-agent-egress.service && " + "nft --numeric list table inet forgezero_agent_egress | grep -q forgezero-agent-egress-v1" + (deploymentEnabled ? ` && nft --numeric list table inet forgezero_agent_egress | grep -q 'public-tcp=${runnerPublicTcpPorts.join(",")}'` + (runnerLoopbackPorts.length > 0 ? ` && nft --numeric list table inet forgezero_agent_egress | grep -Eq 'loopback=[0-9]+:${runnerLoopbackPorts.join(",")}( |")'` : "") : "")
|
|
2152
|
+
}] : [],
|
|
2153
|
+
{ label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
|
|
2154
|
+
{ label: "prove the public Vault socket exists", command: awaitSocketCommand(options.socketPath) },
|
|
2155
|
+
{ label: "prove the Agent Vault backend exists", command: awaitSocketCommand(agentBackendSocketPath(options.socketPath)) },
|
|
2156
|
+
{ label: "prove the Agent update helper exists", command: awaitSocketCommand(DEFAULT_AGENT_UPDATE_SOCKET) },
|
|
2157
|
+
...deploymentEnabled ? [{
|
|
2158
|
+
label: "prove the deployment runner socket exists",
|
|
2159
|
+
command: awaitSocketCommand(DEPLOYMENT_RUNNER_SOCKET)
|
|
2160
|
+
}, {
|
|
2161
|
+
label: "prove the software strategy helper socket exists",
|
|
2162
|
+
command: awaitSocketCommand(DEFAULT_SOFTWARE_HELPER_SOCKET)
|
|
2163
|
+
}] : [],
|
|
2164
|
+
...lifecycleEnabled ? [{
|
|
2165
|
+
label: "prove the lifecycle helper socket exists",
|
|
2166
|
+
command: awaitSocketCommand(lifecycleHelperSocketPath)
|
|
2167
|
+
}] : [],
|
|
2168
|
+
...warpEnabled ? [{
|
|
2169
|
+
label: "prove Cloudflare WARP is connected",
|
|
2170
|
+
command: `warp-cli --accept-tos status | grep -Eiq '(^|[[:space:]])Connected([[:space:]]|$)'`
|
|
2171
|
+
}] : [],
|
|
2172
|
+
...options.repository ? [{
|
|
2173
|
+
label: "prove the deployment control socket exists",
|
|
2174
|
+
command: awaitSocketCommand(options.controlSocketPath ?? "/run/forgezero/control.sock")
|
|
2175
|
+
}] : []
|
|
2176
|
+
]
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
// src/cli/agent-install.ts
|
|
2181
|
+
async function readCapabilities(run) {
|
|
2182
|
+
const answers = {};
|
|
2183
|
+
const checks = Object.entries(CAPABILITY_CHECKS);
|
|
2184
|
+
for (const [id, check] of checks) {
|
|
2185
|
+
try {
|
|
2186
|
+
const result = await run(check.command);
|
|
2187
|
+
answers[id] = check.satisfied(result.stdout, result.exitCode);
|
|
2188
|
+
} catch {
|
|
2189
|
+
answers[id] = false;
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
return answers;
|
|
2193
|
+
}
|
|
2194
|
+
async function localRunner(command) {
|
|
2195
|
+
const proc = Bun.spawn(["sh", "-c", command], { stdout: "pipe", stderr: "pipe" });
|
|
2196
|
+
const stdout = await new Response(proc.stdout).text();
|
|
2197
|
+
return { stdout, exitCode: await proc.exited };
|
|
2198
|
+
}
|
|
2199
|
+
function planInstall(options) {
|
|
2200
|
+
const { capabilities, ...unit } = options;
|
|
2201
|
+
return planProvision({ ...unit, mode: modeFor(capabilities) });
|
|
2202
|
+
}
|
|
2203
|
+
async function applyPlan(plan, run) {
|
|
2204
|
+
const transcript = [];
|
|
2205
|
+
for (const step of plan.steps) {
|
|
2206
|
+
const result = await run(step.command);
|
|
2207
|
+
transcript.push({ label: step.label, command: step.command, exitCode: result.exitCode });
|
|
2208
|
+
if (result.exitCode !== 0 && !step.optional) {
|
|
2209
|
+
throw new Error(`${step.label} failed (exit ${result.exitCode}): ${step.command}`);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
return transcript;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
// src/platform-bootstrap-runtime.ts
|
|
2216
|
+
var safeAtom = (name, value) => {
|
|
2217
|
+
if (!value || /[\0\r\n]/.test(value))
|
|
2218
|
+
throw new Error(`${name} must be non-empty and single-line.`);
|
|
2219
|
+
return value;
|
|
2220
|
+
};
|
|
2221
|
+
var boundedInteger = (name, value, minimum, maximum) => {
|
|
2222
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
|
2223
|
+
throw new Error(`${name} must be an integer from ${minimum} through ${maximum}.`);
|
|
2224
|
+
}
|
|
2225
|
+
return value;
|
|
2226
|
+
};
|
|
2227
|
+
var privateCoordinator = (raw) => {
|
|
2228
|
+
const url = new URL(raw);
|
|
2229
|
+
if (url.protocol !== "http:" || url.username || url.password || url.search || url.hash || url.pathname !== "/") {
|
|
2230
|
+
throw new Error("ArangoDB coordinator URLs must be credential-free private HTTP origins.");
|
|
2231
|
+
}
|
|
2232
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
2233
|
+
const privateHost = host === "localhost" || host === "::1" || host.startsWith("fd") || host.startsWith("fc") || /^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
|
|
2234
|
+
if (!privateHost || url.port && url.port !== "8529") {
|
|
2235
|
+
throw new Error("ArangoDB coordinators must use private addresses and port 8529.");
|
|
2236
|
+
}
|
|
2237
|
+
return url.origin;
|
|
2238
|
+
};
|
|
2239
|
+
var httpsOrigin = (name, raw) => {
|
|
2240
|
+
const value = new URL(raw);
|
|
2241
|
+
if (value.protocol !== "https:" || value.username || value.password || value.search || value.hash || value.pathname !== "/") {
|
|
2242
|
+
throw new Error(`${name} must be a credential-free HTTPS origin.`);
|
|
2243
|
+
}
|
|
2244
|
+
return value.origin;
|
|
2245
|
+
};
|
|
2246
|
+
var systemdValue = (name, raw) => {
|
|
2247
|
+
if (/[\0\r\n]/.test(raw))
|
|
2248
|
+
throw new Error(`${name} must be single-line.`);
|
|
2249
|
+
const value = raw;
|
|
2250
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', "\\\"").replaceAll("$", "\\$")}"`;
|
|
2251
|
+
};
|
|
2252
|
+
function validatePlatformSharedEnvironment(input, options = {}) {
|
|
2253
|
+
if (input.softwareProfile === "platform-api" !== (input.databaseRole === "none")) {
|
|
2254
|
+
throw new Error("platform-api requires database role none; platform-db-api requires master or joiner.");
|
|
2255
|
+
}
|
|
2256
|
+
if (input.databaseCoordinators.length < 1 || input.databaseCoordinators.length > 16) {
|
|
2257
|
+
throw new Error("databaseCoordinators must contain 1 through 16 endpoints.");
|
|
2258
|
+
}
|
|
2259
|
+
const coordinators = input.databaseCoordinators.map(privateCoordinator);
|
|
2260
|
+
if (new Set(coordinators).size !== coordinators.length)
|
|
2261
|
+
throw new Error("databaseCoordinators must be unique.");
|
|
2262
|
+
if (!["private-lan", "cloudflare-warp"].includes(input.databaseNetworkMode)) {
|
|
2263
|
+
throw new Error("Database networking must be private-lan or cloudflare-warp.");
|
|
2264
|
+
}
|
|
2265
|
+
boundedInteger("databaseReplicationFactor", input.databaseReplicationFactor, 1, 16);
|
|
2266
|
+
boundedInteger("databaseWriteConcern", input.databaseWriteConcern, 1, 16);
|
|
2267
|
+
if (input.databaseWriteConcern > input.databaseReplicationFactor) {
|
|
2268
|
+
throw new Error("databaseWriteConcern cannot exceed databaseReplicationFactor.");
|
|
2269
|
+
}
|
|
2270
|
+
boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
|
|
2271
|
+
boundedInteger("seedSyncMembers", input.seedSyncMembers, 1, 64);
|
|
2272
|
+
boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
|
|
2273
|
+
boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
|
|
2274
|
+
boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
|
|
2275
|
+
if (!Number.isFinite(input.otlpTraceSampleRatio) || input.otlpTraceSampleRatio < 0 || input.otlpTraceSampleRatio > 1) {
|
|
2276
|
+
throw new Error("otlpTraceSampleRatio must be from 0 through 1.");
|
|
2277
|
+
}
|
|
2278
|
+
if (input.otlpEndpoint !== "http://127.0.0.1:4318")
|
|
2279
|
+
throw new Error("OTLP must use the exact local collector endpoint.");
|
|
2280
|
+
validateCollectorUnit(input.otlpCollectorUnit);
|
|
2281
|
+
httpsOrigin("agentOtlpEndpoint", input.agentOtlpEndpoint);
|
|
2282
|
+
for (const [name, value] of Object.entries({
|
|
2283
|
+
nodeHostname: input.nodeHostname,
|
|
2284
|
+
nodeRegion: input.nodeRegion,
|
|
2285
|
+
databaseUser: input.databaseUser,
|
|
2286
|
+
sharedDirectory: input.sharedDirectory,
|
|
2287
|
+
seedSyncEpoch: input.seedSyncEpoch,
|
|
2288
|
+
repository: input.repository,
|
|
2289
|
+
branch: input.branch,
|
|
2290
|
+
deployProfile: input.deployProfile
|
|
2291
|
+
}))
|
|
2292
|
+
safeAtom(name, value);
|
|
2293
|
+
if (!input.sharedDirectory.startsWith("/"))
|
|
2294
|
+
throw new Error("sharedDirectory must be absolute.");
|
|
2295
|
+
for (const peer of input.seedSyncPeers) {
|
|
2296
|
+
const url = new URL(peer);
|
|
2297
|
+
if (url.protocol !== "ws:" && url.protocol !== "wss:")
|
|
2298
|
+
throw new Error("Seed peers must be WebSocket URLs.");
|
|
2299
|
+
if (url.username || url.password || url.hash)
|
|
2300
|
+
throw new Error("Seed peers cannot contain credentials or fragments.");
|
|
2301
|
+
}
|
|
2302
|
+
if (input.smtp) {
|
|
2303
|
+
safeAtom("smtp.host", input.smtp.host);
|
|
2304
|
+
boundedInteger("smtp.port", input.smtp.port, 1, 65535);
|
|
2305
|
+
safeAtom("smtp.from", input.smtp.from);
|
|
2306
|
+
if (input.smtp.user)
|
|
2307
|
+
safeAtom("smtp.user", input.smtp.user);
|
|
2308
|
+
}
|
|
2309
|
+
if (input.backup) {
|
|
2310
|
+
httpsOrigin("backup.endpoint", input.backup.endpoint);
|
|
2311
|
+
for (const [name, value] of Object.entries(input.backup))
|
|
2312
|
+
safeAtom(`backup.${name}`, value);
|
|
2313
|
+
}
|
|
2314
|
+
if (input.cloudflare) {
|
|
2315
|
+
if (![input.cloudflare.accountId, input.cloudflare.zoneId, input.cloudflare.kvNamespaceId].every((item) => /^[a-f0-9]{32}$/i.test(item)) || !/^[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(input.cloudflare.tunnelId)) {
|
|
2316
|
+
throw new Error("Cloudflare account, zone, KV and Tunnel ids are malformed.");
|
|
2317
|
+
}
|
|
2318
|
+
const service = new URL(input.cloudflare.tunnelService);
|
|
2319
|
+
if (service.protocol !== "http:" || !["127.0.0.1", "localhost", "::1"].includes(service.hostname) || service.username || service.password || service.search || service.hash)
|
|
2320
|
+
throw new Error("Cloudflare Tunnel service must be loopback HTTP.");
|
|
2321
|
+
if (input.cloudflare.warp) {
|
|
2322
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/.test(input.cloudflare.warp.organization) || !/^[0-9a-f-]{36}$/i.test(input.cloudflare.warp.virtualNetworkId) || !/^[A-Za-z0-9_-]{1,128}$/.test(input.cloudflare.warp.deviceProfileId)) {
|
|
2323
|
+
throw new Error("Cloudflare WARP organization, VNET or device profile is malformed.");
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
if (!options.allowPendingCloudflareHandoff && input.databaseNetworkMode === "cloudflare-warp" !== Boolean(input.cloudflare?.warp)) {
|
|
2328
|
+
throw new Error("cloudflare-warp networking requires its exact enrolled Cloudflare coordinates.");
|
|
2329
|
+
}
|
|
2330
|
+
return {
|
|
2331
|
+
...input,
|
|
2332
|
+
databaseCoordinators: coordinators,
|
|
2333
|
+
appOrigin: httpsOrigin("appOrigin", input.appOrigin),
|
|
2334
|
+
apiOrigin: httpsOrigin("apiOrigin", input.apiOrigin),
|
|
2335
|
+
agentOtlpEndpoint: httpsOrigin("agentOtlpEndpoint", input.agentOtlpEndpoint)
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
function renderPlatformSharedEnvironment(input) {
|
|
2339
|
+
const value = validatePlatformSharedEnvironment(input);
|
|
2340
|
+
const appHost = new URL(value.appOrigin).hostname.split(".").slice(-2).join(".");
|
|
2341
|
+
const apiHost = new URL(value.apiOrigin).hostname.split(".").slice(-2).join(".");
|
|
2342
|
+
const entries = {
|
|
2343
|
+
ARANGO_URL: value.databaseCoordinators[0],
|
|
2344
|
+
ARANGO_URLS: value.databaseCoordinators.join(","),
|
|
2345
|
+
ARANGO_DB: "fz",
|
|
2346
|
+
FZ_DATABASE_MODE: "platform",
|
|
2347
|
+
ARANGO_USER: value.databaseUser,
|
|
2348
|
+
ARANGO_REPLICATION_FACTOR: String(value.databaseReplicationFactor),
|
|
2349
|
+
ARANGO_WRITE_CONCERN: String(value.databaseWriteConcern),
|
|
2350
|
+
FZ_DB_ROLE: value.databaseRole,
|
|
2351
|
+
FZ_SOFTWARE_PROFILE: value.softwareProfile,
|
|
2352
|
+
FZ_ROLE: value.nodeRole,
|
|
2353
|
+
FZ_DB_ADDRESS: value.databaseAddress ?? "",
|
|
2354
|
+
FZ_DB_MASTER: value.databaseMaster ?? "",
|
|
2355
|
+
FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
|
|
2356
|
+
FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
|
|
2357
|
+
FZ_SEED_SYNC_MEMBERS: String(value.seedSyncMembers),
|
|
2358
|
+
FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
|
|
2359
|
+
FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
|
|
2360
|
+
FZ_SHARED_DIR: value.sharedDirectory,
|
|
2361
|
+
FZ_PUBLIC_API_PORT: String(value.publicApiPort),
|
|
2362
|
+
ORIGIN: value.appOrigin,
|
|
2363
|
+
API_ORIGIN: value.apiOrigin,
|
|
2364
|
+
HOST: "127.0.0.1",
|
|
2365
|
+
APP_ORIGINS: value.appOrigin,
|
|
2366
|
+
TRUST_CLOUDFLARE_IP: "1",
|
|
2367
|
+
SESSION_COOKIE_SAMESITE: appHost === apiHost ? "lax" : "none",
|
|
2368
|
+
SESSION_COOKIE_DOMAIN: "",
|
|
2369
|
+
FZ_NODE_HOSTNAME: value.nodeHostname,
|
|
2370
|
+
FZ_NODE_REGION: value.nodeRegion,
|
|
2371
|
+
FZ_CONCURRENCY_LIMIT: String(value.concurrencyLimit),
|
|
2372
|
+
FZ_DRAIN_DEADLINE_MS: String(value.drainDeadlineMs),
|
|
2373
|
+
OTEL_EXPORTER_OTLP_ENDPOINT: value.otlpEndpoint,
|
|
2374
|
+
FZ_OTLP_COLLECTOR_UNIT: value.otlpCollectorUnit,
|
|
2375
|
+
OTEL_SERVICE_NAME: "forgezero-api",
|
|
2376
|
+
FZ_OTLP_FLUSH_INTERVAL_MS: String(value.otlpFlushIntervalMs),
|
|
2377
|
+
FZ_OTLP_TRACE_SAMPLE_RATIO: String(value.otlpTraceSampleRatio),
|
|
2378
|
+
FZ_AGENT_OTLP_ENDPOINT: value.agentOtlpEndpoint,
|
|
2379
|
+
FZ_CUSTODIAN_EMAIL: value.custodianEmail ?? "",
|
|
2380
|
+
FZ_PROFILE: value.deployProfile,
|
|
2381
|
+
FZ_REPO: value.repository,
|
|
2382
|
+
FZ_BRANCH: value.branch,
|
|
2383
|
+
FZ_SMTP_HOST: value.smtp?.host ?? "",
|
|
2384
|
+
FZ_SMTP_PORT: value.smtp ? String(value.smtp.port) : "",
|
|
2385
|
+
FZ_SMTP_USER: value.smtp?.user ?? "",
|
|
2386
|
+
FZ_SMTP_FROM: value.smtp?.from ?? "",
|
|
2387
|
+
BACKUP_S3_ENDPOINT: value.backup?.endpoint ?? "",
|
|
2388
|
+
BACKUP_S3_REGION: value.backup?.region ?? "",
|
|
2389
|
+
BACKUP_S3_BUCKET: value.backup?.bucket ?? "",
|
|
2390
|
+
BACKUP_S3_ACCESS_KEY_ID: value.backup?.accessKeyId ?? "",
|
|
2391
|
+
FZ_CF_ACCOUNT_ID: value.cloudflare?.accountId ?? "",
|
|
2392
|
+
FZ_CF_ZONE_ID: value.cloudflare?.zoneId ?? "",
|
|
2393
|
+
FZ_CF_KV_NAMESPACE_ID: value.cloudflare?.kvNamespaceId ?? "",
|
|
2394
|
+
FZ_CF_TUNNEL_ID: value.cloudflare?.tunnelId ?? "",
|
|
2395
|
+
FZ_CF_TUNNEL_SERVICE: value.cloudflare?.tunnelService ?? "",
|
|
2396
|
+
FZ_WARP_ORGANIZATION: value.cloudflare?.warp?.organization ?? "",
|
|
2397
|
+
FZ_CF_VIRTUAL_NETWORK_ID: value.cloudflare?.warp?.virtualNetworkId ?? "",
|
|
2398
|
+
FZ_CF_WARP_POLICY_ID: value.cloudflare?.warp?.deviceProfileId ?? ""
|
|
2399
|
+
};
|
|
2400
|
+
return `# Generated by fz bootstrap platform. Non-secret coordinates only.
|
|
2401
|
+
` + Object.entries(entries).map(([key, entry]) => `${key}=${systemdValue(key, entry)}`).join(`
|
|
2402
|
+
`) + `
|
|
2403
|
+
`;
|
|
2404
|
+
}
|
|
2405
|
+
function platformApiCredentialSpecs(options) {
|
|
2406
|
+
const optional = [
|
|
2407
|
+
["bootstrap-smtp-password", options.smtp],
|
|
2408
|
+
["cloudflare-kv-token", options.cloudflareKv],
|
|
2409
|
+
["cloudflare-network-token", options.cloudflareNetwork]
|
|
2410
|
+
];
|
|
2411
|
+
return [
|
|
2412
|
+
{ name: "arangodb-jwt", encryptedPath: "/etc/forgezero/creds/arangodb-jwt.cred", required: true },
|
|
2413
|
+
{ name: "seed-sync-root", encryptedPath: "/etc/forgezero/creds/seed-sync-root.cred", required: true },
|
|
2414
|
+
...optional.filter(([, present]) => present).map(([name]) => ({
|
|
2415
|
+
name,
|
|
2416
|
+
encryptedPath: `/etc/forgezero/creds/${name}.cred`,
|
|
2417
|
+
required: false
|
|
2418
|
+
}))
|
|
2419
|
+
];
|
|
2420
|
+
}
|
|
2421
|
+
function renderPlatformApiUnits(input) {
|
|
2422
|
+
for (const path of [input.sharedDirectory, input.sharedEnvironmentFile, input.slotsDirectory]) {
|
|
2423
|
+
if (!path.startsWith("/") || /[\r\n]/.test(path))
|
|
2424
|
+
throw new Error("Runtime paths must be absolute and single-line.");
|
|
2425
|
+
}
|
|
2426
|
+
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(input.serviceUser))
|
|
2427
|
+
throw new Error("Invalid service user.");
|
|
2428
|
+
validateCollectorUnit(input.collectorUnit);
|
|
2429
|
+
boundedInteger("bluePort", input.bluePort, 1024, 65535);
|
|
2430
|
+
boundedInteger("greenPort", input.greenPort, 1024, 65535);
|
|
2431
|
+
if (input.bluePort === input.greenPort)
|
|
2432
|
+
throw new Error("Blue and green ports must differ.");
|
|
2433
|
+
const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
|
|
2434
|
+
`);
|
|
2435
|
+
const template = `[Unit]
|
|
2436
|
+
Description=ForgeZero (%i slot)
|
|
2437
|
+
After=network-online.target ${input.collectorUnit}
|
|
2438
|
+
Wants=network-online.target ${input.collectorUnit}
|
|
2439
|
+
|
|
2440
|
+
[Service]
|
|
2441
|
+
Type=simple
|
|
2442
|
+
User=${input.serviceUser}
|
|
2443
|
+
WorkingDirectory=${input.slotsDirectory}/%i
|
|
2444
|
+
Environment=NODE_ENV=production
|
|
2445
|
+
Environment=FZ_SLOT=%i
|
|
2446
|
+
EnvironmentFile=${input.sharedEnvironmentFile}
|
|
2447
|
+
${credentials}
|
|
2448
|
+
ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
|
|
2449
|
+
Restart=always
|
|
2450
|
+
RestartSec=2
|
|
2451
|
+
TimeoutStopSec=35s
|
|
2452
|
+
LimitCORE=0
|
|
2453
|
+
UMask=0077
|
|
2454
|
+
NoNewPrivileges=yes
|
|
2455
|
+
PrivateTmp=yes
|
|
2456
|
+
PrivateDevices=yes
|
|
2457
|
+
ProtectSystem=strict
|
|
2458
|
+
ProtectHome=yes
|
|
2459
|
+
ReadOnlyPaths=${input.sharedDirectory}
|
|
2460
|
+
ProtectKernelTunables=yes
|
|
2461
|
+
ProtectKernelModules=yes
|
|
2462
|
+
ProtectControlGroups=yes
|
|
2463
|
+
RestrictSUIDSGID=yes
|
|
2464
|
+
RestrictRealtime=yes
|
|
2465
|
+
LockPersonality=yes
|
|
2466
|
+
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
|
2467
|
+
|
|
2468
|
+
[Install]
|
|
2469
|
+
WantedBy=multi-user.target
|
|
2470
|
+
`;
|
|
2471
|
+
return { template, dropIns: {
|
|
2472
|
+
blue: `[Service]
|
|
2473
|
+
Environment=PORT=${input.bluePort}
|
|
2474
|
+
`,
|
|
2475
|
+
green: `[Service]
|
|
2476
|
+
Environment=PORT=${input.greenPort}
|
|
2477
|
+
`
|
|
2478
|
+
} };
|
|
2479
|
+
}
|
|
2480
|
+
function renderPlatformNginx(input) {
|
|
2481
|
+
boundedInteger("publicPort", input.publicPort, 1024, 65535);
|
|
2482
|
+
boundedInteger("initialSlotPort", input.initialSlotPort, 1024, 65535);
|
|
2483
|
+
if (input.publicPort === input.initialSlotPort)
|
|
2484
|
+
throw new Error("Edge and slot ports must differ.");
|
|
2485
|
+
return {
|
|
2486
|
+
upstream: `upstream forgezero { server 127.0.0.1:${input.initialSlotPort}; }
|
|
2487
|
+
`,
|
|
2488
|
+
site: `server {
|
|
2489
|
+
listen 127.0.0.1:${input.publicPort};
|
|
2490
|
+
server_name _;
|
|
2491
|
+
location ^~ /api/ { proxy_pass http://forgezero; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }
|
|
2492
|
+
location ^~ /v1/ { proxy_pass http://forgezero; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }
|
|
2493
|
+
location / { return 404; }
|
|
2494
|
+
}
|
|
2495
|
+
`
|
|
2496
|
+
};
|
|
2497
|
+
}
|
|
2498
|
+
function renderPlatformActivationFiles(input) {
|
|
2499
|
+
if (!input.root.startsWith("/") || /[\0\r\n]/.test(input.root))
|
|
2500
|
+
throw new Error("Activation root must be absolute and single-line.");
|
|
2501
|
+
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(input.serviceUser))
|
|
2502
|
+
throw new Error("Invalid activation service user.");
|
|
2503
|
+
boundedInteger("bluePort", input.bluePort, 1024, 65535);
|
|
2504
|
+
boundedInteger("greenPort", input.greenPort, 1024, 65535);
|
|
2505
|
+
if (input.bluePort === input.greenPort)
|
|
2506
|
+
throw new Error("Activation slot ports must differ.");
|
|
2507
|
+
boundedInteger("keepReleases", input.keepReleases, 2, 100);
|
|
2508
|
+
if (!/^\/[A-Za-z0-9/_-]{1,128}$/.test(input.healthPath) || input.healthPath.includes("..")) {
|
|
2509
|
+
throw new Error("Activation health path is malformed.");
|
|
2510
|
+
}
|
|
2511
|
+
const environment = [
|
|
2512
|
+
`FZ_DIR=${input.root}`,
|
|
2513
|
+
`FZ_USER=${input.serviceUser}`,
|
|
2514
|
+
`FZ_BLUE_PORT=${input.bluePort}`,
|
|
2515
|
+
`FZ_GREEN_PORT=${input.greenPort}`,
|
|
2516
|
+
`FZ_HEALTH_PATH=${input.healthPath}`,
|
|
2517
|
+
`FZ_KEEP_RELEASES=${input.keepReleases}`
|
|
2518
|
+
].join(`
|
|
2519
|
+
`) + `
|
|
2520
|
+
`;
|
|
2521
|
+
const helper = `#!/usr/bin/env bash
|
|
2522
|
+
set -Eeuo pipefail
|
|
2523
|
+
source /etc/forgezero/deploy.env
|
|
2524
|
+
[[ $# == 1 ]] || { echo "usage: forgezero-activate <release>" >&2; exit 2; }
|
|
2525
|
+
release="$(realpath -e "$1")"; releases="$(realpath -e "$FZ_DIR/releases")"; slots="$FZ_DIR/slots"
|
|
2526
|
+
install -d -o root -g root -m 0755 "$slots"
|
|
2527
|
+
case "$release/" in "$releases"/*/) ;; *) echo "release is outside $releases" >&2; exit 2 ;; esac
|
|
2528
|
+
[[ -f "$release/.fz/deploy.json" && -s "$release/src/index.ts" && -s "$release/bun.lock" ]] || { echo "release is incomplete" >&2; exit 2; }
|
|
2529
|
+
slot_file="$FZ_DIR/.forge-slot"; previous_slot="$(cat "$slot_file" 2>/dev/null || true)"
|
|
2530
|
+
if [[ "$previous_slot" == blue ]]; then target=green; port="$FZ_GREEN_PORT"; else target=blue; port="$FZ_BLUE_PORT"; fi
|
|
2531
|
+
target_link="$slots/$target"; previous_target_link="$(readlink -f "$target_link" 2>/dev/null || true)"
|
|
2532
|
+
chown -R root:"$FZ_USER" "$release"; chmod -R a-w "$release"; find "$release" -type d -exec chmod a+rx {} +; find "$release" -type f -exec chmod a+r {} +
|
|
2533
|
+
ln -sfn "$release" "$target_link"; systemctl restart "forgezero@\${target}.service"
|
|
2534
|
+
healthy=0; for _ in $(seq 1 30); do curl -fsS --max-time 2 "http://127.0.0.1:\${port}\${FZ_HEALTH_PATH}" >/dev/null 2>&1 && { healthy=1; break; }; sleep 1; done
|
|
2535
|
+
if (( ! healthy )); then systemctl stop "forgezero@\${target}.service" || true; [[ -n "$previous_target_link" && -d "$previous_target_link" ]] && ln -sfn "$previous_target_link" "$target_link" || rm -f "$target_link"; exit 1; fi
|
|
2536
|
+
upstream=/etc/nginx/conf.d/forgezero-upstream.conf; backup="$(mktemp -p /run forgezero-upstream.XXXXXX)"; [[ -f "$upstream" ]] && cp "$upstream" "$backup" || : >"$backup"
|
|
2537
|
+
printf 'upstream forgezero { server 127.0.0.1:%s; }\\n' "$port" >"$upstream"
|
|
2538
|
+
if ! nginx -t || ! nginx -s reload; then [[ -s "$backup" ]] && cp "$backup" "$upstream" || rm -f "$upstream"; rm -f "$backup"; systemctl stop "forgezero@\${target}.service" || true; [[ -n "$previous_target_link" && -d "$previous_target_link" ]] && ln -sfn "$previous_target_link" "$target_link" || rm -f "$target_link"; nginx -t >/dev/null 2>&1 && nginx -s reload || true; exit 1; fi
|
|
2539
|
+
rm -f "$backup"; printf '%s\\n' "$target" >"$slot_file"; [[ -n "$previous_slot" && "$previous_slot" != "$target" ]] && systemctl stop "forgezero@\${previous_slot}.service" || true
|
|
2540
|
+
mapfile -t old < <(find "$releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -rn | tail -n "+$((FZ_KEEP_RELEASES + 1))" | cut -d' ' -f2-)
|
|
2541
|
+
for path in "\${old[@]}"; do [[ "$path" == "$release" ]] || rm -rf -- "$path"; done
|
|
2542
|
+
printf 'promoted %s on %s\\n' "$release" "$target"
|
|
2543
|
+
`;
|
|
2544
|
+
return {
|
|
2545
|
+
environment,
|
|
2546
|
+
helper,
|
|
2547
|
+
sudoers: `forgezero-runner ALL=(root) NOPASSWD: /usr/local/libexec/forgezero-activate *
|
|
2548
|
+
`
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
2551
|
+
function validateCollectorUnit(unit) {
|
|
2552
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}\.service$/.test(unit))
|
|
2553
|
+
throw new Error("Invalid OTLP collector service unit.");
|
|
2554
|
+
if (/^(forgezero@.*|forgezero-agent|forgezero-metal-agent|forgezero-db)\.service$/.test(unit)) {
|
|
2555
|
+
throw new Error("OTLP collector must be independently supervised.");
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
function planLocalOtlpProof(endpoint2, collectorUnit) {
|
|
2559
|
+
if (endpoint2 !== "http://127.0.0.1:4318")
|
|
2560
|
+
throw new Error("OTLP proof requires exact loopback endpoint http://127.0.0.1:4318.");
|
|
2561
|
+
validateCollectorUnit(collectorUnit);
|
|
2562
|
+
return {
|
|
2563
|
+
unitCheck: { command: "systemctl", argv: ["is-active", "--quiet", collectorUnit] },
|
|
2564
|
+
receiverCheck: {
|
|
2565
|
+
command: "curl",
|
|
2566
|
+
acceptedStatus: "2xx",
|
|
2567
|
+
argv: [
|
|
2568
|
+
"--silent",
|
|
2569
|
+
"--show-error",
|
|
2570
|
+
"--max-time",
|
|
2571
|
+
"5",
|
|
2572
|
+
"--output",
|
|
2573
|
+
"/dev/null",
|
|
2574
|
+
"--write-out",
|
|
2575
|
+
"%{http_code}",
|
|
2576
|
+
"--request",
|
|
2577
|
+
"POST",
|
|
2578
|
+
"--header",
|
|
2579
|
+
"Content-Type: application/json",
|
|
2580
|
+
"--data-binary",
|
|
2581
|
+
"{}",
|
|
2582
|
+
`${endpoint2}/v1/metrics`
|
|
2583
|
+
]
|
|
2584
|
+
}
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
// src/bootstrap.ts
|
|
2589
|
+
import { DEFAULT_SOCKET as DEFAULT_SOCKET2 } from "@forgezero/vault";
|
|
2590
|
+
var PLATFORM_BOOTSTRAP_PROFILES = [
|
|
2591
|
+
"platform-db-api",
|
|
2592
|
+
"platform-api"
|
|
2593
|
+
];
|
|
2594
|
+
var STATE_PATH = "/var/lib/forgezero/bootstrap.json";
|
|
2595
|
+
var CREDS = "/etc/forgezero/creds";
|
|
2596
|
+
var JWT_CREDENTIAL = `${CREDS}/arangodb-jwt.cred`;
|
|
2597
|
+
var ENROL_CREDENTIAL = `${CREDS}/enrol-token.cred`;
|
|
2598
|
+
var TUNNEL_CREDENTIAL = `${CREDS}/cloudflared-token.cred`;
|
|
2599
|
+
var SEED_CREDENTIAL = `${CREDS}/seed-sync-root.cred`;
|
|
2600
|
+
var BACKUP_RECOVERY_CREDENTIAL = `${CREDS}/backup-recovery-root.cred`;
|
|
2601
|
+
var WARP_CLIENT_ID_CREDENTIAL = `${CREDS}/warp-auth-client-id.cred`;
|
|
2602
|
+
var WARP_CLIENT_SECRET_CREDENTIAL = `${CREDS}/warp-auth-client-secret.cred`;
|
|
2603
|
+
var PLATFORM_ENROL_SOURCE = "/run/forgezero-platform-enrol-token";
|
|
2604
|
+
var DB_MODE_EVIDENCE = "/var/lib/forgezero-cluster/server-mode.json";
|
|
2605
|
+
var LIFECYCLE_PROFILE = "/etc/forgezero/lifecycle.json";
|
|
2606
|
+
var PACKAGED_AGENT_BIN = fileURLToPath(new URL("./fz-agent.js", import.meta.url));
|
|
2607
|
+
var privateOrigin = (value) => {
|
|
2608
|
+
let url;
|
|
2609
|
+
try {
|
|
2610
|
+
url = new URL(value);
|
|
2611
|
+
} catch {
|
|
2612
|
+
throw new Error(`database coordinator is malformed: ${value}`);
|
|
2613
|
+
}
|
|
2614
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
|
2615
|
+
throw new Error(`database coordinators must be credential-free HTTP(S) origins: ${value}`);
|
|
2616
|
+
}
|
|
2617
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
2618
|
+
const v4 = host.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/)?.slice(1).map(Number);
|
|
2619
|
+
const privateV4 = v4 && v4.every((part) => part >= 0 && part <= 255) && (v4[0] === 10 || v4[0] === 172 && v4[1] >= 16 && v4[1] <= 31 || v4[0] === 192 && v4[1] === 168);
|
|
2620
|
+
const privateV6 = host === "::1" || /^f[cd][0-9a-f]:/i.test(host);
|
|
2621
|
+
if (!privateV4 && !privateV6 && host !== "127.0.0.1" && host !== "localhost") {
|
|
2622
|
+
throw new Error(`database coordinator must use a private address: ${value}`);
|
|
2623
|
+
}
|
|
2624
|
+
return url.origin;
|
|
2625
|
+
};
|
|
2626
|
+
var privateCidr = (value) => {
|
|
2627
|
+
const match = value.match(/^(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\/(\d|[12]\d|3[0-2])$/);
|
|
2628
|
+
if (!match || match[1].split(".").some((part) => Number(part) > 255))
|
|
2629
|
+
throw new Error(`private firewall CIDR is malformed: ${value}`);
|
|
2630
|
+
return value;
|
|
2631
|
+
};
|
|
2632
|
+
var privateFile = (host, path, label) => {
|
|
2633
|
+
if (!host.exists(path))
|
|
2634
|
+
throw new Error(`${label} file is missing: ${path}`);
|
|
2635
|
+
const metadata = host.inspect?.(path);
|
|
2636
|
+
if (metadata && (!metadata.regular || metadata.symbolic || metadata.uid !== 0 || metadata.links !== 1 || (metadata.mode & 63) !== 0 || metadata.size > 16 * 1024)) {
|
|
2637
|
+
throw new Error(`${label} must be a root-owned, owner-only regular file with one link and at most 16 KiB`);
|
|
2638
|
+
}
|
|
2639
|
+
const value = host.read(path).trim();
|
|
2640
|
+
if (!value)
|
|
2641
|
+
throw new Error(`${label} file is empty: ${path}`);
|
|
2642
|
+
return value;
|
|
2643
|
+
};
|
|
2644
|
+
function validateBootstrapConfig(value) {
|
|
2645
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?){2,}$/.test(value.nodeHostname)) {
|
|
2646
|
+
throw new Error("node hostname must be a lowercase public FQDN");
|
|
2647
|
+
}
|
|
2648
|
+
let telemetry;
|
|
2649
|
+
try {
|
|
2650
|
+
telemetry = new URL(value.telemetryEndpoint);
|
|
2651
|
+
} catch {
|
|
2652
|
+
throw new Error("telemetry endpoint is malformed");
|
|
2653
|
+
}
|
|
2654
|
+
if (telemetry.protocol !== "https:" || telemetry.port && telemetry.port !== "443" || telemetry.username || telemetry.password || telemetry.search || telemetry.hash) {
|
|
2655
|
+
throw new Error("telemetry endpoint must be public HTTPS on port 443 without credentials, query or fragment");
|
|
2656
|
+
}
|
|
2657
|
+
if (value.kind === "tenant") {
|
|
2658
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(value.realm))
|
|
2659
|
+
throw new Error("tenant realm is malformed");
|
|
2660
|
+
if (!/^https:\/\//.test(value.apiUrl) && !/^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/.test(value.apiUrl)) {
|
|
2661
|
+
throw new Error("tenant API must be public HTTPS or loopback HTTP");
|
|
2662
|
+
}
|
|
2663
|
+
if (!value.enrolTokenFile)
|
|
2664
|
+
throw new Error("tenant bootstrap requires --enrol-token-file");
|
|
2665
|
+
return value;
|
|
2666
|
+
}
|
|
2667
|
+
if (!PLATFORM_BOOTSTRAP_PROFILES.includes(value.profile))
|
|
2668
|
+
throw new Error("unsupported platform software profile");
|
|
2669
|
+
if (!["production", "development"].includes(value.environment))
|
|
2670
|
+
throw new Error("platform environment must be production or development");
|
|
2671
|
+
let api;
|
|
2672
|
+
try {
|
|
2673
|
+
api = new URL(value.apiUrl);
|
|
2674
|
+
} catch {
|
|
2675
|
+
throw new Error("platform API URL is malformed");
|
|
2676
|
+
}
|
|
2677
|
+
if (api.protocol !== "https:" || api.username || api.password || api.search || api.hash) {
|
|
2678
|
+
throw new Error("platform API must be public HTTPS without credentials, query or fragment");
|
|
2679
|
+
}
|
|
2680
|
+
const expected = value.profile === "platform-api" ? { role: "none", mode: "default" } : { role: undefined, mode: "default" };
|
|
2681
|
+
if (expected.role && value.database.role !== expected.role || value.database.serverMode !== expected.mode || value.profile === "platform-db-api" && !["master", "joiner"].includes(value.database.role)) {
|
|
2682
|
+
throw new Error("platform profile, database role and Coordinator mode disagree");
|
|
2683
|
+
}
|
|
2684
|
+
if (value.database.role !== "none" && !value.database.address)
|
|
2685
|
+
throw new Error("database nodes require a private address");
|
|
2686
|
+
if (value.database.role === "joiner" && !value.database.master)
|
|
2687
|
+
throw new Error("database joiners require the master starter address");
|
|
2688
|
+
if (!/^(?:dev-)?fz-n[1-9][0-9]{0,2}$/.test(value.computeReference))
|
|
2689
|
+
throw new Error("platform compute reference is malformed");
|
|
2690
|
+
if (!value.database.bootstrapSecretFile)
|
|
2691
|
+
throw new Error("platform bootstrap requires the shared cluster bootstrap-code file");
|
|
2692
|
+
const write = value.database.coordinators.map(privateOrigin);
|
|
2693
|
+
if (write.length < 1 || write.length > 16 || new Set(write).size !== write.length) {
|
|
2694
|
+
throw new Error("database coordinators must contain 1-16 unique private origins");
|
|
2695
|
+
}
|
|
2696
|
+
value.database.coordinators = write;
|
|
2697
|
+
const runtime = validatePlatformSharedEnvironment(value.runtime.environment, {
|
|
2698
|
+
allowPendingCloudflareHandoff: Boolean(value.cloudflareHandoff)
|
|
2699
|
+
});
|
|
2700
|
+
if (runtime.softwareProfile !== value.profile || runtime.databaseRole !== value.database.role || runtime.nodeHostname !== value.nodeHostname || runtime.apiOrigin !== api.origin || runtime.repository !== value.repository || runtime.branch !== value.branch || runtime.databaseCoordinators.join(",") !== write.join(",")) {
|
|
2701
|
+
throw new Error("platform runtime coordinates disagree with immutable bootstrap coordinates");
|
|
2702
|
+
}
|
|
2703
|
+
if (runtime.deployProfile !== value.environment)
|
|
2704
|
+
throw new Error("runtime deployment profile disagrees with bootstrap environment");
|
|
2705
|
+
if (value.database.address !== runtime.databaseAddress || value.database.master !== runtime.databaseMaster) {
|
|
2706
|
+
throw new Error("runtime database topology disagrees with bootstrap topology");
|
|
2707
|
+
}
|
|
2708
|
+
if (!Number.isSafeInteger(value.firewall.sshPort) || value.firewall.sshPort < 1 || value.firewall.sshPort > 65535) {
|
|
2709
|
+
throw new Error("firewall SSH port is invalid");
|
|
2710
|
+
}
|
|
2711
|
+
value.firewall.privateCidrs = value.firewall.privateCidrs.map(privateCidr);
|
|
2712
|
+
if (value.firewall.enabled && (value.firewall.privateCidrs.length < 1 || new Set(value.firewall.privateCidrs).size !== value.firewall.privateCidrs.length)) {
|
|
2713
|
+
throw new Error("enabled firewall requires unique private cluster CIDRs");
|
|
2714
|
+
}
|
|
2715
|
+
if (Number(value.computeReference.match(/n(\d+)$/)?.[1]) > 3 && !value.platformEnrolTokenFile) {
|
|
2716
|
+
throw new Error("post-genesis platform computes require an API-issued enrolment token file");
|
|
2717
|
+
}
|
|
2718
|
+
if (value.cloudflareHandoff && (!value.installCloudflared || !value.cloudflareHandoff.checkpointFile || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(value.cloudflareHandoff.nodeName))) {
|
|
2719
|
+
throw new Error("Cloudflare handoff requires cloudflared installation, a checkpoint and a valid node name");
|
|
2720
|
+
}
|
|
2721
|
+
value.runtime.environment = runtime;
|
|
2722
|
+
return value;
|
|
2723
|
+
}
|
|
2724
|
+
function planBootstrap(input, initialized = false) {
|
|
2725
|
+
const config = validateBootstrapConfig(structuredClone(input));
|
|
2726
|
+
const software = config.kind === "tenant" ? [...config.software ?? [], ...config.installCloudflared ? [{ id: "cloudflared", version: "2026.7.3" }] : []] : [
|
|
2727
|
+
...config.firewall.enabled ? [{ id: "ufw", version: "ubuntu-26.04" }] : [],
|
|
2728
|
+
{ id: "bun", version: "1.3.14" },
|
|
2729
|
+
{ id: "nginx", version: "ubuntu-26.04" },
|
|
2730
|
+
...config.profile === "platform-api" ? [] : [{ id: "arangodb", version: "3.11.14" }],
|
|
2731
|
+
...config.installCloudflared ? [{ id: "cloudflared", version: "2026.7.3" }] : []
|
|
2732
|
+
];
|
|
2733
|
+
return {
|
|
2734
|
+
kind: config.kind,
|
|
2735
|
+
mode: initialized ? "repair" : "install",
|
|
2736
|
+
profile: config.kind === "platform" ? config.profile : config.profile ?? "tenant-managed",
|
|
2737
|
+
software,
|
|
2738
|
+
statePath: STATE_PATH,
|
|
2739
|
+
steps: [
|
|
2740
|
+
{ id: "agent", label: "Install and verify the common Agent supervision boundary", mutation: true },
|
|
2741
|
+
{ id: "software", label: "Install exact Agent-owned software coordinates in order", mutation: true },
|
|
2742
|
+
{ id: "credentials", label: "Seal bootstrap credentials with systemd-creds", mutation: true },
|
|
2743
|
+
...config.kind === "platform" && config.database.role !== "none" ? [{ id: "database", label: "Provision Community 3.11.14 cluster and Coordinator mode", mutation: true }] : [],
|
|
2744
|
+
...config.kind === "platform" ? [
|
|
2745
|
+
{ id: "runtime", label: "Install the shared environment, API slots, edge and activation boundary", mutation: true },
|
|
2746
|
+
{ id: "deploy", label: "Create the first invite when required and health-gate the initial deployment", mutation: true },
|
|
2747
|
+
{ id: "enrol", label: "Consume the API-bound platform capability and enable signed control", mutation: true }
|
|
2748
|
+
] : [],
|
|
2749
|
+
...config.installCloudflared ? [{ id: "cloudflared-install-only", label: "Install cloudflared without creating Cloudflare resources", mutation: true }] : [],
|
|
2750
|
+
{ id: "state", label: "Persist immutable bootstrap profile and evidence coordinates", mutation: true },
|
|
2751
|
+
{ id: "status", label: "Verify services and immutable profile", mutation: false }
|
|
2752
|
+
]
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
var checked = async (host, argv, label, options) => {
|
|
2756
|
+
const result = await host.exec(argv, options);
|
|
2757
|
+
if (result.exitCode !== 0)
|
|
2758
|
+
throw new Error(`${label} failed: ${result.output.trim()}`);
|
|
2759
|
+
return result.output;
|
|
2760
|
+
};
|
|
2761
|
+
var shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
2762
|
+
var unitEscape = (value) => {
|
|
2763
|
+
if (/[^A-Za-z0-9_./:@,+-]/.test(value))
|
|
2764
|
+
throw new Error(`unsafe systemd coordinate: ${value}`);
|
|
2765
|
+
return value;
|
|
2766
|
+
};
|
|
2767
|
+
function databaseUnit(config) {
|
|
2768
|
+
const db = config.database;
|
|
2769
|
+
const join2 = db.role === "joiner" ? ` --starter.join=${unitEscape(db.master)}` : "";
|
|
2770
|
+
return `[Unit]
|
|
2771
|
+
Description=ForgeZero ArangoDB Community 3.11.14 cluster (${db.role})
|
|
2772
|
+
After=network-online.target
|
|
2773
|
+
Wants=network-online.target
|
|
2774
|
+
|
|
2775
|
+
[Service]
|
|
2776
|
+
Type=simple
|
|
2777
|
+
User=arangodb
|
|
2778
|
+
Group=arangodb
|
|
2779
|
+
LoadCredentialEncrypted=arangodb-jwt:${JWT_CREDENTIAL}
|
|
2780
|
+
ExecStart=/usr/bin/arangodb --starter.mode=cluster --starter.address=${unitEscape(db.address)} --starter.host=${unitEscape(db.address)} --starter.data-dir=/var/lib/forgezero-cluster --auth.jwt-secret=%d/arangodb-jwt${join2}
|
|
2781
|
+
Restart=always
|
|
2782
|
+
RestartSec=5
|
|
2783
|
+
UMask=0077
|
|
2784
|
+
NoNewPrivileges=true
|
|
2785
|
+
PrivateTmp=true
|
|
2786
|
+
PrivateDevices=true
|
|
2787
|
+
ProtectSystem=strict
|
|
2788
|
+
ProtectHome=true
|
|
2789
|
+
ReadWritePaths=/var/lib/forgezero-cluster
|
|
2790
|
+
ProtectKernelTunables=true
|
|
2791
|
+
ProtectKernelModules=true
|
|
2792
|
+
ProtectControlGroups=true
|
|
2793
|
+
RestrictSUIDSGID=true
|
|
2794
|
+
LockPersonality=true
|
|
2795
|
+
|
|
2796
|
+
[Install]
|
|
2797
|
+
WantedBy=multi-user.target
|
|
2798
|
+
`;
|
|
2799
|
+
}
|
|
2800
|
+
function databaseVerifyUnit(config) {
|
|
2801
|
+
const { address } = config.database;
|
|
2802
|
+
return `[Unit]
|
|
2803
|
+
Description=Verify ForgeZero ArangoDB Community 3.11.14 writable Coordinator
|
|
2804
|
+
Requires=forgezero-db.service
|
|
2805
|
+
After=forgezero-db.service
|
|
2806
|
+
PartOf=forgezero-db.service
|
|
2807
|
+
|
|
2808
|
+
[Service]
|
|
2809
|
+
Type=oneshot
|
|
2810
|
+
User=arangodb
|
|
2811
|
+
Group=arangodb
|
|
2812
|
+
LoadCredentialEncrypted=arangodb-jwt:${JWT_CREDENTIAL}
|
|
2813
|
+
ExecStart=/usr/bin/arangosh --server.endpoint tcp://${unitEscape(address)}:8529 --server.jwt-secret-keyfile %d/arangodb-jwt --javascript.execute-string 'const c=require("@arangodb").db._connection;let last;for(let i=0;i<90;i++){try{const v=c.GET("/_api/version?details=true");const s=c.GET("/_admin/status");const m=c.GET("/_admin/server/mode");if(!v.error&&v.version==="3.11.14"&&v.details&&v.details.license==="community"&&!s.error&&s.serverInfo&&s.serverInfo.role==="COORDINATOR"&&!m.error&&m.mode==="default")quit(0);last={v,s,m};}catch(e){last=String(e);}require("internal").wait(2);}throw new Error("writable Community Coordinator verification failed: "+JSON.stringify(last));'
|
|
2814
|
+
RemainAfterExit=yes
|
|
2815
|
+
TimeoutStartSec=200
|
|
2816
|
+
NoNewPrivileges=true
|
|
2817
|
+
PrivateTmp=true
|
|
2818
|
+
PrivateDevices=true
|
|
2819
|
+
ProtectSystem=strict
|
|
2820
|
+
ProtectHome=true
|
|
2821
|
+
|
|
2822
|
+
[Install]
|
|
2823
|
+
WantedBy=multi-user.target
|
|
2824
|
+
`;
|
|
2825
|
+
}
|
|
2826
|
+
function tunnelUnit() {
|
|
2827
|
+
return `[Unit]
|
|
2828
|
+
Description=ForgeZero Cloudflare Tunnel connector
|
|
2829
|
+
After=network-online.target
|
|
2830
|
+
Wants=network-online.target
|
|
2831
|
+
|
|
2832
|
+
[Service]
|
|
2833
|
+
Type=simple
|
|
2834
|
+
DynamicUser=yes
|
|
2835
|
+
LoadCredentialEncrypted=cloudflared-token:${TUNNEL_CREDENTIAL}
|
|
2836
|
+
ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate run --token-file %d/cloudflared-token
|
|
2837
|
+
Restart=always
|
|
2838
|
+
RestartSec=5
|
|
2839
|
+
NoNewPrivileges=true
|
|
2840
|
+
PrivateTmp=true
|
|
2841
|
+
ProtectSystem=strict
|
|
2842
|
+
ProtectHome=true
|
|
2843
|
+
|
|
2844
|
+
[Install]
|
|
2845
|
+
WantedBy=multi-user.target
|
|
2846
|
+
`;
|
|
2847
|
+
}
|
|
2848
|
+
var derive = (root, label) => {
|
|
2849
|
+
if (!/^[a-f0-9]{64}$/i.test(root))
|
|
2850
|
+
throw new Error("cluster bootstrap code must contain exactly 64 hexadecimal characters");
|
|
2851
|
+
return createHmac("sha256", Buffer.from(root, "hex")).update(label).digest("hex");
|
|
2852
|
+
};
|
|
2853
|
+
async function seal(host, name, destination, value) {
|
|
2854
|
+
if (host.exists(destination))
|
|
2855
|
+
return;
|
|
2856
|
+
const result = await host.exec(["systemd-creds", "encrypt", `--name=${name}`, "-", destination], { stdin: value });
|
|
2857
|
+
if (result.exitCode !== 0)
|
|
2858
|
+
throw new Error(`could not seal ${name}: ${result.output.trim()}`);
|
|
2859
|
+
}
|
|
2860
|
+
function stateFor(config) {
|
|
2861
|
+
return `${JSON.stringify({
|
|
2862
|
+
format: 1,
|
|
2863
|
+
kind: config.kind,
|
|
2864
|
+
profile: config.kind === "platform" ? config.profile : config.profile ?? "tenant-managed",
|
|
2865
|
+
nodeHostname: config.nodeHostname,
|
|
2866
|
+
apiUrl: config.apiUrl,
|
|
2867
|
+
...config.kind === "platform" ? {
|
|
2868
|
+
environment: config.environment,
|
|
2869
|
+
databaseRole: config.database.role,
|
|
2870
|
+
databaseServerMode: config.database.serverMode,
|
|
2871
|
+
databaseAddress: config.database.address,
|
|
2872
|
+
databaseCoordinators: config.database.coordinators,
|
|
2873
|
+
databaseModeEvidence: config.database.role === "none" ? undefined : DB_MODE_EVIDENCE,
|
|
2874
|
+
collectorUnit: config.runtime.environment.otlpCollectorUnit,
|
|
2875
|
+
cloudflared: Boolean(config.cloudflareHandoff)
|
|
2876
|
+
} : { realm: config.realm }
|
|
2877
|
+
}, null, 2)}
|
|
2878
|
+
`;
|
|
2879
|
+
}
|
|
2880
|
+
async function bootstrapStatus(host = localBootstrapHost()) {
|
|
2881
|
+
if (!host.exists(STATE_PATH))
|
|
2882
|
+
return { initialized: false, services: {}, problems: ["bootstrap state is missing"] };
|
|
2883
|
+
let state;
|
|
2884
|
+
try {
|
|
2885
|
+
state = JSON.parse(host.read(STATE_PATH));
|
|
2886
|
+
} catch {
|
|
2887
|
+
return { initialized: false, services: {}, problems: ["bootstrap state is malformed"] };
|
|
2888
|
+
}
|
|
2889
|
+
const units = ["forgezero-agent.service", "forgezero-agent.socket"];
|
|
2890
|
+
if (state.kind === "platform")
|
|
2891
|
+
units.push("nginx.service");
|
|
2892
|
+
if (state.kind === "platform" && state.collectorUnit)
|
|
2893
|
+
units.push(state.collectorUnit);
|
|
2894
|
+
if (state.kind === "platform" && state.cloudflared)
|
|
2895
|
+
units.push("cloudflared.service");
|
|
2896
|
+
if (state.kind === "platform" && state.databaseRole !== "none")
|
|
2897
|
+
units.push("forgezero-db.service", "forgezero-db-verify.service");
|
|
2898
|
+
const services = {};
|
|
2899
|
+
const problems = [];
|
|
2900
|
+
for (const unit of units) {
|
|
2901
|
+
const result = await host.exec(["systemctl", "is-active", "--quiet", unit]);
|
|
2902
|
+
services[unit] = result.exitCode === 0;
|
|
2903
|
+
if (result.exitCode !== 0)
|
|
2904
|
+
problems.push(`${unit} is not active`);
|
|
2905
|
+
}
|
|
2906
|
+
if (state.kind === "platform") {
|
|
2907
|
+
const [blue, green] = await Promise.all([
|
|
2908
|
+
host.exec(["systemctl", "is-active", "--quiet", "forgezero@blue.service"]),
|
|
2909
|
+
host.exec(["systemctl", "is-active", "--quiet", "forgezero@green.service"])
|
|
2910
|
+
]);
|
|
2911
|
+
services["forgezero@active.service"] = blue.exitCode === 0 || green.exitCode === 0;
|
|
2912
|
+
if (!services["forgezero@active.service"])
|
|
2913
|
+
problems.push("neither API slot is active");
|
|
2914
|
+
}
|
|
2915
|
+
if (!host.exists("/var/lib/forgezero/enrolment.json"))
|
|
2916
|
+
problems.push("durable Agent enrolment state is missing");
|
|
2917
|
+
return { initialized: problems.length === 0, kind: state.kind, profile: state.profile, services, problems };
|
|
2918
|
+
}
|
|
2919
|
+
async function applyBootstrap(input, host = localBootstrapHost()) {
|
|
2920
|
+
const config = validateBootstrapConfig(structuredClone(input));
|
|
2921
|
+
if (host.uid() !== 0)
|
|
2922
|
+
throw new Error("fz bootstrap --apply must run as root");
|
|
2923
|
+
if (config.kind === "tenant") {
|
|
2924
|
+
const token = privateFile(host, config.enrolTokenFile, "tenant enrolment token");
|
|
2925
|
+
if (!/^fze_[A-Za-z0-9_-]{32,128}$/.test(token))
|
|
2926
|
+
throw new Error("tenant enrolment token is malformed");
|
|
2927
|
+
}
|
|
2928
|
+
let cloudflare;
|
|
2929
|
+
if (config.kind === "platform" && config.cloudflareHandoff) {
|
|
2930
|
+
cloudflare = await readCloudflareHostHandoff(config.cloudflareHandoff.checkpointFile, config.cloudflareHandoff.nodeName);
|
|
2931
|
+
const expected = config.runtime.environment.cloudflare;
|
|
2932
|
+
if (cloudflare.hostname !== config.nodeHostname)
|
|
2933
|
+
throw new Error("Cloudflare checkpoint hostname disagrees with platform node hostname");
|
|
2934
|
+
if (expected && (cloudflare.service !== expected.tunnelService || cloudflare.accountId !== expected.accountId || cloudflare.zoneId !== expected.zoneId || cloudflare.kvNamespaceId !== expected.kvNamespaceId || cloudflare.tunnelId !== expected.tunnelId)) {
|
|
2935
|
+
throw new Error("Cloudflare checkpoint disagrees with immutable platform runtime coordinates");
|
|
2936
|
+
}
|
|
2937
|
+
const discovered = {
|
|
2938
|
+
accountId: cloudflare.accountId,
|
|
2939
|
+
zoneId: cloudflare.zoneId,
|
|
2940
|
+
kvNamespaceId: cloudflare.kvNamespaceId,
|
|
2941
|
+
tunnelId: cloudflare.tunnelId,
|
|
2942
|
+
tunnelService: cloudflare.service,
|
|
2943
|
+
...cloudflare.warp ? { warp: {
|
|
2944
|
+
organization: cloudflare.warp.organization,
|
|
2945
|
+
virtualNetworkId: cloudflare.warp.virtualNetworkId,
|
|
2946
|
+
deviceProfileId: cloudflare.warp.deviceProfileId
|
|
2947
|
+
} } : {}
|
|
2948
|
+
};
|
|
2949
|
+
if (expected && JSON.stringify(expected) !== JSON.stringify(discovered)) {
|
|
2950
|
+
throw new Error("Cloudflare checkpoint disagrees with immutable WARP/runtime coordinates");
|
|
2951
|
+
}
|
|
2952
|
+
config.runtime.environment.cloudflare = expected ?? discovered;
|
|
2953
|
+
if (config.runtime.environment.databaseNetworkMode === "cloudflare-warp" !== Boolean(cloudflare.warp)) {
|
|
2954
|
+
throw new Error("Cloudflare checkpoint private-network mode disagrees with the platform database network mode");
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
const plan = planBootstrap(config, host.exists(STATE_PATH));
|
|
2958
|
+
const alreadyEnrolled = host.exists("/var/lib/forgezero/enrolment.json");
|
|
2959
|
+
host.mkdir(CREDS, 448);
|
|
2960
|
+
host.mkdir("/var/lib/forgezero", 448);
|
|
2961
|
+
if (cloudflare?.warp) {
|
|
2962
|
+
await seal(host, "warp-auth-client-id", WARP_CLIENT_ID_CREDENTIAL, cloudflare.warp.clientId);
|
|
2963
|
+
await seal(host, "warp-auth-client-secret", WARP_CLIENT_SECRET_CREDENTIAL, cloudflare.warp.clientSecret);
|
|
2964
|
+
}
|
|
2965
|
+
await host.installAgent(config, alreadyEnrolled && config.kind === "platform" ? PLATFORM_ENROL_SOURCE : undefined);
|
|
2966
|
+
if (config.kind === "platform" && config.firewall.enabled) {
|
|
2967
|
+
await host.ensureSoftware(plan.software.filter(({ id }) => id === "ufw"));
|
|
2968
|
+
} else
|
|
2969
|
+
await host.ensureSoftware(plan.software);
|
|
2970
|
+
if (config.kind === "platform" && config.firewall.enabled) {
|
|
2971
|
+
await checked(host, ["ufw", "--force", "default", "deny", "incoming"], "firewall inbound policy");
|
|
2972
|
+
await checked(host, ["ufw", "--force", "default", "allow", "outgoing"], "firewall outbound policy");
|
|
2973
|
+
await checked(host, ["ufw", "allow", `${config.firewall.sshPort}/tcp`], "firewall SSH rule");
|
|
2974
|
+
for (const cidr of config.firewall.privateCidrs) {
|
|
2975
|
+
if (config.database.role !== "none")
|
|
2976
|
+
await checked(host, ["ufw", "allow", "from", cidr, "to", "any", "port", "8528:8539", "proto", "tcp"], "database firewall rule");
|
|
2977
|
+
for (const port of [config.runtime.bluePort, config.runtime.greenPort])
|
|
2978
|
+
await checked(host, ["ufw", "allow", "from", cidr, "to", "any", "port", String(port), "proto", "tcp"], "seed-mesh firewall rule");
|
|
2979
|
+
}
|
|
2980
|
+
await checked(host, ["ufw", "--force", "enable"], "firewall activation");
|
|
2981
|
+
await host.ensureSoftware(plan.software.filter(({ id }) => id !== "ufw"));
|
|
2982
|
+
}
|
|
2983
|
+
if (config.kind === "platform") {
|
|
2984
|
+
const root = privateFile(host, config.database.bootstrapSecretFile, "database bootstrap secret");
|
|
2985
|
+
if (!host.exists(JWT_CREDENTIAL)) {
|
|
2986
|
+
await seal(host, "arangodb-jwt", JWT_CREDENTIAL, derive(root, "forgezero/cluster/arangodb-jwt/v1"));
|
|
2987
|
+
}
|
|
2988
|
+
await seal(host, "seed-sync-root", SEED_CREDENTIAL, derive(root, "forgezero/cluster/seed-mesh/v1"));
|
|
2989
|
+
await seal(host, "backup-recovery-root", BACKUP_RECOVERY_CREDENTIAL, derive(root, "forgezero/backup/recovery-root/v1"));
|
|
2990
|
+
const credentialFiles = config.runtime.credentialFiles ?? {};
|
|
2991
|
+
for (const [name, source] of Object.entries({
|
|
2992
|
+
"bootstrap-smtp-password": credentialFiles.smtpPassword,
|
|
2993
|
+
"backup-s3-secret": credentialFiles.backupS3Secret
|
|
2994
|
+
})) {
|
|
2995
|
+
if (source) {
|
|
2996
|
+
const destination = `${CREDS}/${name}.cred`;
|
|
2997
|
+
if (!host.exists(destination)) {
|
|
2998
|
+
await seal(host, name, destination, privateFile(host, source, name));
|
|
2999
|
+
host.remove(source);
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
if (cloudflare) {
|
|
3004
|
+
await seal(host, "cloudflare-kv-token", `${CREDS}/cloudflare-kv-token.cred`, cloudflare.kvRuntimeToken);
|
|
3005
|
+
if (cloudflare.privateNetworkRuntimeToken)
|
|
3006
|
+
await seal(host, "cloudflare-network-token", `${CREDS}/cloudflare-network-token.cred`, cloudflare.privateNetworkRuntimeToken);
|
|
3007
|
+
}
|
|
3008
|
+
const runtime = config.runtime;
|
|
3009
|
+
const envPath = `${runtime.environment.sharedDirectory}/.env`;
|
|
3010
|
+
const credentials = platformApiCredentialSpecs({
|
|
3011
|
+
smtp: Boolean(credentialFiles.smtpPassword),
|
|
3012
|
+
cloudflareKv: Boolean(cloudflare),
|
|
3013
|
+
cloudflareNetwork: Boolean(cloudflare?.privateNetworkRuntimeToken)
|
|
3014
|
+
});
|
|
3015
|
+
const units = renderPlatformApiUnits({
|
|
3016
|
+
serviceUser: runtime.serviceUser,
|
|
3017
|
+
sharedDirectory: runtime.environment.sharedDirectory,
|
|
3018
|
+
sharedEnvironmentFile: envPath,
|
|
3019
|
+
slotsDirectory: runtime.slotsDirectory,
|
|
3020
|
+
bluePort: runtime.bluePort,
|
|
3021
|
+
greenPort: runtime.greenPort,
|
|
3022
|
+
collectorUnit: runtime.environment.otlpCollectorUnit,
|
|
3023
|
+
credentials
|
|
3024
|
+
});
|
|
3025
|
+
const edge = renderPlatformNginx({ publicPort: runtime.environment.publicApiPort, initialSlotPort: runtime.bluePort });
|
|
3026
|
+
const activation = renderPlatformActivationFiles({
|
|
3027
|
+
root: config.deployRoot ?? "/opt/forgezero",
|
|
3028
|
+
serviceUser: runtime.serviceUser,
|
|
3029
|
+
bluePort: runtime.bluePort,
|
|
3030
|
+
greenPort: runtime.greenPort,
|
|
3031
|
+
healthPath: runtime.healthPath,
|
|
3032
|
+
keepReleases: runtime.keepReleases
|
|
3033
|
+
});
|
|
3034
|
+
await checked(host, ["useradd", "--system", "--no-create-home", "--shell", "/usr/sbin/nologin", runtime.serviceUser], "API service account").catch(async () => {
|
|
3035
|
+
await checked(host, ["id", runtime.serviceUser], "existing API service account");
|
|
3036
|
+
});
|
|
3037
|
+
host.mkdir(runtime.environment.sharedDirectory, 488);
|
|
3038
|
+
host.mkdir(runtime.slotsDirectory, 493);
|
|
3039
|
+
host.write(envPath, renderPlatformSharedEnvironment(runtime.environment), 416);
|
|
3040
|
+
await checked(host, ["chown", `root:${runtime.serviceUser}`, runtime.environment.sharedDirectory, envPath], "runtime ownership");
|
|
3041
|
+
host.write("/etc/systemd/system/forgezero@.service", units.template, 420);
|
|
3042
|
+
host.write("/etc/systemd/system/forgezero@blue.service.d/port.conf", units.dropIns.blue, 420);
|
|
3043
|
+
host.write("/etc/systemd/system/forgezero@green.service.d/port.conf", units.dropIns.green, 420);
|
|
3044
|
+
host.write("/etc/nginx/conf.d/forgezero-upstream.conf", edge.upstream, 420);
|
|
3045
|
+
host.write("/etc/nginx/conf.d/forgezero.conf", edge.site, 420);
|
|
3046
|
+
host.write("/etc/forgezero/deploy.env", activation.environment, 420);
|
|
3047
|
+
host.write("/usr/local/libexec/forgezero-activate", activation.helper, 493);
|
|
3048
|
+
host.write("/etc/sudoers.d/forgezero-runner", activation.sudoers, 288);
|
|
3049
|
+
await checked(host, ["visudo", "-cf", "/etc/sudoers.d/forgezero-runner"], "activation sudo policy");
|
|
3050
|
+
const telemetry = planLocalOtlpProof(runtime.environment.otlpEndpoint, runtime.environment.otlpCollectorUnit);
|
|
3051
|
+
await checked(host, telemetry.unitCheck.argv, "OTLP collector supervision");
|
|
3052
|
+
const otlpStatus = (await checked(host, [telemetry.receiverCheck.command, ...telemetry.receiverCheck.argv], "OTLP receiver")).trim();
|
|
3053
|
+
if (!/^2\d\d$/.test(otlpStatus))
|
|
3054
|
+
throw new Error(`OTLP receiver returned HTTP ${otlpStatus || "unknown"}`);
|
|
3055
|
+
await checked(host, ["nginx", "-t"], "nginx configuration");
|
|
3056
|
+
await checked(host, ["systemctl", "daemon-reload"], "systemd reload");
|
|
3057
|
+
await checked(host, ["systemctl", "enable", "--now", "nginx.service"], "nginx supervision");
|
|
3058
|
+
if (config.database.role === "master") {
|
|
3059
|
+
const invite = `${runtime.environment.sharedDirectory}/platform-invite.token`;
|
|
3060
|
+
if (!host.exists(invite)) {
|
|
3061
|
+
host.write(invite, `plt_${randomBytes(24).toString("hex")}
|
|
3062
|
+
`, 384);
|
|
3063
|
+
await checked(host, ["chown", `${runtime.serviceUser}:${runtime.serviceUser}`, invite], "platform invite ownership");
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
if (config.database.role !== "none") {
|
|
3067
|
+
host.mkdir("/var/lib/forgezero-cluster", 448);
|
|
3068
|
+
await checked(host, ["chown", "arangodb:arangodb", "/var/lib/forgezero-cluster"], "database state ownership");
|
|
3069
|
+
host.write("/etc/systemd/system/forgezero-db.service", databaseUnit(config), 420);
|
|
3070
|
+
host.write("/etc/systemd/system/forgezero-db-verify.service", databaseVerifyUnit(config), 420);
|
|
3071
|
+
await checked(host, ["systemctl", "daemon-reload"], "database unit reload");
|
|
3072
|
+
await checked(host, ["systemctl", "enable", "--now", "forgezero-db.service", "forgezero-db-verify.service"], "database supervision");
|
|
3073
|
+
const evidence = {
|
|
3074
|
+
expectedMode: "default",
|
|
3075
|
+
role: "COORDINATOR",
|
|
3076
|
+
unit: "forgezero-db-verify.service",
|
|
3077
|
+
verifiedAt: new Date().toISOString()
|
|
3078
|
+
};
|
|
3079
|
+
host.write(DB_MODE_EVIDENCE, `${JSON.stringify(evidence, null, 2)}
|
|
3080
|
+
`, 384);
|
|
3081
|
+
}
|
|
3082
|
+
if (!alreadyEnrolled) {
|
|
3083
|
+
const enrolToken = config.platformEnrolTokenFile ? privateFile(host, config.platformEnrolTokenFile, "platform enrolment token") : `fze_${derive(derive(root, "forgezero/cluster/arangodb-jwt/v1"), `forgezero/platform-enrolment/v1/${config.computeReference}`)}`;
|
|
3084
|
+
if (!/^fze_[A-Za-z0-9_-]{32,128}$/.test(enrolToken))
|
|
3085
|
+
throw new Error("platform enrolment token is malformed");
|
|
3086
|
+
host.write(PLATFORM_ENROL_SOURCE, `${enrolToken}
|
|
3087
|
+
`, 384);
|
|
3088
|
+
}
|
|
3089
|
+
await checked(host, [
|
|
3090
|
+
"runuser",
|
|
3091
|
+
"-u",
|
|
3092
|
+
"forgezero-agent",
|
|
3093
|
+
"--",
|
|
3094
|
+
"/usr/local/bin/fz-agent",
|
|
3095
|
+
"deploy",
|
|
3096
|
+
...config.database.role === "master" ? ["--release-executor"] : []
|
|
3097
|
+
], "initial Agent deployment");
|
|
3098
|
+
if (!alreadyEnrolled) {
|
|
3099
|
+
await host.installAgent(config, PLATFORM_ENROL_SOURCE);
|
|
3100
|
+
if (config.platformEnrolTokenFile)
|
|
3101
|
+
host.remove(config.platformEnrolTokenFile);
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
if (config.kind === "platform" && cloudflare) {
|
|
3105
|
+
if (!host.exists(TUNNEL_CREDENTIAL)) {
|
|
3106
|
+
await seal(host, "cloudflared-token", TUNNEL_CREDENTIAL, cloudflare.connectorToken);
|
|
3107
|
+
}
|
|
3108
|
+
host.write("/etc/systemd/system/cloudflared.service", tunnelUnit(), 420);
|
|
3109
|
+
await checked(host, ["systemctl", "daemon-reload"], "cloudflared unit reload");
|
|
3110
|
+
await checked(host, ["systemctl", "enable", "--now", "cloudflared.service"], "cloudflared connector supervision");
|
|
3111
|
+
}
|
|
3112
|
+
host.write(STATE_PATH, stateFor(config), 384);
|
|
3113
|
+
const status = await bootstrapStatus(host);
|
|
3114
|
+
if (!status.initialized)
|
|
3115
|
+
throw new Error(`bootstrap verification failed: ${status.problems.join("; ")}`);
|
|
3116
|
+
let launch;
|
|
3117
|
+
if (config.kind === "platform" && config.database.role === "master") {
|
|
3118
|
+
const invitePath = `${config.runtime.environment.sharedDirectory}/platform-invite.token`;
|
|
3119
|
+
if (!host.exists(invitePath))
|
|
3120
|
+
throw new Error("platform invite is missing after deployment");
|
|
3121
|
+
const token = host.read(invitePath).trim();
|
|
3122
|
+
if (!/^plt_[a-f0-9]{48}$/.test(token))
|
|
3123
|
+
throw new Error("platform invite is malformed");
|
|
3124
|
+
launch = {
|
|
3125
|
+
command: `sudo -u ${shellQuote(config.runtime.serviceUser)} env FZ_SHARED_DIR=${shellQuote(config.runtime.environment.sharedDirectory)} /usr/local/bin/fz genesis --mode 2-of-3 --api ${shellQuote(config.apiUrl)} --app ${shellQuote(config.runtime.environment.appOrigin)}`,
|
|
3126
|
+
inviteUrl: `${config.runtime.environment.appOrigin}/invite?token=${encodeURIComponent(token)}`
|
|
3127
|
+
};
|
|
3128
|
+
}
|
|
3129
|
+
return { plan, applied: true, status, ...launch ? { launch } : {} };
|
|
3130
|
+
}
|
|
3131
|
+
var exactKeys = (value, allowed, label) => {
|
|
3132
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
3133
|
+
throw new Error(`${label} must be an object`);
|
|
3134
|
+
const record = value;
|
|
3135
|
+
const unknown = Object.keys(record).filter((key) => !allowed.includes(key));
|
|
3136
|
+
if (unknown.length)
|
|
3137
|
+
throw new Error(`${label} contains unknown fields: ${unknown.join(", ")}`);
|
|
3138
|
+
return record;
|
|
3139
|
+
};
|
|
3140
|
+
function strictBootstrapDocument(value) {
|
|
3141
|
+
const root = exactKeys(value, [
|
|
3142
|
+
"kind",
|
|
3143
|
+
"environment",
|
|
3144
|
+
"profile",
|
|
3145
|
+
"computeReference",
|
|
3146
|
+
"nodeHostname",
|
|
3147
|
+
"apiUrl",
|
|
3148
|
+
"repository",
|
|
3149
|
+
"branch",
|
|
3150
|
+
"deployRoot",
|
|
3151
|
+
"telemetryEndpoint",
|
|
3152
|
+
"database",
|
|
3153
|
+
"platformEnrolTokenFile",
|
|
3154
|
+
"runtime",
|
|
3155
|
+
"firewall",
|
|
3156
|
+
"installCloudflared",
|
|
3157
|
+
"cloudflareHandoff",
|
|
3158
|
+
"realm",
|
|
3159
|
+
"enrolTokenFile",
|
|
3160
|
+
"software"
|
|
3161
|
+
], "bootstrap config");
|
|
3162
|
+
if (root.kind === "platform") {
|
|
3163
|
+
exactKeys(root.firewall, ["enabled", "sshPort", "privateCidrs"], "firewall config");
|
|
3164
|
+
if (root.cloudflareHandoff !== undefined)
|
|
3165
|
+
exactKeys(root.cloudflareHandoff, ["checkpointFile", "nodeName"], "Cloudflare handoff");
|
|
3166
|
+
exactKeys(root.database, ["role", "serverMode", "address", "master", "coordinators", "bootstrapSecretFile"], "database config");
|
|
3167
|
+
const runtime = exactKeys(root.runtime, [
|
|
3168
|
+
"environment",
|
|
3169
|
+
"serviceUser",
|
|
3170
|
+
"slotsDirectory",
|
|
3171
|
+
"bluePort",
|
|
3172
|
+
"greenPort",
|
|
3173
|
+
"healthPath",
|
|
3174
|
+
"keepReleases",
|
|
3175
|
+
"credentialFiles"
|
|
3176
|
+
], "runtime config");
|
|
3177
|
+
exactKeys(runtime.environment, [
|
|
3178
|
+
"softwareProfile",
|
|
3179
|
+
"databaseRole",
|
|
3180
|
+
"databaseCoordinators",
|
|
3181
|
+
"databaseAddress",
|
|
3182
|
+
"databaseMaster",
|
|
3183
|
+
"databaseNetworkMode",
|
|
3184
|
+
"databaseReplicationFactor",
|
|
3185
|
+
"databaseWriteConcern",
|
|
3186
|
+
"nodeHostname",
|
|
3187
|
+
"nodeRegion",
|
|
3188
|
+
"databaseUser",
|
|
3189
|
+
"nodeRole",
|
|
3190
|
+
"appOrigin",
|
|
3191
|
+
"apiOrigin",
|
|
3192
|
+
"publicApiPort",
|
|
3193
|
+
"sharedDirectory",
|
|
3194
|
+
"seedSyncPeers",
|
|
3195
|
+
"seedSyncMembers",
|
|
3196
|
+
"seedSyncEpoch",
|
|
3197
|
+
"concurrencyLimit",
|
|
3198
|
+
"drainDeadlineMs",
|
|
3199
|
+
"otlpEndpoint",
|
|
3200
|
+
"otlpCollectorUnit",
|
|
3201
|
+
"agentOtlpEndpoint",
|
|
3202
|
+
"custodianEmail",
|
|
3203
|
+
"smtp",
|
|
3204
|
+
"repository",
|
|
3205
|
+
"branch",
|
|
3206
|
+
"deployProfile",
|
|
3207
|
+
"otlpFlushIntervalMs",
|
|
3208
|
+
"otlpTraceSampleRatio",
|
|
3209
|
+
"backup",
|
|
3210
|
+
"cloudflare"
|
|
3211
|
+
], "runtime environment");
|
|
3212
|
+
if (runtime.credentialFiles !== undefined)
|
|
3213
|
+
exactKeys(runtime.credentialFiles, ["smtpPassword", "backupS3Secret"], "runtime credential files");
|
|
3214
|
+
const environment = runtime.environment;
|
|
3215
|
+
if (environment.smtp !== undefined)
|
|
3216
|
+
exactKeys(environment.smtp, ["host", "port", "user", "from"], "SMTP config");
|
|
3217
|
+
if (environment.backup !== undefined)
|
|
3218
|
+
exactKeys(environment.backup, ["endpoint", "region", "bucket", "accessKeyId"], "backup config");
|
|
3219
|
+
if (environment.cloudflare !== undefined)
|
|
3220
|
+
exactKeys(environment.cloudflare, ["accountId", "zoneId", "kvNamespaceId", "tunnelId", "tunnelService", "warp"], "Cloudflare runtime config");
|
|
3221
|
+
if (environment.cloudflare && typeof environment.cloudflare === "object" && environment.cloudflare.warp !== undefined) {
|
|
3222
|
+
exactKeys(environment.cloudflare.warp, ["organization", "virtualNetworkId", "deviceProfileId"], "Cloudflare WARP runtime config");
|
|
3223
|
+
}
|
|
3224
|
+
} else if (root.kind === "tenant") {
|
|
3225
|
+
for (const key of ["environment", "profile", "computeReference", "database", "platformEnrolTokenFile", "runtime", "cloudflareHandoff"]) {
|
|
3226
|
+
if (root[key] !== undefined && key !== "profile")
|
|
3227
|
+
throw new Error(`tenant bootstrap cannot contain ${key}`);
|
|
3228
|
+
}
|
|
3229
|
+
} else
|
|
3230
|
+
throw new Error("bootstrap config kind must be platform or tenant");
|
|
3231
|
+
return value;
|
|
3232
|
+
}
|
|
3233
|
+
function readBootstrapConfig(path) {
|
|
3234
|
+
const metadata = lstatSync(path);
|
|
3235
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.uid !== (process.getuid?.() ?? metadata.uid) || metadata.nlink !== 1 || (metadata.mode & 63) !== 0 || metadata.size > 64 * 1024) {
|
|
3236
|
+
throw new Error("bootstrap config must be an owner-only regular file with one link and at most 64 KiB");
|
|
3237
|
+
}
|
|
3238
|
+
return validateBootstrapConfig(strictBootstrapDocument(JSON.parse(readFileSync(path, "utf8"))));
|
|
3239
|
+
}
|
|
3240
|
+
function localBootstrapHost() {
|
|
3241
|
+
const execute = async (argv, options = {}) => {
|
|
3242
|
+
const child = Bun.spawn([...argv], { stdin: options.stdin === undefined ? "ignore" : "pipe", stdout: "pipe", stderr: "pipe" });
|
|
3243
|
+
if (options.stdin !== undefined && child.stdin && typeof child.stdin !== "number") {
|
|
3244
|
+
child.stdin.write(options.stdin);
|
|
3245
|
+
child.stdin.end();
|
|
3246
|
+
}
|
|
3247
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
3248
|
+
new Response(child.stdout).text(),
|
|
3249
|
+
new Response(child.stderr).text(),
|
|
3250
|
+
child.exited
|
|
3251
|
+
]);
|
|
3252
|
+
return { exitCode, output: `${stdout}${stderr}` };
|
|
3253
|
+
};
|
|
3254
|
+
return {
|
|
3255
|
+
uid: () => process.getuid?.() ?? -1,
|
|
3256
|
+
exists: existsSync,
|
|
3257
|
+
read: (path) => readFileSync(path, "utf8"),
|
|
3258
|
+
write(path, content, mode) {
|
|
3259
|
+
mkdirSync(dirname2(path), { recursive: true, mode: 493 });
|
|
3260
|
+
const temporary = `${path}.next.${process.pid}`;
|
|
3261
|
+
writeFileSync(temporary, content, { mode });
|
|
3262
|
+
chmodSync(temporary, mode);
|
|
3263
|
+
renameSync(temporary, path);
|
|
3264
|
+
},
|
|
3265
|
+
mkdir: (path, mode) => mkdirSync(path, { recursive: true, mode }),
|
|
3266
|
+
remove: (path) => rmSync(path, { force: true }),
|
|
3267
|
+
inspect(path) {
|
|
3268
|
+
const value = lstatSync(path);
|
|
3269
|
+
return { regular: value.isFile(), symbolic: value.isSymbolicLink(), uid: value.uid, mode: value.mode, links: value.nlink, size: value.size };
|
|
3270
|
+
},
|
|
3271
|
+
exec: execute,
|
|
3272
|
+
async ensureSoftware(requirements) {
|
|
3273
|
+
const result = await execute([
|
|
3274
|
+
"runuser",
|
|
3275
|
+
"-u",
|
|
3276
|
+
"forgezero-agent",
|
|
3277
|
+
"--",
|
|
3278
|
+
"/usr/local/bin/fz-agent",
|
|
3279
|
+
"software-ensure",
|
|
3280
|
+
...requirements.map(({ id, version }) => `--require=${id}@${version}`)
|
|
3281
|
+
]);
|
|
3282
|
+
if (result.exitCode !== 0)
|
|
3283
|
+
throw new Error(`Agent software requirements failed: ${result.output.trim()}`);
|
|
3284
|
+
return result;
|
|
3285
|
+
},
|
|
3286
|
+
async installAgent(config, enrolTokenSourcePath) {
|
|
3287
|
+
const capabilities = await readCapabilities(localRunner);
|
|
3288
|
+
const deployRoot = config.deployRoot ?? "/opt/forgezero";
|
|
3289
|
+
if (config.kind === "platform") {
|
|
3290
|
+
const lifecycle = config.database.role === "none" ? {
|
|
3291
|
+
apiUnits: ["forgezero@blue.service", "forgezero@green.service"],
|
|
3292
|
+
apiHealthUrl: "http://127.0.0.1:3000/api/health"
|
|
3293
|
+
} : {
|
|
3294
|
+
apiUnits: ["forgezero@blue.service", "forgezero@green.service"],
|
|
3295
|
+
databaseUnit: "forgezero-db.service",
|
|
3296
|
+
apiHealthUrl: "http://127.0.0.1:3000/api/health",
|
|
3297
|
+
databaseHealthUrl: `http://${config.database.address}:8529/_api/version`,
|
|
3298
|
+
databasePorts: [8529]
|
|
3299
|
+
};
|
|
3300
|
+
mkdirSync(dirname2(LIFECYCLE_PROFILE), { recursive: true, mode: 493 });
|
|
3301
|
+
writeFileSync(LIFECYCLE_PROFILE, `${JSON.stringify(lifecycle, null, 2)}
|
|
3302
|
+
`, { mode: 256 });
|
|
3303
|
+
}
|
|
3304
|
+
const plan = planInstall({
|
|
3305
|
+
capabilities,
|
|
3306
|
+
socketPath: DEFAULT_SOCKET2,
|
|
3307
|
+
seedPath: "/var/lib/forgezero/node.seed",
|
|
3308
|
+
controlSocketPath: "/run/forgezero/control.sock",
|
|
3309
|
+
repository: config.repository,
|
|
3310
|
+
branch: config.branch,
|
|
3311
|
+
profile: config.kind === "platform" ? config.profile : config.profile,
|
|
3312
|
+
deployRoot,
|
|
3313
|
+
publicApiUrl: config.apiUrl,
|
|
3314
|
+
gitCredentialPath: "/etc/forgezero/creds/git-deploy-key.cred",
|
|
3315
|
+
gitPublicKeyPath: "/etc/forgezero/git/deploy.pub",
|
|
3316
|
+
generateGitIdentity: true,
|
|
3317
|
+
pullDeployments: true,
|
|
3318
|
+
pullMigrations: config.kind === "platform",
|
|
3319
|
+
lifecycleProfilePath: config.kind === "platform" ? LIFECYCLE_PROFILE : undefined,
|
|
3320
|
+
...config.kind === "platform" && config.runtime.environment.cloudflare?.warp ? {
|
|
3321
|
+
warpOrganization: config.runtime.environment.cloudflare.warp.organization,
|
|
3322
|
+
warpClientIdCredentialPath: WARP_CLIENT_ID_CREDENTIAL,
|
|
3323
|
+
warpClientSecretCredentialPath: WARP_CLIENT_SECRET_CREDENTIAL,
|
|
3324
|
+
cloudflareAccountId: config.runtime.environment.cloudflare.accountId,
|
|
3325
|
+
cloudflareTunnelId: config.runtime.environment.cloudflare.tunnelId,
|
|
3326
|
+
cloudflareVirtualNetworkId: config.runtime.environment.cloudflare.warp.virtualNetworkId,
|
|
3327
|
+
cloudflareWarpPolicyId: config.runtime.environment.cloudflare.warp.deviceProfileId
|
|
3328
|
+
} : {},
|
|
3329
|
+
enforceEgress: true,
|
|
3330
|
+
nodeHostname: config.nodeHostname,
|
|
3331
|
+
telemetryEndpoint: config.telemetryEndpoint,
|
|
3332
|
+
binPath: "/usr/local/lib/forgezero/agent/fz-agent",
|
|
3333
|
+
sourceBinPath: PACKAGED_AGENT_BIN,
|
|
3334
|
+
...config.kind === "tenant" || enrolTokenSourcePath ? {
|
|
3335
|
+
enrolTokenSourcePath: config.kind === "tenant" ? config.enrolTokenFile : enrolTokenSourcePath,
|
|
3336
|
+
enrolTokenCredentialPath: ENROL_CREDENTIAL,
|
|
3337
|
+
enrolStatePath: "/var/lib/forgezero/enrolment.json",
|
|
3338
|
+
apiUrl: config.apiUrl,
|
|
3339
|
+
project: config.kind === "tenant" ? config.realm : "platform",
|
|
3340
|
+
environment: config.kind === "tenant" ? undefined : config.environment,
|
|
3341
|
+
nodeLabel: config.kind === "tenant" ? config.nodeHostname : config.computeReference
|
|
3342
|
+
} : {}
|
|
3343
|
+
});
|
|
3344
|
+
for (const unit of [{ path: plan.unitPath, unit: plan.unit }, ...plan.auxiliaryUnits]) {
|
|
3345
|
+
mkdirSync(dirname2(unit.path), { recursive: true, mode: 493 });
|
|
3346
|
+
writeFileSync(unit.path, unit.unit, { mode: 420 });
|
|
3347
|
+
}
|
|
3348
|
+
await applyPlan(plan, localRunner);
|
|
3349
|
+
return plan;
|
|
3350
|
+
}
|
|
3351
|
+
};
|
|
3352
|
+
}
|
|
3353
|
+
export {
|
|
3354
|
+
validateBootstrapConfig,
|
|
3355
|
+
readBootstrapConfig,
|
|
3356
|
+
planBootstrap,
|
|
3357
|
+
localBootstrapHost,
|
|
3358
|
+
bootstrapStatus,
|
|
3359
|
+
applyBootstrap,
|
|
3360
|
+
PLATFORM_BOOTSTRAP_PROFILES
|
|
3361
|
+
};
|