@forgezero/agent 0.1.34 → 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/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/fz-agent.js +1 -1
- package/dist/fz.js +3774 -264
- 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 +2 -2
- package/dist/version.d.ts +1 -1
- package/package.json +22 -2
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/metal-bootstrap.ts
|
|
3
|
+
import { createHash, randomBytes } from "crypto";
|
|
4
|
+
import {
|
|
5
|
+
chmodSync,
|
|
6
|
+
chownSync,
|
|
7
|
+
copyFileSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
lstatSync,
|
|
10
|
+
mkdirSync as mkdirSync2,
|
|
11
|
+
readFileSync,
|
|
12
|
+
realpathSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
statSync,
|
|
15
|
+
symlinkSync,
|
|
16
|
+
unlinkSync,
|
|
17
|
+
writeFileSync as writeFileSync2
|
|
18
|
+
} from "fs";
|
|
19
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join3, resolve } from "path";
|
|
20
|
+
import { isIP as isIP2 } from "net";
|
|
21
|
+
|
|
22
|
+
// src/metal-isolation.ts
|
|
23
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
24
|
+
import { join as join2 } from "path";
|
|
25
|
+
|
|
26
|
+
// src/metal-provision.ts
|
|
27
|
+
import { dirname, isAbsolute, join } from "path";
|
|
28
|
+
import { isIP } from "net";
|
|
29
|
+
|
|
30
|
+
// src/ubuntu.ts
|
|
31
|
+
var SUPPORTED_GUEST_IMAGE = Object.freeze({
|
|
32
|
+
key: "ubuntu-resolute-20260731",
|
|
33
|
+
family: "ubuntu-26.04",
|
|
34
|
+
version: "2026-07-31",
|
|
35
|
+
label: "Ubuntu 26.04 LTS Resolute",
|
|
36
|
+
url: "https://cloud-images.ubuntu.com/releases/resolute/release-20260731/ubuntu-26.04-server-cloudimg-amd64.img",
|
|
37
|
+
sha256: "9dc7c5363c0146a08ba0c9aa834d82c2c6dfbb1c471ad9a2f0aba1189e21be05"
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// src/metal-provision.ts
|
|
41
|
+
var SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
|
|
42
|
+
var DEVICE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,14}$/;
|
|
43
|
+
var IPV4_PREFIX = /^(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)\.(?:25[0-5]|2[0-4]\d|1?\d?\d)$/;
|
|
44
|
+
var LINUX_LIST = /^\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$/;
|
|
45
|
+
|
|
46
|
+
class MetalProvisionError extends Error {
|
|
47
|
+
}
|
|
48
|
+
function membersOfLinuxList(value, label) {
|
|
49
|
+
if (!LINUX_LIST.test(value))
|
|
50
|
+
throw new MetalProvisionError(`invalid ${label} list`);
|
|
51
|
+
const members = [];
|
|
52
|
+
for (const part of value.split(",")) {
|
|
53
|
+
const [startText, endText = startText] = part.split("-");
|
|
54
|
+
const start = Number(startText);
|
|
55
|
+
const end = Number(endText);
|
|
56
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end > 65535) {
|
|
57
|
+
throw new MetalProvisionError(`invalid ${label} list`);
|
|
58
|
+
}
|
|
59
|
+
for (let value2 = start;value2 <= end; value2 += 1)
|
|
60
|
+
members.push(value2);
|
|
61
|
+
}
|
|
62
|
+
if (new Set(members).size !== members.length)
|
|
63
|
+
throw new MetalProvisionError(`${label} list overlaps itself`);
|
|
64
|
+
return members;
|
|
65
|
+
}
|
|
66
|
+
function validateMetalProfile(profile) {
|
|
67
|
+
if (!SAFE_NAME.test(profile.volumeGroup))
|
|
68
|
+
throw new MetalProvisionError("invalid volume group");
|
|
69
|
+
if (!DEVICE.test(profile.bridge))
|
|
70
|
+
throw new MetalProvisionError("invalid bridge");
|
|
71
|
+
if (!IPV4_PREFIX.test(profile.subnetPrefix))
|
|
72
|
+
throw new MetalProvisionError("invalid subnet prefix");
|
|
73
|
+
if (!Number.isInteger(profile.addressStart) || !Number.isInteger(profile.addressEnd) || profile.addressStart < 2 || profile.addressEnd > 254 || profile.addressStart > profile.addressEnd)
|
|
74
|
+
throw new MetalProvisionError("invalid guest address range");
|
|
75
|
+
for (const path of [profile.stateDir, profile.seedDir, profile.unitDir]) {
|
|
76
|
+
if (!isAbsolute(path))
|
|
77
|
+
throw new MetalProvisionError("metal paths must be absolute");
|
|
78
|
+
}
|
|
79
|
+
new URL(profile.apiUrl);
|
|
80
|
+
let telemetryEndpoint;
|
|
81
|
+
try {
|
|
82
|
+
telemetryEndpoint = new URL(profile.agentTelemetryEndpoint);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new MetalProvisionError("Agent telemetry endpoint must be an absolute public HTTPS URL");
|
|
85
|
+
}
|
|
86
|
+
if (telemetryEndpoint.protocol !== "https:" || telemetryEndpoint.username || telemetryEndpoint.password || telemetryEndpoint.search || telemetryEndpoint.hash || isIP(telemetryEndpoint.hostname) !== 0 || !telemetryEndpoint.hostname.includes(".") || telemetryEndpoint.hostname === "localhost" || telemetryEndpoint.hostname.endsWith(".local"))
|
|
87
|
+
throw new MetalProvisionError("Agent telemetry endpoint must be a public HTTPS DNS coordinate without credentials, query or fragment");
|
|
88
|
+
const imageKeys = Object.keys(profile.images);
|
|
89
|
+
if (imageKeys.length !== 1 || imageKeys[0] !== SUPPORTED_GUEST_IMAGE.key || profile.images[SUPPORTED_GUEST_IMAGE.key]?.sha256 !== SUPPORTED_GUEST_IMAGE.sha256) {
|
|
90
|
+
throw new MetalProvisionError(`metal profile must contain only the pinned ${SUPPORTED_GUEST_IMAGE.key} image contract`);
|
|
91
|
+
}
|
|
92
|
+
if (!Array.isArray(profile.cpuPools) || profile.cpuPools.length === 0) {
|
|
93
|
+
throw new MetalProvisionError("at least one exclusive CPU pool is required");
|
|
94
|
+
}
|
|
95
|
+
const keys = new Set;
|
|
96
|
+
const assigned = new Set;
|
|
97
|
+
const assignedMemory = new Set;
|
|
98
|
+
let poolsWithMemory = 0;
|
|
99
|
+
for (const pool of profile.cpuPools) {
|
|
100
|
+
if (!SAFE_NAME.test(pool.key) || keys.has(pool.key))
|
|
101
|
+
throw new MetalProvisionError("invalid or duplicate CPU pool key");
|
|
102
|
+
keys.add(pool.key);
|
|
103
|
+
const cpus = membersOfLinuxList(pool.cpus, "CPU");
|
|
104
|
+
if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
|
|
105
|
+
throw new MetalProvisionError("invalid CPU pool physical-core count");
|
|
106
|
+
}
|
|
107
|
+
for (const cpu of cpus) {
|
|
108
|
+
if (assigned.has(cpu))
|
|
109
|
+
throw new MetalProvisionError("CPU pools overlap");
|
|
110
|
+
assigned.add(cpu);
|
|
111
|
+
}
|
|
112
|
+
if (pool.memoryNodes) {
|
|
113
|
+
poolsWithMemory += 1;
|
|
114
|
+
for (const node of membersOfLinuxList(pool.memoryNodes, "memory-node")) {
|
|
115
|
+
if (assignedMemory.has(node))
|
|
116
|
+
throw new MetalProvisionError("guest memory-node pools overlap");
|
|
117
|
+
assignedMemory.add(node);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (poolsWithMemory !== 0 && poolsWithMemory !== profile.cpuPools.length) {
|
|
122
|
+
throw new MetalProvisionError("every CPU pool must name memory nodes when NUMA isolation is enabled");
|
|
123
|
+
}
|
|
124
|
+
const housekeeping = membersOfLinuxList(profile.housekeepingCpus, "housekeeping CPU");
|
|
125
|
+
if (housekeeping.some((cpu) => assigned.has(cpu))) {
|
|
126
|
+
throw new MetalProvisionError("housekeeping CPUs overlap guest CPU pools");
|
|
127
|
+
}
|
|
128
|
+
if (profile.housekeepingMemoryNodes) {
|
|
129
|
+
const housekeepingMemory = membersOfLinuxList(profile.housekeepingMemoryNodes, "housekeeping memory-node");
|
|
130
|
+
if (housekeepingMemory.some((node) => assignedMemory.has(node))) {
|
|
131
|
+
throw new MetalProvisionError("housekeeping memory nodes overlap guest memory-node pools");
|
|
132
|
+
}
|
|
133
|
+
} else if (assignedMemory.size > 0) {
|
|
134
|
+
throw new MetalProvisionError("NUMA-isolated guest pools require housekeeping memory nodes");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/metal-isolation.ts
|
|
139
|
+
var members = (list) => list.split(",").flatMap((part) => {
|
|
140
|
+
const [first, last = first] = part.split("-").map(Number);
|
|
141
|
+
return Array.from({ length: last - first + 1 }, (_, index) => first + index);
|
|
142
|
+
});
|
|
143
|
+
var compact = (values) => {
|
|
144
|
+
const sorted = [...new Set(values)].sort((left, right) => left - right);
|
|
145
|
+
const ranges = [];
|
|
146
|
+
for (let index = 0;index < sorted.length; ) {
|
|
147
|
+
const first = sorted[index];
|
|
148
|
+
let last = first;
|
|
149
|
+
while (sorted[index + 1] === last + 1)
|
|
150
|
+
last = sorted[++index];
|
|
151
|
+
ranges.push(first === last ? String(first) : `${first}-${last}`);
|
|
152
|
+
index += 1;
|
|
153
|
+
}
|
|
154
|
+
return ranges.join(",");
|
|
155
|
+
};
|
|
156
|
+
var memoryDirective = (nodes) => nodes ? `AllowedMemoryNodes=${nodes}
|
|
157
|
+
` : "";
|
|
158
|
+
function metalGuestSliceUnit(profile) {
|
|
159
|
+
validateMetalProfile(profile);
|
|
160
|
+
const cpus = compact(profile.cpuPools.flatMap((pool) => members(pool.cpus)));
|
|
161
|
+
const nodes = compact(profile.cpuPools.flatMap((pool) => pool.memoryNodes ? members(pool.memoryNodes) : [])) || undefined;
|
|
162
|
+
return `[Unit]
|
|
163
|
+
Description=ForgeZero exclusive guest CPU and memory boundary
|
|
164
|
+
|
|
165
|
+
[Slice]
|
|
166
|
+
AllowedCPUs=${cpus}
|
|
167
|
+
${memoryDirective(nodes)}`;
|
|
168
|
+
}
|
|
169
|
+
function metalHousekeepingDropIn(profile, kind) {
|
|
170
|
+
validateMetalProfile(profile);
|
|
171
|
+
return `[${kind === "slice" ? "Slice" : "Scope"}]
|
|
172
|
+
AllowedCPUs=${profile.housekeepingCpus}
|
|
173
|
+
${memoryDirective(profile.housekeepingMemoryNodes)}`;
|
|
174
|
+
}
|
|
175
|
+
var defaultExec = async (argv) => {
|
|
176
|
+
const child = Bun.spawn([...argv], { stdout: "pipe", stderr: "pipe" });
|
|
177
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
178
|
+
child.exited,
|
|
179
|
+
new Response(child.stdout).text(),
|
|
180
|
+
new Response(child.stderr).text()
|
|
181
|
+
]);
|
|
182
|
+
return { exitCode, stdout, stderr };
|
|
183
|
+
};
|
|
184
|
+
var checked = async (exec, argv) => {
|
|
185
|
+
const result = await exec(argv);
|
|
186
|
+
if (result.exitCode !== 0)
|
|
187
|
+
throw new Error(`${argv[0]} failed: ${(result.stderr || result.stdout).trim()}`);
|
|
188
|
+
return result;
|
|
189
|
+
};
|
|
190
|
+
var requireGuestsInSlice = async (exec) => {
|
|
191
|
+
const active = await checked(exec, [
|
|
192
|
+
"systemctl",
|
|
193
|
+
"list-units",
|
|
194
|
+
"--type=service",
|
|
195
|
+
"--state=running",
|
|
196
|
+
"--plain",
|
|
197
|
+
"--no-legend",
|
|
198
|
+
"forgezero-guest@*.service"
|
|
199
|
+
]);
|
|
200
|
+
for (const line of active.stdout.split(`
|
|
201
|
+
`)) {
|
|
202
|
+
const service = line.trim().split(/\s+/)[0];
|
|
203
|
+
if (!service)
|
|
204
|
+
continue;
|
|
205
|
+
const cgroup = await checked(exec, ["systemctl", "show", "-p", "ControlGroup", "--value", service]);
|
|
206
|
+
if (!cgroup.stdout.trim().includes("/forgezero-guests.slice/")) {
|
|
207
|
+
throw new Error(`${service} must be drained and restarted into forgezero-guests.slice`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
async function applyMetalIsolation(profile, exec = defaultExec) {
|
|
212
|
+
validateMetalProfile(profile);
|
|
213
|
+
await requireGuestsInSlice(exec);
|
|
214
|
+
const unitDir = profile.unitDir;
|
|
215
|
+
mkdirSync(unitDir, { recursive: true });
|
|
216
|
+
writeFileSync(join2(unitDir, "forgezero-guests.slice"), metalGuestSliceUnit(profile), { mode: 420 });
|
|
217
|
+
for (const unit of ["system.slice", "user.slice"]) {
|
|
218
|
+
const directory = join2(unitDir, `${unit}.d`);
|
|
219
|
+
mkdirSync(directory, { recursive: true });
|
|
220
|
+
writeFileSync(join2(directory, "50-forgezero-housekeeping.conf"), metalHousekeepingDropIn(profile, "slice"), { mode: 420 });
|
|
221
|
+
}
|
|
222
|
+
const initDirectory = join2(unitDir, "init.scope.d");
|
|
223
|
+
mkdirSync(initDirectory, { recursive: true });
|
|
224
|
+
writeFileSync(join2(initDirectory, "50-forgezero-housekeeping.conf"), metalHousekeepingDropIn(profile, "scope"), { mode: 420 });
|
|
225
|
+
await checked(exec, ["systemctl", "daemon-reload"]);
|
|
226
|
+
await requireGuestsInSlice(exec);
|
|
227
|
+
const properties = [`AllowedCPUs=${profile.housekeepingCpus}`];
|
|
228
|
+
if (profile.housekeepingMemoryNodes)
|
|
229
|
+
properties.push(`AllowedMemoryNodes=${profile.housekeepingMemoryNodes}`);
|
|
230
|
+
for (const unit of ["system.slice", "user.slice", "init.scope"]) {
|
|
231
|
+
await checked(exec, ["systemctl", "set-property", "--runtime", unit, ...properties]);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/egress-policy.ts
|
|
236
|
+
var AGENT_EGRESS_TABLE = "forgezero_agent_egress";
|
|
237
|
+
var SYSTEMD_RESOLVED_ADDRESS = "127.0.0.53";
|
|
238
|
+
var BLOCKED_IPV4 = [
|
|
239
|
+
"0.0.0.0/8",
|
|
240
|
+
"10.0.0.0/8",
|
|
241
|
+
"100.64.0.0/10",
|
|
242
|
+
"127.0.0.0/8",
|
|
243
|
+
"168.63.129.16/32",
|
|
244
|
+
"169.254.0.0/16",
|
|
245
|
+
"172.16.0.0/12",
|
|
246
|
+
"192.0.0.0/24",
|
|
247
|
+
"192.0.2.0/24",
|
|
248
|
+
"192.88.99.0/24",
|
|
249
|
+
"192.168.0.0/16",
|
|
250
|
+
"198.18.0.0/15",
|
|
251
|
+
"198.51.100.0/24",
|
|
252
|
+
"203.0.113.0/24",
|
|
253
|
+
"224.0.0.0/4",
|
|
254
|
+
"240.0.0.0/4"
|
|
255
|
+
];
|
|
256
|
+
var BLOCKED_IPV6 = [
|
|
257
|
+
"::/128",
|
|
258
|
+
"::1/128",
|
|
259
|
+
"::ffff:0:0/96",
|
|
260
|
+
"64:ff9b::/96",
|
|
261
|
+
"64:ff9b:1::/48",
|
|
262
|
+
"100::/64",
|
|
263
|
+
"fc00::/7",
|
|
264
|
+
"fec0::/10",
|
|
265
|
+
"fe80::/10",
|
|
266
|
+
"ff00::/8",
|
|
267
|
+
"2001::/32",
|
|
268
|
+
"2001:2::/48",
|
|
269
|
+
"2001:10::/28",
|
|
270
|
+
"2001:20::/28",
|
|
271
|
+
"2001:db8::/32",
|
|
272
|
+
"2002::/16",
|
|
273
|
+
"3fff::/20"
|
|
274
|
+
];
|
|
275
|
+
var normalizeEgressTcpPorts = (ports) => {
|
|
276
|
+
for (const port of ports) {
|
|
277
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
278
|
+
throw new Error("Agent egress policy refuses an invalid loopback TCP port.");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return [...new Set(ports)].sort((left, right) => left - right);
|
|
282
|
+
};
|
|
283
|
+
function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
|
|
284
|
+
const ports = normalizeEgressTcpPorts(loopbackTcpPorts);
|
|
285
|
+
return [
|
|
286
|
+
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6",
|
|
287
|
+
`IPAddressAllow=${SYSTEMD_RESOLVED_ADDRESS}/32`,
|
|
288
|
+
...ports.length > 0 ? ["IPAddressAllow=127.0.0.1/32", "IPAddressAllow=::1/128"] : [],
|
|
289
|
+
...BLOCKED_IPV4.map((network) => `IPAddressDeny=${network}`),
|
|
290
|
+
...BLOCKED_IPV6.map((network) => `IPAddressDeny=${network}`)
|
|
291
|
+
].join(`
|
|
292
|
+
`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/version.ts
|
|
296
|
+
var VERSION = "0.1.35";
|
|
297
|
+
|
|
298
|
+
// src/metal-bootstrap.ts
|
|
299
|
+
var PROFILE_PATH = "/etc/forgezero/metal.json";
|
|
300
|
+
var STATE_PATH = "/etc/forgezero/metal.initialized.json";
|
|
301
|
+
var SEED_CREDENTIAL_PATH = "/etc/forgezero/creds/metal-agent-seed.cred";
|
|
302
|
+
var UNIT_DIRECTORY = "/etc/systemd/system";
|
|
303
|
+
var AGENT_PATH = "/usr/local/bin/fz-agent";
|
|
304
|
+
var HELPER_SOCKET = "/run/forgezero-metal/helper.sock";
|
|
305
|
+
var UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
|
|
306
|
+
var MAX_CONFIG_BYTES = 256 * 1024;
|
|
307
|
+
var SUPPORTED_BUN_VERSION = "1.3.14";
|
|
308
|
+
var SUPPORTED_BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
|
|
309
|
+
|
|
310
|
+
class MetalBootstrapError extends Error {
|
|
311
|
+
}
|
|
312
|
+
var defaultExec2 = async (argv, stdin) => {
|
|
313
|
+
const child = Bun.spawn([...argv], {
|
|
314
|
+
stdin: stdin === undefined ? undefined : new Blob([stdin]),
|
|
315
|
+
stdout: "pipe",
|
|
316
|
+
stderr: "pipe",
|
|
317
|
+
env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }
|
|
318
|
+
});
|
|
319
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
320
|
+
child.exited,
|
|
321
|
+
new Response(child.stdout).text(),
|
|
322
|
+
new Response(child.stderr).text()
|
|
323
|
+
]);
|
|
324
|
+
return { exitCode, stdout, stderr };
|
|
325
|
+
};
|
|
326
|
+
var runChecked = async (exec, argv, stdin) => {
|
|
327
|
+
const result = await exec(argv, stdin);
|
|
328
|
+
if (result.exitCode !== 0) {
|
|
329
|
+
throw new MetalBootstrapError(`${argv.join(" ")} failed: ${(result.stderr || result.stdout).trim() || `exit ${result.exitCode}`}`);
|
|
330
|
+
}
|
|
331
|
+
return result;
|
|
332
|
+
};
|
|
333
|
+
var exactKeys = (value, allowed, label) => {
|
|
334
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
335
|
+
if (unknown.length)
|
|
336
|
+
throw new MetalBootstrapError(`${label} contains unknown fields: ${unknown.join(", ")}`);
|
|
337
|
+
};
|
|
338
|
+
var validUnit = (unit) => {
|
|
339
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}\.service$/.test(unit)) {
|
|
340
|
+
throw new MetalBootstrapError("hostTelemetryUnit must be a systemd .service unit");
|
|
341
|
+
}
|
|
342
|
+
if (/^(forgezero@.*|forgezero-agent|forgezero-metal-agent|forgezero-metal-helper|forgezero-db)\.service$/.test(unit)) {
|
|
343
|
+
throw new MetalBootstrapError("the OTLP collector must be independently supervised");
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
function validateMetalBootstrapConfig(config) {
|
|
347
|
+
if (!config || typeof config !== "object")
|
|
348
|
+
throw new MetalBootstrapError("metal bootstrap config must be an object");
|
|
349
|
+
exactKeys(config, ["kind", "metalHostname", "profile", "hostTelemetryEndpoint", "hostTelemetryUnit", "agentSeedFile"], "metal bootstrap config");
|
|
350
|
+
if (config.kind !== "metal")
|
|
351
|
+
throw new MetalBootstrapError("metal bootstrap config kind must be metal");
|
|
352
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9.-]{1,252}$/.test(config.metalHostname)) {
|
|
353
|
+
throw new MetalBootstrapError("invalid metal inventory hostname");
|
|
354
|
+
}
|
|
355
|
+
if (!config.profile || typeof config.profile !== "object")
|
|
356
|
+
throw new MetalBootstrapError("metal profile must be an object");
|
|
357
|
+
exactKeys(config.profile, [
|
|
358
|
+
"volumeGroup",
|
|
359
|
+
"bridge",
|
|
360
|
+
"subnetPrefix",
|
|
361
|
+
"addressStart",
|
|
362
|
+
"addressEnd",
|
|
363
|
+
"gateway",
|
|
364
|
+
"nameservers",
|
|
365
|
+
"stateDir",
|
|
366
|
+
"seedDir",
|
|
367
|
+
"unitDir",
|
|
368
|
+
"apiUrl",
|
|
369
|
+
"agentTelemetryEndpoint",
|
|
370
|
+
"images",
|
|
371
|
+
"cpuPools",
|
|
372
|
+
"housekeepingCpus",
|
|
373
|
+
"housekeepingMemoryNodes",
|
|
374
|
+
"bunVersion",
|
|
375
|
+
"bunInstallerSha256",
|
|
376
|
+
"agentVersion",
|
|
377
|
+
"confidential"
|
|
378
|
+
], "metal profile");
|
|
379
|
+
if (Array.isArray(config.profile.cpuPools)) {
|
|
380
|
+
for (const pool of config.profile.cpuPools)
|
|
381
|
+
exactKeys(pool, ["key", "cpus", "physicalCores", "memoryNodes"], "metal CPU pool");
|
|
382
|
+
}
|
|
383
|
+
validateMetalProfile(config.profile);
|
|
384
|
+
if (config.profile.stateDir !== "/etc/forgezero/metal-guests" || config.profile.seedDir !== "/var/lib/forgezero/seed" || config.profile.unitDir !== UNIT_DIRECTORY || config.profile.legacyStateDir !== undefined) {
|
|
385
|
+
throw new MetalBootstrapError("metal bootstrap uses fixed state, seed, and systemd unit directories");
|
|
386
|
+
}
|
|
387
|
+
const image = config.profile.images[Object.keys(config.profile.images)[0]];
|
|
388
|
+
if (!image.path.startsWith("/var/lib/forgezero/images/") || resolve(image.path) !== image.path || /[\0\r\n]/.test(image.path)) {
|
|
389
|
+
throw new MetalBootstrapError("the pinned guest image must use the fixed image directory");
|
|
390
|
+
}
|
|
391
|
+
if (config.profile.bunVersion !== SUPPORTED_BUN_VERSION || config.profile.bunInstallerSha256 !== SUPPORTED_BUN_INSTALLER_SHA256 || config.profile.agentVersion !== VERSION) {
|
|
392
|
+
throw new MetalBootstrapError("metal guest runtime must use the package-owned Bun and Agent release coordinates");
|
|
393
|
+
}
|
|
394
|
+
let api;
|
|
395
|
+
try {
|
|
396
|
+
api = new URL(config.profile.apiUrl);
|
|
397
|
+
} catch {
|
|
398
|
+
throw new MetalBootstrapError("metal API must be a public HTTPS origin");
|
|
399
|
+
}
|
|
400
|
+
if (api.protocol !== "https:" || api.username || api.password || api.pathname !== "/" || api.search || api.hash || isIP2(api.hostname) !== 0 || !api.hostname.includes(".") || api.hostname.endsWith(".local")) {
|
|
401
|
+
throw new MetalBootstrapError("metal API must be a credential-free public HTTPS origin");
|
|
402
|
+
}
|
|
403
|
+
if (config.profile.nameservers?.some((address) => isIP2(address) === 0)) {
|
|
404
|
+
throw new MetalBootstrapError("metal nameservers must be literal IP addresses");
|
|
405
|
+
}
|
|
406
|
+
if (isIP2(`${config.profile.subnetPrefix}.1`) !== 4 || isIP2(config.profile.gateway) !== 4 || !config.profile.gateway.startsWith(`${config.profile.subnetPrefix}.`)) {
|
|
407
|
+
throw new MetalBootstrapError("metal gateway must be an IPv4 address in the reviewed subnet");
|
|
408
|
+
}
|
|
409
|
+
if (config.profile.confidential && (!Number.isSafeInteger(config.profile.confidential.cbitpos) || config.profile.confidential.cbitpos < 1 || config.profile.confidential.cbitpos > 63 || !Number.isSafeInteger(config.profile.confidential.reducedPhysBits) || config.profile.confidential.reducedPhysBits < 0 || config.profile.confidential.reducedPhysBits > 63 || !/^0x[0-9a-fA-F]{1,16}$/.test(config.profile.confidential.policy)))
|
|
410
|
+
throw new MetalBootstrapError("invalid confidential-compute coordinate");
|
|
411
|
+
if (config.hostTelemetryEndpoint !== "http://127.0.0.1:4318") {
|
|
412
|
+
throw new MetalBootstrapError("metal host OTLP must use exact loopback http://127.0.0.1:4318");
|
|
413
|
+
}
|
|
414
|
+
validUnit(config.hostTelemetryUnit);
|
|
415
|
+
if (config.agentSeedFile)
|
|
416
|
+
validateOwnerOnlyPath(config.agentSeedFile, false);
|
|
417
|
+
return config;
|
|
418
|
+
}
|
|
419
|
+
function validateOwnerOnlyPath(path, requireRootOwner) {
|
|
420
|
+
if (!isAbsolute2(path) || resolve(path) !== path || path.includes("/../")) {
|
|
421
|
+
throw new MetalBootstrapError("private bootstrap paths must be canonical absolute paths");
|
|
422
|
+
}
|
|
423
|
+
const metadata = lstatSync(path);
|
|
424
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || realpathSync(path) !== path) {
|
|
425
|
+
throw new MetalBootstrapError("private bootstrap path must be a regular non-symlink file");
|
|
426
|
+
}
|
|
427
|
+
if ((metadata.mode & 63) !== 0)
|
|
428
|
+
throw new MetalBootstrapError("private bootstrap file must be owner-only");
|
|
429
|
+
const caller = typeof process.getuid === "function" ? process.getuid() : -1;
|
|
430
|
+
if (requireRootOwner && metadata.uid !== 0 || !requireRootOwner && metadata.uid !== 0 && metadata.uid !== caller) {
|
|
431
|
+
throw new MetalBootstrapError(requireRootOwner ? "private bootstrap file must be root-owned" : "private bootstrap file has an unexpected owner");
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function readMetalBootstrapConfig(path) {
|
|
435
|
+
validateOwnerOnlyPath(path, false);
|
|
436
|
+
const metadata = statSync(path);
|
|
437
|
+
if (metadata.size < 2 || metadata.size > MAX_CONFIG_BYTES)
|
|
438
|
+
throw new MetalBootstrapError("metal bootstrap config size is invalid");
|
|
439
|
+
let parsed;
|
|
440
|
+
try {
|
|
441
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
442
|
+
} catch {
|
|
443
|
+
throw new MetalBootstrapError("metal bootstrap config is not valid JSON");
|
|
444
|
+
}
|
|
445
|
+
return validateMetalBootstrapConfig(parsed);
|
|
446
|
+
}
|
|
447
|
+
function planMetalBootstrap(config) {
|
|
448
|
+
validateMetalBootstrapConfig(config);
|
|
449
|
+
return {
|
|
450
|
+
kind: "metal",
|
|
451
|
+
metalHostname: config.metalHostname,
|
|
452
|
+
profilePath: PROFILE_PATH,
|
|
453
|
+
units: [
|
|
454
|
+
"forgezero-metal-helper.service",
|
|
455
|
+
"forgezero-agent-update-helper.service",
|
|
456
|
+
"forgezero-metal-agent-egress.service",
|
|
457
|
+
"forgezero-metal-agent.service"
|
|
458
|
+
],
|
|
459
|
+
steps: [
|
|
460
|
+
"install fixed KVM/LVM/cloud-init/nftables prerequisites",
|
|
461
|
+
"verify bridge, volume group, pinned guest image, KVM and optional SEV device",
|
|
462
|
+
"install the published Agent binary and root-owned immutable metal profile",
|
|
463
|
+
"seal or generate the metal identity seed as a systemd credential",
|
|
464
|
+
"apply reviewed CPU/NUMA host and guest isolation",
|
|
465
|
+
"install helper, update, egress and identity-only Agent units",
|
|
466
|
+
"enable units and prove services, local sockets, egress policy and OTLP collector"
|
|
467
|
+
],
|
|
468
|
+
requiresRoot: true,
|
|
469
|
+
consumes: config.agentSeedFile ? [config.agentSeedFile] : []
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
var atomicWrite = (path, body, mode) => {
|
|
473
|
+
mkdirSync2(dirname2(path), { recursive: true, mode: 493 });
|
|
474
|
+
const temporary = `${path}.next-${process.pid}`;
|
|
475
|
+
writeFileSync2(temporary, body, { mode, flag: "wx" });
|
|
476
|
+
chmodSync(temporary, mode);
|
|
477
|
+
chownSync(temporary, 0, 0);
|
|
478
|
+
renameSync(temporary, path);
|
|
479
|
+
};
|
|
480
|
+
var validateAgentSourcePath = (source) => {
|
|
481
|
+
if (!isAbsolute2(source) || !lstatSync(source).isFile() || lstatSync(source).isSymbolicLink()) {
|
|
482
|
+
throw new MetalBootstrapError("published Agent source path must be an absolute regular non-symlink file");
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
var ensureAccount = async (exec) => {
|
|
486
|
+
if ((await exec(["/usr/bin/getent", "group", "forgezero-metal"])).exitCode !== 0) {
|
|
487
|
+
await runChecked(exec, ["/usr/sbin/groupadd", "--system", "forgezero-metal"]);
|
|
488
|
+
}
|
|
489
|
+
if ((await exec(["/usr/bin/getent", "group", "forgezero-update"])).exitCode !== 0) {
|
|
490
|
+
await runChecked(exec, ["/usr/sbin/groupadd", "--system", "forgezero-update"]);
|
|
491
|
+
}
|
|
492
|
+
if ((await exec(["/usr/bin/id", "forgezero-metal"])).exitCode !== 0) {
|
|
493
|
+
await runChecked(exec, [
|
|
494
|
+
"/usr/sbin/useradd",
|
|
495
|
+
"--system",
|
|
496
|
+
"--no-create-home",
|
|
497
|
+
"--shell",
|
|
498
|
+
"/usr/sbin/nologin",
|
|
499
|
+
"--gid",
|
|
500
|
+
"forgezero-metal",
|
|
501
|
+
"forgezero-metal"
|
|
502
|
+
]);
|
|
503
|
+
}
|
|
504
|
+
await runChecked(exec, ["/usr/sbin/usermod", "-a", "-G", "forgezero-update", "forgezero-metal"]);
|
|
505
|
+
};
|
|
506
|
+
function renderMetalUnits(config) {
|
|
507
|
+
validateMetalBootstrapConfig(config);
|
|
508
|
+
const egress = systemdAgentEgressDirectives([4318]);
|
|
509
|
+
const updateEgress = systemdAgentEgressDirectives();
|
|
510
|
+
return {
|
|
511
|
+
"forgezero-metal-helper.service": `[Unit]
|
|
512
|
+
Description=ForgeZero constrained physical-host helper
|
|
513
|
+
After=local-fs.target
|
|
514
|
+
|
|
515
|
+
[Service]
|
|
516
|
+
Type=simple
|
|
517
|
+
User=root
|
|
518
|
+
Group=forgezero-metal
|
|
519
|
+
UMask=0007
|
|
520
|
+
RuntimeDirectory=forgezero-metal
|
|
521
|
+
RuntimeDirectoryMode=0770
|
|
522
|
+
ExecStart=${AGENT_PATH} metal-helper --profile=${PROFILE_PATH}
|
|
523
|
+
Restart=on-failure
|
|
524
|
+
RestartSec=5
|
|
525
|
+
TimeoutStopSec=10min
|
|
526
|
+
LimitCORE=0
|
|
527
|
+
PrivateTmp=true
|
|
528
|
+
ProtectHome=true
|
|
529
|
+
RestrictAddressFamilies=AF_UNIX
|
|
530
|
+
|
|
531
|
+
[Install]
|
|
532
|
+
WantedBy=multi-user.target
|
|
533
|
+
`,
|
|
534
|
+
"forgezero-agent-update-helper.service": `[Unit]
|
|
535
|
+
Description=ForgeZero verified Agent update helper
|
|
536
|
+
After=network-online.target
|
|
537
|
+
Wants=network-online.target
|
|
538
|
+
|
|
539
|
+
[Service]
|
|
540
|
+
Type=simple
|
|
541
|
+
User=root
|
|
542
|
+
Group=forgezero-update
|
|
543
|
+
Environment=FZ_AGENT_UPDATE_SOCKET=${UPDATE_SOCKET}
|
|
544
|
+
ExecStart=${AGENT_PATH} update-helper
|
|
545
|
+
Restart=always
|
|
546
|
+
RestartSec=2
|
|
547
|
+
RuntimeDirectory=forgezero-update
|
|
548
|
+
RuntimeDirectoryMode=0750
|
|
549
|
+
UMask=0007
|
|
550
|
+
LimitCORE=0
|
|
551
|
+
NoNewPrivileges=true
|
|
552
|
+
PrivateTmp=true
|
|
553
|
+
ProtectSystem=strict
|
|
554
|
+
ProtectHome=true
|
|
555
|
+
ProtectKernelTunables=true
|
|
556
|
+
ProtectKernelModules=true
|
|
557
|
+
ProtectControlGroups=true
|
|
558
|
+
RestrictSUIDSGID=true
|
|
559
|
+
RestrictRealtime=true
|
|
560
|
+
LockPersonality=true
|
|
561
|
+
ReadWritePaths=/opt/forgezero/agent /var/lib/forgezero
|
|
562
|
+
${updateEgress}
|
|
563
|
+
|
|
564
|
+
[Install]
|
|
565
|
+
WantedBy=multi-user.target
|
|
566
|
+
`,
|
|
567
|
+
"forgezero-metal-agent-egress.service": `[Unit]
|
|
568
|
+
Description=ForgeZero physical Agent host egress policy
|
|
569
|
+
After=systemd-resolved.service nftables.service
|
|
570
|
+
Requires=systemd-resolved.service
|
|
571
|
+
Before=forgezero-metal-agent.service
|
|
572
|
+
|
|
573
|
+
[Service]
|
|
574
|
+
Type=notify
|
|
575
|
+
NotifyAccess=all
|
|
576
|
+
User=root
|
|
577
|
+
Group=root
|
|
578
|
+
ExecStart=${AGENT_PATH} egress-policy --user=forgezero-metal --loopback-user=forgezero-metal --loopback-tcp-port=4318 --public-tcp-port=443
|
|
579
|
+
Restart=on-failure
|
|
580
|
+
RestartSec=2
|
|
581
|
+
LimitCORE=0
|
|
582
|
+
NoNewPrivileges=true
|
|
583
|
+
PrivateTmp=true
|
|
584
|
+
ProtectSystem=strict
|
|
585
|
+
ProtectHome=true
|
|
586
|
+
ProtectKernelTunables=true
|
|
587
|
+
ProtectKernelModules=true
|
|
588
|
+
ProtectControlGroups=true
|
|
589
|
+
RestrictSUIDSGID=true
|
|
590
|
+
RestrictRealtime=true
|
|
591
|
+
MemoryDenyWriteExecute=true
|
|
592
|
+
LockPersonality=true
|
|
593
|
+
CapabilityBoundingSet=CAP_NET_ADMIN
|
|
594
|
+
RestrictAddressFamilies=AF_UNIX AF_NETLINK
|
|
595
|
+
|
|
596
|
+
[Install]
|
|
597
|
+
WantedBy=multi-user.target
|
|
598
|
+
`,
|
|
599
|
+
"forgezero-metal-agent.service": `[Unit]
|
|
600
|
+
Description=ForgeZero identity-only physical-host Agent
|
|
601
|
+
After=network-online.target ${config.hostTelemetryUnit} forgezero-metal-helper.service forgezero-agent-update-helper.service forgezero-metal-agent-egress.service
|
|
602
|
+
Wants=network-online.target ${config.hostTelemetryUnit}
|
|
603
|
+
Requires=forgezero-metal-helper.service forgezero-agent-update-helper.service forgezero-metal-agent-egress.service
|
|
604
|
+
BindsTo=forgezero-metal-agent-egress.service
|
|
605
|
+
|
|
606
|
+
[Service]
|
|
607
|
+
Type=simple
|
|
608
|
+
User=forgezero-metal
|
|
609
|
+
Group=forgezero-metal
|
|
610
|
+
SupplementaryGroups=forgezero-update
|
|
611
|
+
LoadCredentialEncrypted=metal-agent-seed:${SEED_CREDENTIAL_PATH}
|
|
612
|
+
Environment=FZ_SEED_CREDENTIAL=metal-agent-seed
|
|
613
|
+
Environment=FZ_AGENT_ROLE=metal
|
|
614
|
+
Environment=FZ_METAL_HOSTNAME=${config.metalHostname}
|
|
615
|
+
Environment=FZ_API=${config.profile.apiUrl}
|
|
616
|
+
Environment=FZ_METAL_HELPER_SOCKET=${HELPER_SOCKET}
|
|
617
|
+
Environment=FZ_DRAIN_DEADLINE_MS=120000
|
|
618
|
+
Environment=NODE_ENV=production
|
|
619
|
+
Environment=OTEL_EXPORTER_OTLP_ENDPOINT=${config.hostTelemetryEndpoint}
|
|
620
|
+
Environment=OTEL_SERVICE_NAME=forgezero-metal-agent
|
|
621
|
+
ExecStart=${AGENT_PATH}
|
|
622
|
+
Restart=on-failure
|
|
623
|
+
RestartSec=5
|
|
624
|
+
TimeoutStopSec=130s
|
|
625
|
+
LimitCORE=0
|
|
626
|
+
NoNewPrivileges=true
|
|
627
|
+
PrivateTmp=true
|
|
628
|
+
ProtectSystem=strict
|
|
629
|
+
ProtectHome=true
|
|
630
|
+
ProtectKernelTunables=true
|
|
631
|
+
ProtectKernelModules=true
|
|
632
|
+
ProtectControlGroups=true
|
|
633
|
+
RestrictSUIDSGID=true
|
|
634
|
+
LockPersonality=true
|
|
635
|
+
${egress}
|
|
636
|
+
|
|
637
|
+
[Install]
|
|
638
|
+
WantedBy=multi-user.target
|
|
639
|
+
`
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
var installAgentBinary = (source, version) => {
|
|
643
|
+
validateAgentSourcePath(source);
|
|
644
|
+
const release = `/opt/forgezero/agent/versions/${version}/dist`;
|
|
645
|
+
mkdirSync2(release, { recursive: true, mode: 493 });
|
|
646
|
+
copyFileSync(source, join3(release, "fz-agent.js"));
|
|
647
|
+
chmodSync(join3(release, "fz-agent.js"), 493);
|
|
648
|
+
chownSync(join3(release, "fz-agent.js"), 0, 0);
|
|
649
|
+
mkdirSync2("/opt/forgezero/agent", { recursive: true, mode: 493 });
|
|
650
|
+
for (const [link, target] of [
|
|
651
|
+
["/opt/forgezero/agent/current.next", `versions/${version}`],
|
|
652
|
+
[AGENT_PATH, "/opt/forgezero/agent/current/dist/fz-agent.js"]
|
|
653
|
+
]) {
|
|
654
|
+
try {
|
|
655
|
+
unlinkSync(link);
|
|
656
|
+
} catch {}
|
|
657
|
+
symlinkSync(target, link);
|
|
658
|
+
if (link.endsWith("current.next"))
|
|
659
|
+
renameSync(link, "/opt/forgezero/agent/current");
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
var preflight = async (config, exec) => {
|
|
663
|
+
await runChecked(exec, ["/usr/bin/systemctl", "is-active", "--quiet", config.hostTelemetryUnit]);
|
|
664
|
+
await runChecked(exec, [
|
|
665
|
+
"/usr/bin/curl",
|
|
666
|
+
"--silent",
|
|
667
|
+
"--show-error",
|
|
668
|
+
"--fail",
|
|
669
|
+
"--max-time",
|
|
670
|
+
"5",
|
|
671
|
+
"--request",
|
|
672
|
+
"POST",
|
|
673
|
+
"--header",
|
|
674
|
+
"Content-Type: application/json",
|
|
675
|
+
"--data-binary",
|
|
676
|
+
"{}",
|
|
677
|
+
`${config.hostTelemetryEndpoint}/v1/metrics`
|
|
678
|
+
]);
|
|
679
|
+
await runChecked(exec, ["/usr/sbin/vgs", config.profile.volumeGroup]);
|
|
680
|
+
await runChecked(exec, ["/usr/sbin/ip", "link", "show", config.profile.bridge]);
|
|
681
|
+
if (!existsSync("/dev/kvm"))
|
|
682
|
+
throw new MetalBootstrapError("/dev/kvm is required");
|
|
683
|
+
if (config.profile.confidential) {
|
|
684
|
+
if (!existsSync("/dev/sev"))
|
|
685
|
+
throw new MetalBootstrapError("/dev/sev is required by the confidential profile");
|
|
686
|
+
await runChecked(exec, ["/usr/bin/qemu-system-x86_64", "-object", "sev-snp-guest,help"]);
|
|
687
|
+
}
|
|
688
|
+
const digest = (await runChecked(exec, ["/usr/bin/sha256sum", config.profile.images[Object.keys(config.profile.images)[0]].path])).stdout.split(/\s+/)[0];
|
|
689
|
+
if (digest !== config.profile.images[Object.keys(config.profile.images)[0]].sha256)
|
|
690
|
+
throw new MetalBootstrapError("pinned guest image digest mismatch");
|
|
691
|
+
};
|
|
692
|
+
var assertSupportedMetalHost = () => {
|
|
693
|
+
if (process.platform !== "linux" || process.arch !== "x64") {
|
|
694
|
+
throw new MetalBootstrapError("metal bootstrap supports only Ubuntu 26.04 x86_64 hosts");
|
|
695
|
+
}
|
|
696
|
+
const release = readFileSync("/etc/os-release", "utf8");
|
|
697
|
+
if (!/^ID=ubuntu$/m.test(release) || !/^VERSION_ID="?26\.04"?$/m.test(release)) {
|
|
698
|
+
throw new MetalBootstrapError("metal bootstrap supports only Ubuntu 26.04 x86_64 hosts");
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
var ensurePinnedGuestImage = async (config, exec) => {
|
|
702
|
+
const image = config.profile.images[SUPPORTED_GUEST_IMAGE.key];
|
|
703
|
+
if (existsSync(image.path))
|
|
704
|
+
return;
|
|
705
|
+
mkdirSync2(dirname2(image.path), { recursive: true, mode: 493 });
|
|
706
|
+
const temporary = `${image.path}.next-${process.pid}`;
|
|
707
|
+
try {
|
|
708
|
+
await runChecked(exec, [
|
|
709
|
+
"/usr/bin/curl",
|
|
710
|
+
"--fail",
|
|
711
|
+
"--location",
|
|
712
|
+
"--proto",
|
|
713
|
+
"=https",
|
|
714
|
+
"--tlsv1.2",
|
|
715
|
+
"--output",
|
|
716
|
+
temporary,
|
|
717
|
+
SUPPORTED_GUEST_IMAGE.url
|
|
718
|
+
]);
|
|
719
|
+
const digest = (await runChecked(exec, ["/usr/bin/sha256sum", temporary])).stdout.split(/\s+/)[0];
|
|
720
|
+
if (digest !== image.sha256)
|
|
721
|
+
throw new MetalBootstrapError("downloaded guest image digest mismatch");
|
|
722
|
+
chmodSync(temporary, 292);
|
|
723
|
+
chownSync(temporary, 0, 0);
|
|
724
|
+
renameSync(temporary, image.path);
|
|
725
|
+
} catch (cause) {
|
|
726
|
+
try {
|
|
727
|
+
unlinkSync(temporary);
|
|
728
|
+
} catch {}
|
|
729
|
+
throw cause;
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
async function applyMetalBootstrap(config, options) {
|
|
733
|
+
validateMetalBootstrapConfig(config);
|
|
734
|
+
if ((options.getuid ?? process.getuid)?.() !== 0)
|
|
735
|
+
throw new MetalBootstrapError("fz bootstrap metal --apply must run as root");
|
|
736
|
+
assertSupportedMetalHost();
|
|
737
|
+
if (existsSync(STATE_PATH) && !options.repair)
|
|
738
|
+
throw new MetalBootstrapError("metal host is already initialized; use explicit repair");
|
|
739
|
+
if (config.agentSeedFile)
|
|
740
|
+
validateOwnerOnlyPath(config.agentSeedFile, true);
|
|
741
|
+
if (config.agentSeedFile && existsSync(SEED_CREDENTIAL_PATH)) {
|
|
742
|
+
throw new MetalBootstrapError("repair refuses replacement seed material while the sealed metal identity exists");
|
|
743
|
+
}
|
|
744
|
+
validateAgentSourcePath(options.agentSourcePath);
|
|
745
|
+
const exec = options.exec ?? defaultExec2;
|
|
746
|
+
await runChecked(exec, ["/usr/bin/apt-get", "update"]);
|
|
747
|
+
await runChecked(exec, [
|
|
748
|
+
"/usr/bin/apt-get",
|
|
749
|
+
"install",
|
|
750
|
+
"-y",
|
|
751
|
+
"--no-install-recommends",
|
|
752
|
+
"qemu-system-x86",
|
|
753
|
+
"qemu-utils",
|
|
754
|
+
"cloud-image-utils",
|
|
755
|
+
"lvm2",
|
|
756
|
+
"nftables",
|
|
757
|
+
"curl"
|
|
758
|
+
]);
|
|
759
|
+
await ensurePinnedGuestImage(config, exec);
|
|
760
|
+
await preflight(config, exec);
|
|
761
|
+
await ensureAccount(exec);
|
|
762
|
+
installAgentBinary(options.agentSourcePath, config.profile.agentVersion);
|
|
763
|
+
mkdirSync2("/etc/forgezero/creds", { recursive: true, mode: 448 });
|
|
764
|
+
mkdirSync2(config.profile.stateDir, { recursive: true, mode: 448 });
|
|
765
|
+
mkdirSync2(config.profile.seedDir, { recursive: true, mode: 448 });
|
|
766
|
+
const persistedProfile = {
|
|
767
|
+
...config.profile,
|
|
768
|
+
metalHostname: config.metalHostname,
|
|
769
|
+
hostTelemetryEndpoint: config.hostTelemetryEndpoint,
|
|
770
|
+
hostTelemetryUnit: config.hostTelemetryUnit
|
|
771
|
+
};
|
|
772
|
+
atomicWrite(PROFILE_PATH, `${JSON.stringify(persistedProfile, null, 2)}
|
|
773
|
+
`, 384);
|
|
774
|
+
if (!existsSync(SEED_CREDENTIAL_PATH)) {
|
|
775
|
+
const seed = config.agentSeedFile ? readFileSync(config.agentSeedFile, "utf8").trim() : randomBytes(32).toString("base64url");
|
|
776
|
+
if (seed.length < 32 || /[\0\r\n]/.test(seed))
|
|
777
|
+
throw new MetalBootstrapError("metal Agent seed is invalid");
|
|
778
|
+
await runChecked(exec, ["/usr/bin/systemd-creds", "encrypt", "--name=metal-agent-seed", "-", SEED_CREDENTIAL_PATH], `${seed}
|
|
779
|
+
`);
|
|
780
|
+
chmodSync(SEED_CREDENTIAL_PATH, 256);
|
|
781
|
+
chownSync(SEED_CREDENTIAL_PATH, 0, 0);
|
|
782
|
+
if (config.agentSeedFile)
|
|
783
|
+
unlinkSync(config.agentSeedFile);
|
|
784
|
+
}
|
|
785
|
+
await applyMetalIsolation(config.profile, (argv) => exec(argv));
|
|
786
|
+
for (const [unit, body] of Object.entries(renderMetalUnits(config)))
|
|
787
|
+
atomicWrite(join3(UNIT_DIRECTORY, unit), body, 420);
|
|
788
|
+
await runChecked(exec, ["/usr/bin/systemctl", "daemon-reload"]);
|
|
789
|
+
await runChecked(exec, [
|
|
790
|
+
"/usr/bin/systemctl",
|
|
791
|
+
"enable",
|
|
792
|
+
"--now",
|
|
793
|
+
"forgezero-agent-update-helper.service",
|
|
794
|
+
"forgezero-metal-helper.service",
|
|
795
|
+
"forgezero-metal-agent-egress.service",
|
|
796
|
+
"forgezero-metal-agent.service"
|
|
797
|
+
]);
|
|
798
|
+
for (let attempt = 0;attempt < 100 && (!socketReady(HELPER_SOCKET) || !socketReady(UPDATE_SOCKET)); attempt += 1) {
|
|
799
|
+
await Bun.sleep(100);
|
|
800
|
+
}
|
|
801
|
+
const status = await metalBootstrapStatus(exec);
|
|
802
|
+
const operationalProblems = options.repair ? status.problems.filter((problem) => problem !== "metal initialized state does not bind the current profile") : status.problems;
|
|
803
|
+
if (operationalProblems.length)
|
|
804
|
+
throw new MetalBootstrapError(`metal bootstrap verification failed: ${operationalProblems.join("; ")}`);
|
|
805
|
+
const identity = await runChecked(exec, [
|
|
806
|
+
"/usr/bin/systemd-run",
|
|
807
|
+
"--pipe",
|
|
808
|
+
"--wait",
|
|
809
|
+
"--quiet",
|
|
810
|
+
"--collect",
|
|
811
|
+
`--property=LoadCredentialEncrypted=metal-agent-seed:${SEED_CREDENTIAL_PATH}`,
|
|
812
|
+
"--setenv=FZ_SEED_CREDENTIAL=metal-agent-seed",
|
|
813
|
+
AGENT_PATH,
|
|
814
|
+
"identity"
|
|
815
|
+
]);
|
|
816
|
+
const publicIdentity = identity.stdout.trim();
|
|
817
|
+
if (!publicIdentity || /[\r\n]/.test(publicIdentity))
|
|
818
|
+
throw new MetalBootstrapError("metal public identity proof was invalid");
|
|
819
|
+
const state = {
|
|
820
|
+
initializedAt: new Date().toISOString(),
|
|
821
|
+
role: "metal",
|
|
822
|
+
metalHostname: config.metalHostname,
|
|
823
|
+
profileSha256: createHash("sha256").update(JSON.stringify(config.profile)).digest("hex")
|
|
824
|
+
};
|
|
825
|
+
atomicWrite(STATE_PATH, `${JSON.stringify(state, null, 2)}
|
|
826
|
+
`, 384);
|
|
827
|
+
return { ...status, initialized: true, problems: [], metalHostname: config.metalHostname, publicIdentity };
|
|
828
|
+
}
|
|
829
|
+
var socketReady = (path) => {
|
|
830
|
+
try {
|
|
831
|
+
return lstatSync(path).isSocket();
|
|
832
|
+
} catch {
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
async function metalBootstrapStatus(exec = defaultExec2) {
|
|
837
|
+
const problems = [];
|
|
838
|
+
let profileValid = false, imageVerified = false, metalHostname, profileSha256;
|
|
839
|
+
let profileMode = null;
|
|
840
|
+
if (existsSync(PROFILE_PATH)) {
|
|
841
|
+
try {
|
|
842
|
+
const metadata = statSync(PROFILE_PATH);
|
|
843
|
+
profileMode = metadata.mode & 511;
|
|
844
|
+
if (profileMode !== 384 || metadata.uid !== 0)
|
|
845
|
+
problems.push("metal profile is not root-owned mode 0600");
|
|
846
|
+
const persisted = JSON.parse(readFileSync(PROFILE_PATH, "utf8"));
|
|
847
|
+
const { metalHostname: profileHostname, hostTelemetryEndpoint, hostTelemetryUnit, ...profile } = persisted;
|
|
848
|
+
validateMetalProfile(profile);
|
|
849
|
+
profileValid = true;
|
|
850
|
+
profileSha256 = createHash("sha256").update(JSON.stringify(profile)).digest("hex");
|
|
851
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9.-]{1,252}$/.test(profileHostname) || hostTelemetryEndpoint !== "http://127.0.0.1:4318") {
|
|
852
|
+
problems.push("persisted metal host coordinates are invalid");
|
|
853
|
+
}
|
|
854
|
+
try {
|
|
855
|
+
validUnit(hostTelemetryUnit);
|
|
856
|
+
} catch {
|
|
857
|
+
problems.push("persisted metal OTLP collector unit is invalid");
|
|
858
|
+
}
|
|
859
|
+
if ((await exec(["/usr/bin/systemctl", "is-active", "--quiet", hostTelemetryUnit])).exitCode !== 0) {
|
|
860
|
+
problems.push(`${hostTelemetryUnit} is inactive`);
|
|
861
|
+
} else if ((await exec([
|
|
862
|
+
"/usr/bin/curl",
|
|
863
|
+
"--silent",
|
|
864
|
+
"--show-error",
|
|
865
|
+
"--fail",
|
|
866
|
+
"--max-time",
|
|
867
|
+
"5",
|
|
868
|
+
"--request",
|
|
869
|
+
"POST",
|
|
870
|
+
"--header",
|
|
871
|
+
"Content-Type: application/json",
|
|
872
|
+
"--data-binary",
|
|
873
|
+
"{}",
|
|
874
|
+
`${hostTelemetryEndpoint}/v1/metrics`
|
|
875
|
+
])).exitCode !== 0) {
|
|
876
|
+
problems.push("local OTLP metrics receiver did not accept a proof request");
|
|
877
|
+
}
|
|
878
|
+
const image = profile.images[Object.keys(profile.images)[0]];
|
|
879
|
+
if (existsSync(image.path)) {
|
|
880
|
+
const digest = (await exec(["/usr/bin/sha256sum", image.path])).stdout.split(/\s+/)[0];
|
|
881
|
+
imageVerified = digest === image.sha256;
|
|
882
|
+
}
|
|
883
|
+
if (!imageVerified)
|
|
884
|
+
problems.push("pinned guest image is absent or has the wrong digest");
|
|
885
|
+
} catch (cause) {
|
|
886
|
+
problems.push(`metal profile invalid: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
887
|
+
}
|
|
888
|
+
} else
|
|
889
|
+
problems.push("metal profile is missing");
|
|
890
|
+
if (existsSync(STATE_PATH)) {
|
|
891
|
+
try {
|
|
892
|
+
const state = JSON.parse(readFileSync(STATE_PATH, "utf8"));
|
|
893
|
+
metalHostname = state.metalHostname;
|
|
894
|
+
if (state.role !== "metal" || !metalHostname || state.profileSha256 !== profileSha256) {
|
|
895
|
+
problems.push("metal initialized state does not bind the current profile");
|
|
896
|
+
}
|
|
897
|
+
} catch {
|
|
898
|
+
problems.push("metal initialized state is invalid");
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const units = {};
|
|
902
|
+
for (const unit of [
|
|
903
|
+
"forgezero-metal-helper.service",
|
|
904
|
+
"forgezero-agent-update-helper.service",
|
|
905
|
+
"forgezero-metal-agent-egress.service",
|
|
906
|
+
"forgezero-metal-agent.service"
|
|
907
|
+
]) {
|
|
908
|
+
if (!existsSync(join3(UNIT_DIRECTORY, unit)))
|
|
909
|
+
units[unit] = "missing";
|
|
910
|
+
else
|
|
911
|
+
units[unit] = (await exec(["/usr/bin/systemctl", "is-active", "--quiet", unit])).exitCode === 0 ? "active" : "inactive";
|
|
912
|
+
if (units[unit] !== "active")
|
|
913
|
+
problems.push(`${unit} is ${units[unit]}`);
|
|
914
|
+
}
|
|
915
|
+
const helperSocketReady = socketReady(HELPER_SOCKET);
|
|
916
|
+
const updateSocketReady = socketReady(UPDATE_SOCKET);
|
|
917
|
+
if (!helperSocketReady)
|
|
918
|
+
problems.push("metal helper socket is not ready");
|
|
919
|
+
if (!updateSocketReady)
|
|
920
|
+
problems.push("Agent update helper socket is not ready");
|
|
921
|
+
return {
|
|
922
|
+
initialized: existsSync(STATE_PATH),
|
|
923
|
+
profileValid,
|
|
924
|
+
profileMode,
|
|
925
|
+
imageVerified,
|
|
926
|
+
units,
|
|
927
|
+
helperSocketReady,
|
|
928
|
+
updateSocketReady,
|
|
929
|
+
metalHostname,
|
|
930
|
+
problems
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
export {
|
|
934
|
+
validateOwnerOnlyPath,
|
|
935
|
+
validateMetalBootstrapConfig,
|
|
936
|
+
renderMetalUnits,
|
|
937
|
+
readMetalBootstrapConfig,
|
|
938
|
+
planMetalBootstrap,
|
|
939
|
+
metalBootstrapStatus,
|
|
940
|
+
applyMetalBootstrap,
|
|
941
|
+
MetalBootstrapError
|
|
942
|
+
};
|