@forgezero/agent 0.1.31 → 0.1.33
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 +60 -5
- package/dist/agent-heartbeat.d.ts +12 -2
- package/dist/agent-heartbeat.js +416 -60
- package/dist/agent-update-helper.d.ts +41 -2
- package/dist/agent-update-helper.js +361 -43
- package/dist/agent-update.d.ts +1 -0
- package/dist/agent-update.js +34 -2
- package/dist/attestation-client.d.ts +2 -0
- package/dist/cli/agent-install.d.ts +6 -0
- package/dist/cli/genesis.d.ts +7 -48
- package/dist/cli/index.d.ts +5 -0
- package/dist/cli/session-store.d.ts +22 -0
- package/dist/deployment-pull.d.ts +2 -0
- package/dist/deployment.d.ts +3 -0
- package/dist/egress-policy.d.ts +50 -0
- package/dist/fz-agent.js +5088 -1699
- package/dist/fz.js +10334 -7613
- package/dist/git-egress.d.ts +35 -0
- package/dist/index.d.ts +19 -2
- package/dist/metal-helper-socket.js +12 -1
- package/dist/metal-provision.d.ts +2 -0
- package/dist/metal-provision.js +12 -1
- package/dist/migration-pull.d.ts +2 -0
- package/dist/migration-pull.js +18 -6
- package/dist/node-vault.d.ts +2 -0
- package/dist/node-vault.js +2 -1
- package/dist/provision.d.ts +13 -1
- package/dist/provision.js +540 -52
- package/dist/provisioning-pull.d.ts +2 -0
- package/dist/provisioning-pull.js +20 -7
- package/dist/telemetry-runtime.d.ts +20 -0
- package/dist/telemetry.d.ts +56 -0
- package/dist/version.d.ts +1 -1
- package/package.json +10 -10
package/dist/provision.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import { createHash, timingSafeEqual, randomUUID } from "node:crypto";
|
|
3
3
|
import {
|
|
4
4
|
chmodSync,
|
|
5
|
+
closeSync,
|
|
5
6
|
existsSync,
|
|
7
|
+
fsyncSync,
|
|
6
8
|
mkdirSync,
|
|
9
|
+
openSync,
|
|
7
10
|
readFileSync,
|
|
8
11
|
readlinkSync,
|
|
9
12
|
renameSync,
|
|
@@ -17,6 +20,25 @@ var DEFAULT_AGENT_UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
|
|
|
17
20
|
var MAX_AGENT_TARBALL_BYTES = 32 * 1024 * 1024;
|
|
18
21
|
var VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
19
22
|
var REGISTRY = "registry.npmjs.org";
|
|
23
|
+
var syncPath = (path) => {
|
|
24
|
+
const descriptor = openSync(path, "r");
|
|
25
|
+
try {
|
|
26
|
+
fsyncSync(descriptor);
|
|
27
|
+
} finally {
|
|
28
|
+
closeSync(descriptor);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
var syncReleaseDirectory = (directory) => {
|
|
32
|
+
for (const path of [
|
|
33
|
+
join(directory, "package.json"),
|
|
34
|
+
join(directory, "dist", "fz-agent.js"),
|
|
35
|
+
join(directory, "dist", "fz.js"),
|
|
36
|
+
join(directory, "dist"),
|
|
37
|
+
directory,
|
|
38
|
+
dirname(directory)
|
|
39
|
+
])
|
|
40
|
+
syncPath(path);
|
|
41
|
+
};
|
|
20
42
|
function validateAgentRelease(release) {
|
|
21
43
|
if (release?.package !== "@forgezero/agent")
|
|
22
44
|
throw new Error("agent update package is fixed");
|
|
@@ -135,16 +157,24 @@ async function stageAgentRelease(releaseInput, options) {
|
|
|
135
157
|
]
|
|
136
158
|
}, "agent update extraction");
|
|
137
159
|
await validateReleaseDirectory(unpacked, release, run);
|
|
138
|
-
if (!existsSync(finalDirectory))
|
|
160
|
+
if (!existsSync(finalDirectory)) {
|
|
139
161
|
renameSync(unpacked, finalDirectory);
|
|
140
|
-
|
|
162
|
+
syncReleaseDirectory(finalDirectory);
|
|
163
|
+
} else
|
|
141
164
|
await validateReleaseDirectory(finalDirectory, release, run);
|
|
142
165
|
if (!existsSync(currentLink)) {
|
|
143
166
|
throw new Error("agent update requires an active immutable release to roll back to");
|
|
144
167
|
}
|
|
145
168
|
const previousTarget = readlinkSync(currentLink);
|
|
169
|
+
if (previousTarget !== join("versions", options.currentVersion)) {
|
|
170
|
+
throw new Error("agent update current release does not match the running version");
|
|
171
|
+
}
|
|
172
|
+
if (!existsSync(join(root, previousTarget))) {
|
|
173
|
+
throw new Error("agent update rollback release is missing");
|
|
174
|
+
}
|
|
146
175
|
return {
|
|
147
176
|
version: release.version,
|
|
177
|
+
fromVersion: options.currentVersion,
|
|
148
178
|
directory: finalDirectory,
|
|
149
179
|
previousTarget,
|
|
150
180
|
nextTarget: join("versions", release.version),
|
|
@@ -159,6 +189,7 @@ function selectAgentRelease(staged) {
|
|
|
159
189
|
try {
|
|
160
190
|
symlinkSync(staged.nextTarget, next);
|
|
161
191
|
renameSync(next, staged.currentLink);
|
|
192
|
+
syncPath(dirname(staged.currentLink));
|
|
162
193
|
} finally {
|
|
163
194
|
rmSync(next, { force: true });
|
|
164
195
|
}
|
|
@@ -168,25 +199,189 @@ function restoreAgentRelease(staged) {
|
|
|
168
199
|
try {
|
|
169
200
|
symlinkSync(staged.previousTarget, next);
|
|
170
201
|
renameSync(next, staged.currentLink);
|
|
202
|
+
syncPath(dirname(staged.currentLink));
|
|
171
203
|
} finally {
|
|
172
204
|
rmSync(next, { force: true });
|
|
173
205
|
}
|
|
174
206
|
}
|
|
175
207
|
|
|
176
208
|
// src/agent-update-helper.ts
|
|
177
|
-
import {
|
|
209
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
210
|
+
import {
|
|
211
|
+
chmodSync as chmodSync2,
|
|
212
|
+
closeSync as closeSync2,
|
|
213
|
+
existsSync as existsSync2,
|
|
214
|
+
fsyncSync as fsyncSync2,
|
|
215
|
+
mkdirSync as mkdirSync2,
|
|
216
|
+
openSync as openSync2,
|
|
217
|
+
readFileSync as readFileSync2,
|
|
218
|
+
renameSync as renameSync2,
|
|
219
|
+
rmSync as rmSync2,
|
|
220
|
+
unlinkSync,
|
|
221
|
+
writeFileSync as writeFileSync2
|
|
222
|
+
} from "node:fs";
|
|
178
223
|
import { connect, createServer } from "node:net";
|
|
179
|
-
import { dirname as dirname2 } from "node:path";
|
|
224
|
+
import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
|
|
180
225
|
import { DEFAULT_SOCKET } from "@forgezero/vault";
|
|
181
226
|
var AGENT_UPDATE_GROUP = "forgezero-update";
|
|
182
227
|
var AGENT_UPDATE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-agent-update-helper.service";
|
|
183
|
-
var
|
|
228
|
+
var AGENT_UPDATE_JOURNAL = "/var/lib/forgezero/agent-update.json";
|
|
229
|
+
var AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update-receipt.json";
|
|
184
230
|
var MAX_REQUEST_BYTES = 8 * 1024;
|
|
185
231
|
var COMPUTE_HELPER_UNITS = [
|
|
232
|
+
"forgezero-agent-egress.service",
|
|
186
233
|
"forgezero-deploy-runner.service",
|
|
187
234
|
"forgezero-lifecycle-helper.service",
|
|
188
235
|
"forgezero-software-helper.service"
|
|
189
236
|
];
|
|
237
|
+
var VERSION2 = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
238
|
+
var ATTEMPT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
239
|
+
var REASON_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
240
|
+
var MAX_REASON_BYTES = 512;
|
|
241
|
+
var UPDATE_RETRY_BASE_MS = 5 * 60000;
|
|
242
|
+
var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
|
|
243
|
+
var boundedMessage = (value) => {
|
|
244
|
+
let message = value.replace(/[\r\n]+/g, " ").trim();
|
|
245
|
+
while (Buffer.byteLength(message, "utf8") > MAX_REASON_BYTES)
|
|
246
|
+
message = message.slice(0, -1);
|
|
247
|
+
return message;
|
|
248
|
+
};
|
|
249
|
+
var reason = (code, message) => ({
|
|
250
|
+
code: REASON_CODE.test(code) ? code : "UPDATE_FAILED",
|
|
251
|
+
message: boundedMessage(message) || "Agent update failed"
|
|
252
|
+
});
|
|
253
|
+
var validTime = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
254
|
+
function validateReceipt(value) {
|
|
255
|
+
if (!value || typeof value !== "object")
|
|
256
|
+
throw new Error("Agent update receipt is malformed");
|
|
257
|
+
const receipt = value;
|
|
258
|
+
if (!receipt.attemptId || !ATTEMPT_ID.test(receipt.attemptId))
|
|
259
|
+
throw new Error("Agent update attempt ID is invalid");
|
|
260
|
+
if (!receipt.fromVersion || !VERSION2.test(receipt.fromVersion))
|
|
261
|
+
throw new Error("Agent update source version is invalid");
|
|
262
|
+
if (!receipt.targetVersion || !VERSION2.test(receipt.targetVersion))
|
|
263
|
+
throw new Error("Agent update target version is invalid");
|
|
264
|
+
if (!["activating", "active", "rolled-back", "failed"].includes(receipt.outcome ?? "")) {
|
|
265
|
+
throw new Error("Agent update outcome is invalid");
|
|
266
|
+
}
|
|
267
|
+
if (!validTime(receipt.startedAtTs) || !validTime(receipt.updatedAtTs)) {
|
|
268
|
+
throw new Error("Agent update timestamps are invalid");
|
|
269
|
+
}
|
|
270
|
+
if (receipt.retryAfterTs !== undefined && !validTime(receipt.retryAfterTs)) {
|
|
271
|
+
throw new Error("Agent update retry timestamp is invalid");
|
|
272
|
+
}
|
|
273
|
+
if (receipt.rollbackHealthy !== undefined && typeof receipt.rollbackHealthy !== "boolean") {
|
|
274
|
+
throw new Error("Agent update rollback health is invalid");
|
|
275
|
+
}
|
|
276
|
+
if (receipt.reason && (!REASON_CODE.test(receipt.reason.code) || typeof receipt.reason.message !== "string" || Buffer.byteLength(receipt.reason.message, "utf8") > MAX_REASON_BYTES))
|
|
277
|
+
throw new Error("Agent update failure reason is invalid");
|
|
278
|
+
return {
|
|
279
|
+
attemptId: receipt.attemptId,
|
|
280
|
+
fromVersion: receipt.fromVersion,
|
|
281
|
+
targetVersion: receipt.targetVersion,
|
|
282
|
+
outcome: receipt.outcome,
|
|
283
|
+
startedAtTs: receipt.startedAtTs,
|
|
284
|
+
updatedAtTs: receipt.updatedAtTs,
|
|
285
|
+
...receipt.retryAfterTs === undefined ? {} : { retryAfterTs: receipt.retryAfterTs },
|
|
286
|
+
...receipt.rollbackHealthy === undefined ? {} : { rollbackHealthy: receipt.rollbackHealthy },
|
|
287
|
+
...receipt.reason === undefined ? {} : { reason: receipt.reason }
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function validateJournal(value, root) {
|
|
291
|
+
if (!value || typeof value !== "object")
|
|
292
|
+
throw new Error("Agent update journal is malformed");
|
|
293
|
+
const legacy = value;
|
|
294
|
+
if (legacy.schemaVersion === undefined) {
|
|
295
|
+
if (typeof legacy.version === "string" && VERSION2.test(legacy.version) && legacy.outcome === "active" && validTime(legacy.updatedAtTs) && Object.keys(value).every((key) => ["version", "outcome", "updatedAtTs"].includes(key)))
|
|
296
|
+
return;
|
|
297
|
+
throw new Error("Agent update legacy receipt is malformed");
|
|
298
|
+
}
|
|
299
|
+
const journal = value;
|
|
300
|
+
const receipt = validateReceipt(journal);
|
|
301
|
+
if (journal.schemaVersion !== 1)
|
|
302
|
+
throw new Error("Agent update journal schema is unsupported");
|
|
303
|
+
if (journal.target !== "compute" && journal.target !== "metal")
|
|
304
|
+
throw new Error("Agent update target is invalid");
|
|
305
|
+
if (!Number.isSafeInteger(journal.failureCount) || (journal.failureCount ?? -1) < 0) {
|
|
306
|
+
throw new Error("Agent update failure count is invalid");
|
|
307
|
+
}
|
|
308
|
+
const releaseRoot = resolve2(root);
|
|
309
|
+
if (journal.currentLink !== join2(releaseRoot, "current"))
|
|
310
|
+
throw new Error("Agent update current link is invalid");
|
|
311
|
+
if (journal.previousTarget !== join2("versions", receipt.fromVersion)) {
|
|
312
|
+
throw new Error("Agent update rollback target is invalid");
|
|
313
|
+
}
|
|
314
|
+
if (journal.nextTarget !== join2("versions", receipt.targetVersion)) {
|
|
315
|
+
throw new Error("Agent update next target is invalid");
|
|
316
|
+
}
|
|
317
|
+
return journal;
|
|
318
|
+
}
|
|
319
|
+
function readJournal(path, root) {
|
|
320
|
+
if (!existsSync2(path))
|
|
321
|
+
return;
|
|
322
|
+
return validateJournal(JSON.parse(readFileSync2(path, "utf8")), root);
|
|
323
|
+
}
|
|
324
|
+
function writeAtomic(path, value, mode) {
|
|
325
|
+
mkdirSync2(dirname2(path), { recursive: true, mode: 493 });
|
|
326
|
+
const next = `${path}.${randomUUID2()}.next`;
|
|
327
|
+
let file;
|
|
328
|
+
try {
|
|
329
|
+
file = openSync2(next, "wx", mode);
|
|
330
|
+
writeFileSync2(file, `${JSON.stringify(value)}
|
|
331
|
+
`);
|
|
332
|
+
fsyncSync2(file);
|
|
333
|
+
closeSync2(file);
|
|
334
|
+
file = undefined;
|
|
335
|
+
renameSync2(next, path);
|
|
336
|
+
const directory = openSync2(dirname2(path), "r");
|
|
337
|
+
try {
|
|
338
|
+
fsyncSync2(directory);
|
|
339
|
+
} finally {
|
|
340
|
+
closeSync2(directory);
|
|
341
|
+
}
|
|
342
|
+
} finally {
|
|
343
|
+
if (file !== undefined)
|
|
344
|
+
closeSync2(file);
|
|
345
|
+
rmSync2(next, { force: true });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
var publicReceipt = (journal) => {
|
|
349
|
+
const {
|
|
350
|
+
attemptId,
|
|
351
|
+
fromVersion,
|
|
352
|
+
targetVersion,
|
|
353
|
+
outcome,
|
|
354
|
+
startedAtTs,
|
|
355
|
+
updatedAtTs,
|
|
356
|
+
retryAfterTs,
|
|
357
|
+
rollbackHealthy,
|
|
358
|
+
reason: failureReason
|
|
359
|
+
} = journal;
|
|
360
|
+
return {
|
|
361
|
+
attemptId,
|
|
362
|
+
fromVersion,
|
|
363
|
+
targetVersion,
|
|
364
|
+
outcome,
|
|
365
|
+
startedAtTs,
|
|
366
|
+
updatedAtTs,
|
|
367
|
+
...retryAfterTs === undefined ? {} : { retryAfterTs },
|
|
368
|
+
...rollbackHealthy === undefined ? {} : { rollbackHealthy },
|
|
369
|
+
...failureReason === undefined ? {} : { reason: failureReason }
|
|
370
|
+
};
|
|
371
|
+
};
|
|
372
|
+
function writeUpdateState(journalPath, receiptPath, journal) {
|
|
373
|
+
writeAtomic(journalPath, journal, 384);
|
|
374
|
+
writeAtomic(receiptPath, publicReceipt(journal), 416);
|
|
375
|
+
}
|
|
376
|
+
function readAgentUpdateReceipt(path = AGENT_UPDATE_RECEIPT) {
|
|
377
|
+
try {
|
|
378
|
+
if (!existsSync2(path))
|
|
379
|
+
return;
|
|
380
|
+
return validateReceipt(JSON.parse(readFileSync2(path, "utf8")));
|
|
381
|
+
} catch {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
190
385
|
var runCommand = async (input) => {
|
|
191
386
|
const child = Bun.spawn([input.command, ...input.args], {
|
|
192
387
|
cwd: input.cwd,
|
|
@@ -202,8 +397,27 @@ var runCommand = async (input) => {
|
|
|
202
397
|
return { exitCode, output: `${stdout}${stderr}` };
|
|
203
398
|
};
|
|
204
399
|
var runOk = async (run, command2, args) => (await run({ command: command2, args })).exitCode === 0;
|
|
400
|
+
var retryAfter = (now, failures) => now + Math.min(UPDATE_RETRY_MAX_MS, UPDATE_RETRY_BASE_MS * 2 ** Math.min(16, Math.max(0, failures - 1)));
|
|
401
|
+
var stagedFromJournal = (journal, root) => ({
|
|
402
|
+
version: journal.targetVersion,
|
|
403
|
+
fromVersion: journal.fromVersion,
|
|
404
|
+
directory: join2(resolve2(root), journal.nextTarget),
|
|
405
|
+
previousTarget: journal.previousTarget,
|
|
406
|
+
nextTarget: journal.nextTarget,
|
|
407
|
+
currentLink: journal.currentLink
|
|
408
|
+
});
|
|
409
|
+
var restartAgent = async (target, run) => {
|
|
410
|
+
const helpers = target === "compute" ? COMPUTE_HELPER_UNITS : ["forgezero-metal-helper.service"];
|
|
411
|
+
for (const unit of helpers)
|
|
412
|
+
await run({ command: "/usr/bin/systemctl", args: ["try-restart", unit] });
|
|
413
|
+
const service = target === "compute" ? "forgezero-agent.service" : "forgezero-metal-agent.service";
|
|
414
|
+
if (!await runOk(run, "/usr/bin/systemctl", ["restart", service])) {
|
|
415
|
+
throw new Error(`systemd could not restart ${service}`);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
var targetProbe = (target, run) => target === "compute" ? () => probeAgentSocket() : async () => await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-agent.service"]) && await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-helper.service"]);
|
|
205
419
|
function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
|
|
206
|
-
return new Promise((
|
|
420
|
+
return new Promise((resolve3) => {
|
|
207
421
|
const socket = connect(socketPath);
|
|
208
422
|
let settled = false;
|
|
209
423
|
let buffer = "";
|
|
@@ -213,7 +427,7 @@ function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
|
|
|
213
427
|
settled = true;
|
|
214
428
|
clearTimeout(timer);
|
|
215
429
|
socket.destroy();
|
|
216
|
-
|
|
430
|
+
resolve3(value);
|
|
217
431
|
};
|
|
218
432
|
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
219
433
|
socket.on("connect", () => socket.write(`{"op":"identity"}
|
|
@@ -237,55 +451,136 @@ function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
|
|
|
237
451
|
async function activateAgentRelease(staged, options = {}) {
|
|
238
452
|
const run = options.run ?? runCommand;
|
|
239
453
|
const target = options.target ?? "compute";
|
|
240
|
-
const probe = options.probe ?? (target
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
454
|
+
const probe = options.probe ?? targetProbe(target, run);
|
|
455
|
+
const now = options.now ?? Date.now;
|
|
456
|
+
const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
|
|
457
|
+
const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
|
|
458
|
+
const previous = readJournal(journalPath, dirname2(staged.currentLink));
|
|
459
|
+
const attemptId = options.attemptId ?? randomUUID2();
|
|
460
|
+
if (!ATTEMPT_ID.test(attemptId))
|
|
461
|
+
throw new Error("Agent update attempt ID is invalid");
|
|
462
|
+
const startedAtTs = now();
|
|
463
|
+
const failureCount = previous?.targetVersion === staged.version ? previous.failureCount : 0;
|
|
464
|
+
let journal = {
|
|
465
|
+
schemaVersion: 1,
|
|
466
|
+
attemptId,
|
|
467
|
+
target,
|
|
468
|
+
fromVersion: staged.fromVersion,
|
|
469
|
+
targetVersion: staged.version,
|
|
470
|
+
outcome: "activating",
|
|
471
|
+
startedAtTs,
|
|
472
|
+
updatedAtTs: startedAtTs,
|
|
473
|
+
currentLink: staged.currentLink,
|
|
474
|
+
previousTarget: staged.previousTarget,
|
|
475
|
+
nextTarget: staged.nextTarget,
|
|
476
|
+
failureCount
|
|
250
477
|
};
|
|
251
|
-
|
|
478
|
+
writeUpdateState(journalPath, receiptPath, journal);
|
|
479
|
+
let selectionAttempted = false;
|
|
252
480
|
try {
|
|
481
|
+
selectionAttempted = true;
|
|
253
482
|
selectAgentRelease(staged);
|
|
254
|
-
|
|
255
|
-
await restart();
|
|
483
|
+
await restartAgent(target, run);
|
|
256
484
|
if (!await probe())
|
|
257
485
|
throw new Error("the replacement Agent did not answer its retained Vault socket");
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const next = `${receipt}.next`;
|
|
261
|
-
writeFileSync2(next, JSON.stringify({
|
|
262
|
-
version: staged.version,
|
|
263
|
-
outcome: "active",
|
|
264
|
-
updatedAtTs: (options.now ?? Date.now)()
|
|
265
|
-
}) + `
|
|
266
|
-
`, { mode: 420 });
|
|
267
|
-
renameSync2(next, receipt);
|
|
486
|
+
journal = { ...journal, outcome: "active", updatedAtTs: now(), rollbackHealthy: undefined };
|
|
487
|
+
writeUpdateState(journalPath, receiptPath, journal);
|
|
268
488
|
run({
|
|
269
489
|
command: "/usr/bin/systemctl",
|
|
270
490
|
args: ["try-restart", "--no-block", "forgezero-agent-update-helper.service"]
|
|
271
491
|
});
|
|
272
492
|
return { ok: true, version: staged.version };
|
|
273
493
|
} catch (cause) {
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
494
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
495
|
+
let rollbackHealthy = false;
|
|
496
|
+
let restored = false;
|
|
497
|
+
if (selectionAttempted) {
|
|
498
|
+
try {
|
|
499
|
+
restoreAgentRelease(staged);
|
|
500
|
+
restored = true;
|
|
501
|
+
await restartAgent(target, run);
|
|
502
|
+
rollbackHealthy = await probe();
|
|
503
|
+
} catch {
|
|
504
|
+
rollbackHealthy = false;
|
|
505
|
+
}
|
|
278
506
|
}
|
|
279
|
-
|
|
507
|
+
const failures = failureCount + 1;
|
|
508
|
+
const updatedAtTs = now();
|
|
509
|
+
journal = {
|
|
510
|
+
...journal,
|
|
511
|
+
outcome: rollbackHealthy ? "rolled-back" : "failed",
|
|
512
|
+
updatedAtTs,
|
|
513
|
+
retryAfterTs: retryAfter(updatedAtTs, failures),
|
|
514
|
+
rollbackHealthy,
|
|
515
|
+
reason: reason(rollbackHealthy ? "REPLACEMENT_UNHEALTHY" : "ROLLBACK_UNHEALTHY", rollbackHealthy ? message : `${message}; the restored Agent did not pass its health probe`),
|
|
516
|
+
failureCount: failures
|
|
517
|
+
};
|
|
518
|
+
writeUpdateState(journalPath, receiptPath, journal);
|
|
519
|
+
return { ok: false, rolledBack: restored, rollbackHealthy, reason: message };
|
|
280
520
|
}
|
|
281
521
|
}
|
|
522
|
+
async function recoverInterruptedAgentUpdate(options = {}) {
|
|
523
|
+
const root = resolve2(options.root ?? DEFAULT_AGENT_RELEASE_ROOT);
|
|
524
|
+
const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
|
|
525
|
+
const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
|
|
526
|
+
const journal = readJournal(journalPath, root);
|
|
527
|
+
if (!journal)
|
|
528
|
+
return;
|
|
529
|
+
if (journal.outcome !== "activating") {
|
|
530
|
+
writeAtomic(receiptPath, publicReceipt(journal), 416);
|
|
531
|
+
return publicReceipt(journal);
|
|
532
|
+
}
|
|
533
|
+
const staged = stagedFromJournal(journal, root);
|
|
534
|
+
if (!existsSync2(join2(root, journal.previousTarget))) {
|
|
535
|
+
throw new Error("Agent update rollback release is missing");
|
|
536
|
+
}
|
|
537
|
+
const run = options.run ?? runCommand;
|
|
538
|
+
const probe = options.probe ?? targetProbe(journal.target, run);
|
|
539
|
+
restoreAgentRelease(staged);
|
|
540
|
+
let rollbackHealthy = false;
|
|
541
|
+
let failureMessage = "activation was interrupted before its health verdict became durable";
|
|
542
|
+
try {
|
|
543
|
+
await restartAgent(journal.target, run);
|
|
544
|
+
rollbackHealthy = await probe();
|
|
545
|
+
} catch (cause) {
|
|
546
|
+
failureMessage = `${failureMessage}; ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
547
|
+
}
|
|
548
|
+
const failures = journal.failureCount + 1;
|
|
549
|
+
const updatedAtTs = (options.now ?? Date.now)();
|
|
550
|
+
const recovered = {
|
|
551
|
+
...journal,
|
|
552
|
+
outcome: rollbackHealthy ? "rolled-back" : "failed",
|
|
553
|
+
updatedAtTs,
|
|
554
|
+
retryAfterTs: retryAfter(updatedAtTs, failures),
|
|
555
|
+
rollbackHealthy,
|
|
556
|
+
reason: reason(rollbackHealthy ? "ACTIVATION_INTERRUPTED" : "ROLLBACK_UNHEALTHY", failureMessage),
|
|
557
|
+
failureCount: failures
|
|
558
|
+
};
|
|
559
|
+
writeUpdateState(journalPath, receiptPath, recovered);
|
|
560
|
+
return readAgentUpdateReceipt(receiptPath);
|
|
561
|
+
}
|
|
282
562
|
function startAgentUpdateHelper(options = {}) {
|
|
283
563
|
const socketPath = options.socketPath ?? DEFAULT_AGENT_UPDATE_SOCKET;
|
|
284
564
|
if (existsSync2(socketPath))
|
|
285
565
|
unlinkSync(socketPath);
|
|
286
566
|
mkdirSync2(dirname2(socketPath), { recursive: true, mode: 488 });
|
|
287
567
|
const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
|
|
288
|
-
const
|
|
568
|
+
const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
|
|
569
|
+
const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
|
|
570
|
+
const releaseRoot = options.root ?? DEFAULT_AGENT_RELEASE_ROOT;
|
|
571
|
+
const activate = options.activate ?? ((staged, target, attemptId) => activateAgentRelease(staged, { target, attemptId, journalPath, receiptPath, now: options.now }));
|
|
572
|
+
let busy = true;
|
|
573
|
+
let blocked;
|
|
574
|
+
(options.recover ?? (() => recoverInterruptedAgentUpdate({
|
|
575
|
+
root: releaseRoot,
|
|
576
|
+
journalPath,
|
|
577
|
+
receiptPath,
|
|
578
|
+
now: options.now
|
|
579
|
+
})))().catch((cause) => {
|
|
580
|
+
blocked = cause instanceof Error ? cause.message : String(cause);
|
|
581
|
+
}).finally(() => {
|
|
582
|
+
busy = false;
|
|
583
|
+
});
|
|
289
584
|
const server = createServer((socket) => {
|
|
290
585
|
let buffer = "";
|
|
291
586
|
socket.on("data", (chunk) => {
|
|
@@ -301,22 +596,42 @@ function startAgentUpdateHelper(options = {}) {
|
|
|
301
596
|
return;
|
|
302
597
|
const line = buffer.slice(0, newline);
|
|
303
598
|
buffer = "";
|
|
599
|
+
let ownsBusy = false;
|
|
304
600
|
Promise.resolve().then(() => JSON.parse(line)).then(async (request) => {
|
|
601
|
+
if (blocked)
|
|
602
|
+
throw new Error(`update journal needs operator recovery: ${blocked}`);
|
|
603
|
+
if (busy)
|
|
604
|
+
throw new Error("another Agent update or recovery is already active");
|
|
305
605
|
if (request.op !== "apply")
|
|
306
606
|
throw new Error("unknown update operation");
|
|
307
607
|
if (request.target !== "compute" && request.target !== "metal") {
|
|
308
608
|
throw new Error("agent update target is invalid");
|
|
309
609
|
}
|
|
610
|
+
const attemptId = request.attemptId ?? randomUUID2();
|
|
611
|
+
if (!ATTEMPT_ID.test(attemptId))
|
|
612
|
+
throw new Error("Agent update attempt ID is invalid");
|
|
613
|
+
const prior = readJournal(journalPath, releaseRoot);
|
|
614
|
+
const now = (options.now ?? Date.now)();
|
|
615
|
+
if (prior?.targetVersion === request.release.version && (prior.outcome === "rolled-back" || prior.outcome === "failed") && (prior.retryAfterTs ?? 0) > now)
|
|
616
|
+
throw new Error(`Agent update ${request.release.version} is quarantined until ${prior.retryAfterTs}`);
|
|
617
|
+
busy = true;
|
|
618
|
+
ownsBusy = true;
|
|
310
619
|
const staged = await stageAgentRelease(request.release, {
|
|
311
620
|
currentVersion: request.currentVersion,
|
|
312
|
-
root:
|
|
621
|
+
root: releaseRoot
|
|
313
622
|
});
|
|
314
|
-
const response = { ok: true, status: "staged", version: staged.version };
|
|
623
|
+
const response = { ok: true, status: "staged", version: staged.version, attemptId };
|
|
315
624
|
socket.end(`${JSON.stringify(response)}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
625
|
+
`);
|
|
626
|
+
setTimer(() => void activate(staged, request.target, attemptId).catch((cause) => {
|
|
627
|
+
blocked = cause instanceof Error ? cause.message : String(cause);
|
|
628
|
+
}).finally(() => {
|
|
629
|
+
busy = false;
|
|
630
|
+
}), 100);
|
|
631
|
+
ownsBusy = false;
|
|
319
632
|
}).catch((cause) => {
|
|
633
|
+
if (ownsBusy)
|
|
634
|
+
busy = false;
|
|
320
635
|
const response = {
|
|
321
636
|
ok: false,
|
|
322
637
|
error: { code: "UPDATE_REFUSED", message: cause instanceof Error ? cause.message : String(cause) }
|
|
@@ -331,7 +646,7 @@ function startAgentUpdateHelper(options = {}) {
|
|
|
331
646
|
return server;
|
|
332
647
|
}
|
|
333
648
|
function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, timeoutMs = 90000) {
|
|
334
|
-
return new Promise((
|
|
649
|
+
return new Promise((resolve3, reject) => {
|
|
335
650
|
const socket = connect(socketPath, () => socket.write(`${JSON.stringify(request)}
|
|
336
651
|
`));
|
|
337
652
|
let buffer = "";
|
|
@@ -347,7 +662,7 @@ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, t
|
|
|
347
662
|
return;
|
|
348
663
|
socket.end();
|
|
349
664
|
try {
|
|
350
|
-
|
|
665
|
+
resolve3(JSON.parse(buffer.slice(0, newline)));
|
|
351
666
|
} catch (cause) {
|
|
352
667
|
reject(cause);
|
|
353
668
|
}
|
|
@@ -357,7 +672,7 @@ function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, t
|
|
|
357
672
|
}
|
|
358
673
|
|
|
359
674
|
// src/software.ts
|
|
360
|
-
import { readFileSync as
|
|
675
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
361
676
|
var BUN_INSTALLER_SHA256 = "bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd";
|
|
362
677
|
var ARANGO_SHA256 = "b5a9197b4343f2ed554e1ebc1ef8e6529c7c39cde0035cdc311a4747a3355066";
|
|
363
678
|
var CLOUDFLARED_SHA256 = "9d71c677db00134c1bd4144b7783486b654ad281b1ea62b4972098d19f770f17";
|
|
@@ -398,7 +713,7 @@ var UBUNTU_2604_X64 = [
|
|
|
398
713
|
install: "DEBIAN_FRONTEND=noninteractive apt-get update -qq && apt-get install -y ufw"
|
|
399
714
|
}
|
|
400
715
|
];
|
|
401
|
-
function observeSoftwareHost(osRelease =
|
|
716
|
+
function observeSoftwareHost(osRelease = readFileSync3("/etc/os-release", "utf8"), architecture = process.arch) {
|
|
402
717
|
const values = Object.fromEntries(osRelease.split(`
|
|
403
718
|
`).flatMap((line) => {
|
|
404
719
|
const separator = line.indexOf("=");
|
|
@@ -540,7 +855,7 @@ function startSoftwareHelper(options = {}) {
|
|
|
540
855
|
}
|
|
541
856
|
function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCKET, timeoutMs = 15 * 60000) {
|
|
542
857
|
validateSoftwareRequirements(requirements);
|
|
543
|
-
return new Promise((
|
|
858
|
+
return new Promise((resolve3, reject) => {
|
|
544
859
|
const socket = connect2(socketPath, () => socket.write(`${JSON.stringify({ op: "ensure", requirements })}
|
|
545
860
|
`));
|
|
546
861
|
let buffer = "";
|
|
@@ -559,7 +874,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
|
|
|
559
874
|
const response = JSON.parse(buffer.slice(0, newline));
|
|
560
875
|
if (!response.ok || !response.results)
|
|
561
876
|
throw new Error(response.error?.message ?? "software helper refused the request");
|
|
562
|
-
|
|
877
|
+
resolve3(response.results);
|
|
563
878
|
} catch (cause) {
|
|
564
879
|
reject(cause);
|
|
565
880
|
}
|
|
@@ -569,9 +884,71 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
|
|
|
569
884
|
}
|
|
570
885
|
|
|
571
886
|
// src/version.ts
|
|
572
|
-
var
|
|
887
|
+
var VERSION3 = "0.1.33";
|
|
888
|
+
|
|
889
|
+
// src/egress-policy.ts
|
|
890
|
+
import { realpathSync } from "node:fs";
|
|
891
|
+
var AGENT_EGRESS_TABLE = "forgezero_agent_egress";
|
|
892
|
+
var SYSTEMD_RESOLVED_ADDRESS = "127.0.0.53";
|
|
893
|
+
var BLOCKED_IPV4 = [
|
|
894
|
+
"0.0.0.0/8",
|
|
895
|
+
"10.0.0.0/8",
|
|
896
|
+
"100.64.0.0/10",
|
|
897
|
+
"127.0.0.0/8",
|
|
898
|
+
"168.63.129.16/32",
|
|
899
|
+
"169.254.0.0/16",
|
|
900
|
+
"172.16.0.0/12",
|
|
901
|
+
"192.0.0.0/24",
|
|
902
|
+
"192.0.2.0/24",
|
|
903
|
+
"192.88.99.0/24",
|
|
904
|
+
"192.168.0.0/16",
|
|
905
|
+
"198.18.0.0/15",
|
|
906
|
+
"198.51.100.0/24",
|
|
907
|
+
"203.0.113.0/24",
|
|
908
|
+
"224.0.0.0/4",
|
|
909
|
+
"240.0.0.0/4"
|
|
910
|
+
];
|
|
911
|
+
var BLOCKED_IPV6 = [
|
|
912
|
+
"::/128",
|
|
913
|
+
"::1/128",
|
|
914
|
+
"::ffff:0:0/96",
|
|
915
|
+
"64:ff9b::/96",
|
|
916
|
+
"64:ff9b:1::/48",
|
|
917
|
+
"100::/64",
|
|
918
|
+
"fc00::/7",
|
|
919
|
+
"fec0::/10",
|
|
920
|
+
"fe80::/10",
|
|
921
|
+
"ff00::/8",
|
|
922
|
+
"2001::/32",
|
|
923
|
+
"2001:2::/48",
|
|
924
|
+
"2001:10::/28",
|
|
925
|
+
"2001:20::/28",
|
|
926
|
+
"2001:db8::/32",
|
|
927
|
+
"2002::/16",
|
|
928
|
+
"3fff::/20"
|
|
929
|
+
];
|
|
930
|
+
var normalizeEgressTcpPorts = (ports) => {
|
|
931
|
+
for (const port of ports) {
|
|
932
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
933
|
+
throw new Error("Agent egress policy refuses an invalid loopback TCP port.");
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return [...new Set(ports)].sort((left, right) => left - right);
|
|
937
|
+
};
|
|
938
|
+
function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
|
|
939
|
+
const ports = normalizeEgressTcpPorts(loopbackTcpPorts);
|
|
940
|
+
return [
|
|
941
|
+
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6",
|
|
942
|
+
`IPAddressAllow=${SYSTEMD_RESOLVED_ADDRESS}/32`,
|
|
943
|
+
...ports.length > 0 ? ["IPAddressAllow=127.0.0.1/32", "IPAddressAllow=::1/128"] : [],
|
|
944
|
+
...BLOCKED_IPV4.map((network) => `IPAddressDeny=${network}`),
|
|
945
|
+
...BLOCKED_IPV6.map((network) => `IPAddressDeny=${network}`)
|
|
946
|
+
].join(`
|
|
947
|
+
`);
|
|
948
|
+
}
|
|
573
949
|
|
|
574
950
|
// src/provision.ts
|
|
951
|
+
import { isIP } from "node:net";
|
|
575
952
|
function atLeast(version, floor) {
|
|
576
953
|
const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
|
|
577
954
|
const got = parse(version);
|
|
@@ -625,6 +1002,63 @@ var LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper
|
|
|
625
1002
|
var LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
|
|
626
1003
|
var WARP_CONFIG_UNIT_PATH = "/etc/systemd/system/forgezero-warp-config.service";
|
|
627
1004
|
var WARP_SERVICE_DROP_IN_PATH = "/etc/systemd/system/warp-svc.service.d/forgezero.conf";
|
|
1005
|
+
var AGENT_EGRESS_UNIT_PATH = "/etc/systemd/system/forgezero-agent-egress.service";
|
|
1006
|
+
var DEFAULT_RUNNER_PUBLIC_TCP_PORTS = [443];
|
|
1007
|
+
function agentEgressUnit(options) {
|
|
1008
|
+
const bin = options.binPath ?? "fz-agent";
|
|
1009
|
+
const user = options.user ?? "forgezero";
|
|
1010
|
+
if (!/^[a-z_][a-z0-9_-]{0,30}$/.test(user))
|
|
1011
|
+
throw new Error("invalid Agent service user");
|
|
1012
|
+
const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
|
|
1013
|
+
const runnerLoopbackPorts = normalizeEgressTcpPorts(options.runnerLoopbackPorts ?? []);
|
|
1014
|
+
const runnerPublicTcpPorts = normalizeEgressTcpPorts(options.runnerPublicTcpPorts ?? DEFAULT_RUNNER_PUBLIC_TCP_PORTS);
|
|
1015
|
+
if (deploymentEnabled && runnerPublicTcpPorts.length < 1) {
|
|
1016
|
+
throw new Error("deployed project runner needs at least one vetted public TCP port");
|
|
1017
|
+
}
|
|
1018
|
+
if (deploymentEnabled)
|
|
1019
|
+
systemdAgentEgressDirectives(runnerLoopbackPorts);
|
|
1020
|
+
const users = [user, ...deploymentEnabled ? [DEPLOYMENT_RUNNER_USER] : []];
|
|
1021
|
+
const runnerGrant = deploymentEnabled ? ` --loopback-user=${DEPLOYMENT_RUNNER_USER}` + runnerLoopbackPorts.map((port) => ` --loopback-tcp-port=${port}`).join("") + runnerPublicTcpPorts.map((port) => ` --public-tcp-port=${port}`).join("") : "";
|
|
1022
|
+
const policyProofs = deploymentEnabled ? [
|
|
1023
|
+
...runnerLoopbackPorts.length > 0 ? [`loopback=.*:${runnerLoopbackPorts.join(",")}`] : [],
|
|
1024
|
+
`public-tcp=${runnerPublicTcpPorts.join(",")}`
|
|
1025
|
+
].map((pattern) => `ExecStartPost=/bin/sh -c '/usr/sbin/nft --numeric list table inet ${AGENT_EGRESS_TABLE} | /usr/bin/grep -q "${pattern}"'`).join(`
|
|
1026
|
+
`) : "";
|
|
1027
|
+
return `[Unit]
|
|
1028
|
+
Description=ForgeZero Agent host egress policy
|
|
1029
|
+
Documentation=https://www.forgezero.net/docs/agent
|
|
1030
|
+
After=systemd-resolved.service nftables.service
|
|
1031
|
+
Requires=systemd-resolved.service
|
|
1032
|
+
Before=forgezero-agent-enrol.service forgezero-agent.service
|
|
1033
|
+
|
|
1034
|
+
[Service]
|
|
1035
|
+
Type=notify
|
|
1036
|
+
NotifyAccess=all
|
|
1037
|
+
User=root
|
|
1038
|
+
Group=root
|
|
1039
|
+
ExecStart=${bin} egress-policy ${users.map((name) => `--user=${name}`).join(" ")}${runnerGrant}
|
|
1040
|
+
${policyProofs}
|
|
1041
|
+
Restart=on-failure
|
|
1042
|
+
RestartSec=2
|
|
1043
|
+
LimitCORE=0
|
|
1044
|
+
NoNewPrivileges=true
|
|
1045
|
+
PrivateTmp=true
|
|
1046
|
+
ProtectSystem=strict
|
|
1047
|
+
ProtectHome=true
|
|
1048
|
+
ProtectKernelTunables=true
|
|
1049
|
+
ProtectKernelModules=true
|
|
1050
|
+
ProtectControlGroups=true
|
|
1051
|
+
RestrictSUIDSGID=true
|
|
1052
|
+
RestrictRealtime=true
|
|
1053
|
+
MemoryDenyWriteExecute=true
|
|
1054
|
+
LockPersonality=true
|
|
1055
|
+
CapabilityBoundingSet=CAP_NET_ADMIN
|
|
1056
|
+
RestrictAddressFamilies=AF_UNIX AF_NETLINK
|
|
1057
|
+
|
|
1058
|
+
[Install]
|
|
1059
|
+
WantedBy=multi-user.target
|
|
1060
|
+
`;
|
|
1061
|
+
}
|
|
628
1062
|
function softwareHelperUnit(options) {
|
|
629
1063
|
const bin = options.binPath ?? "fz-agent";
|
|
630
1064
|
return `[Unit]
|
|
@@ -869,11 +1303,16 @@ function agentEnrolmentUnit(options) {
|
|
|
869
1303
|
` : ""
|
|
870
1304
|
].join("");
|
|
871
1305
|
const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
|
|
1306
|
+
const egressDependency = options.enforceEgress ? `After=forgezero-agent-egress.service
|
|
1307
|
+
Requires=forgezero-agent-egress.service
|
|
1308
|
+
BindsTo=forgezero-agent-egress.service
|
|
1309
|
+
` : "";
|
|
1310
|
+
const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives() : "";
|
|
872
1311
|
return `[Unit]
|
|
873
1312
|
Description=Bind this machine to its ForgeZero compute
|
|
874
1313
|
After=network-online.target
|
|
875
1314
|
Wants=network-online.target
|
|
876
|
-
Before=forgezero-agent.service
|
|
1315
|
+
${egressDependency}Before=forgezero-agent.service
|
|
877
1316
|
ConditionPathExists=!${options.enrolStatePath}
|
|
878
1317
|
|
|
879
1318
|
[Service]
|
|
@@ -896,6 +1335,7 @@ ProtectSystem=strict
|
|
|
896
1335
|
ProtectHome=true
|
|
897
1336
|
ReadWritePaths=${stateDir}
|
|
898
1337
|
LimitCORE=0
|
|
1338
|
+
${egressDirectives}
|
|
899
1339
|
|
|
900
1340
|
[Install]
|
|
901
1341
|
WantedBy=multi-user.target
|
|
@@ -905,9 +1345,15 @@ function deploymentRunnerUnit(options) {
|
|
|
905
1345
|
const bin = options.binPath ?? "fz-agent";
|
|
906
1346
|
const root = options.deployRoot ?? "/opt/forgezero";
|
|
907
1347
|
const agentUser = options.user ?? "forgezero-agent";
|
|
1348
|
+
const egressDependency = options.enforceEgress ? `After=forgezero-agent-egress.service
|
|
1349
|
+
Requires=forgezero-agent-egress.service
|
|
1350
|
+
BindsTo=forgezero-agent-egress.service
|
|
1351
|
+
` : "";
|
|
1352
|
+
const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives(options.runnerLoopbackPorts ?? []) : "";
|
|
908
1353
|
return `[Unit]
|
|
909
1354
|
Description=ForgeZero credential-free project command runner
|
|
910
1355
|
Documentation=https://www.forgezero.net/docs/agent
|
|
1356
|
+
${egressDependency}
|
|
911
1357
|
|
|
912
1358
|
[Service]
|
|
913
1359
|
Type=notify
|
|
@@ -938,6 +1384,7 @@ ProtectControlGroups=true
|
|
|
938
1384
|
RestrictRealtime=true
|
|
939
1385
|
MemoryDenyWriteExecute=true
|
|
940
1386
|
LockPersonality=true
|
|
1387
|
+
${egressDirectives}
|
|
941
1388
|
ReadWritePaths=${root}/releases ${root}/runner-home
|
|
942
1389
|
|
|
943
1390
|
[Install]
|
|
@@ -947,6 +1394,21 @@ WantedBy=multi-user.target
|
|
|
947
1394
|
function agentUnit(options) {
|
|
948
1395
|
if (!validNodeHostname(options.nodeHostname))
|
|
949
1396
|
throw new Error("node hostname is invalid");
|
|
1397
|
+
if (!options.telemetryEndpoint) {
|
|
1398
|
+
throw new Error("compute Agent provisioning requires OTEL_EXPORTER_OTLP_ENDPOINT as an explicit public HTTPS collector coordinate");
|
|
1399
|
+
}
|
|
1400
|
+
let telemetryEndpoint;
|
|
1401
|
+
{
|
|
1402
|
+
let endpoint;
|
|
1403
|
+
try {
|
|
1404
|
+
endpoint = new URL(options.telemetryEndpoint);
|
|
1405
|
+
} catch {
|
|
1406
|
+
throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL");
|
|
1407
|
+
}
|
|
1408
|
+
if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || isIP(endpoint.hostname) !== 0 || !endpoint.hostname.includes(".") || endpoint.hostname === "localhost" || endpoint.hostname.endsWith(".local"))
|
|
1409
|
+
throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL without credentials, query or fragment");
|
|
1410
|
+
telemetryEndpoint = endpoint.toString().replace(/\/$/, "");
|
|
1411
|
+
}
|
|
950
1412
|
const bin = options.binPath ?? "fz-agent";
|
|
951
1413
|
const user = options.user ?? "forgezero";
|
|
952
1414
|
const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
|
|
@@ -995,6 +1457,7 @@ function agentUnit(options) {
|
|
|
995
1457
|
}
|
|
996
1458
|
}
|
|
997
1459
|
const environment = [
|
|
1460
|
+
"NODE_ENV=production",
|
|
998
1461
|
`FZ_SOCKET_PATH=${agentBackendSocketPath(options.socketPath)}`,
|
|
999
1462
|
`FZ_CONTROL_SOCKET=${controlSocketPath}`,
|
|
1000
1463
|
`FZ_SEED_CREDENTIAL=agent-seed`,
|
|
@@ -1005,6 +1468,8 @@ function agentUnit(options) {
|
|
|
1005
1468
|
options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
|
|
1006
1469
|
options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
|
|
1007
1470
|
options.nodeHostname ? `FZ_NODE_HOSTNAME=${options.nodeHostname}` : null,
|
|
1471
|
+
`OTEL_EXPORTER_OTLP_ENDPOINT=${telemetryEndpoint}`,
|
|
1472
|
+
"OTEL_SERVICE_NAME=forgezero-agent",
|
|
1008
1473
|
options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
|
|
1009
1474
|
options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
|
|
1010
1475
|
options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
|
|
@@ -1040,6 +1505,7 @@ function agentUnit(options) {
|
|
|
1040
1505
|
const after = [
|
|
1041
1506
|
"network-online.target",
|
|
1042
1507
|
"forgezero-agent-update-helper.service",
|
|
1508
|
+
options.enforceEgress ? "forgezero-agent-egress.service" : null,
|
|
1043
1509
|
deploymentEnabled ? "forgezero-deploy-runner.service" : null,
|
|
1044
1510
|
deploymentEnabled ? "forgezero-software-helper.service" : null,
|
|
1045
1511
|
lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
|
|
@@ -1048,6 +1514,7 @@ function agentUnit(options) {
|
|
|
1048
1514
|
].filter((value) => value !== null);
|
|
1049
1515
|
const requires = [
|
|
1050
1516
|
"forgezero-agent-update-helper.service",
|
|
1517
|
+
options.enforceEgress ? "forgezero-agent-egress.service" : null,
|
|
1051
1518
|
deploymentEnabled ? "forgezero-deploy-runner.service" : null,
|
|
1052
1519
|
deploymentEnabled ? "forgezero-software-helper.service" : null,
|
|
1053
1520
|
lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
|
|
@@ -1057,7 +1524,8 @@ function agentUnit(options) {
|
|
|
1057
1524
|
const deploymentDependency = [
|
|
1058
1525
|
`After=${after.join(" ")}`,
|
|
1059
1526
|
"Wants=network-online.target",
|
|
1060
|
-
requires.length > 0 ? `Requires=${requires.join(" ")}` : null
|
|
1527
|
+
requires.length > 0 ? `Requires=${requires.join(" ")}` : null,
|
|
1528
|
+
options.enforceEgress ? "BindsTo=forgezero-agent-egress.service" : null
|
|
1061
1529
|
].filter((value) => value !== null).join(`
|
|
1062
1530
|
`);
|
|
1063
1531
|
const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
|
|
@@ -1065,6 +1533,7 @@ DeviceAllow=/dev/sev-guest rw` : "";
|
|
|
1065
1533
|
const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
|
|
1066
1534
|
ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
|
|
1067
1535
|
` : "";
|
|
1536
|
+
const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives() : "";
|
|
1068
1537
|
return `[Unit]
|
|
1069
1538
|
Description=ForgeZero node agent (${options.mode})
|
|
1070
1539
|
Documentation=https://www.forgezero.net/docs/agent
|
|
@@ -1109,6 +1578,7 @@ RestrictSUIDSGID=true
|
|
|
1109
1578
|
RestrictRealtime=true
|
|
1110
1579
|
MemoryDenyWriteExecute=true
|
|
1111
1580
|
LockPersonality=true
|
|
1581
|
+
${egressDirectives}
|
|
1112
1582
|
${snpDevice}
|
|
1113
1583
|
${deploymentWrites}
|
|
1114
1584
|
|
|
@@ -1124,6 +1594,8 @@ function planProvision(options) {
|
|
|
1124
1594
|
const credentialDir = seedCredentialPath.replace(/\/[^/]+$/, "");
|
|
1125
1595
|
const deployRoot = options.deployRoot ?? "/opt/forgezero";
|
|
1126
1596
|
const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
|
|
1597
|
+
const runnerLoopbackPorts = normalizeEgressTcpPorts(options.runnerLoopbackPorts ?? []);
|
|
1598
|
+
const runnerPublicTcpPorts = normalizeEgressTcpPorts(options.runnerPublicTcpPorts ?? DEFAULT_RUNNER_PUBLIC_TCP_PORTS);
|
|
1127
1599
|
const lifecycleEnabled = Boolean(options.pullMigrations && options.lifecycleProfilePath);
|
|
1128
1600
|
if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
|
|
1129
1601
|
throw new Error("migration pull and lifecycle profile must be supplied together");
|
|
@@ -1164,6 +1636,9 @@ function planProvision(options) {
|
|
|
1164
1636
|
{ path: AGENT_SOCKET_UNIT_PATH, unit: agentSocketUnit(options) },
|
|
1165
1637
|
{ path: AGENT_SOCKET_PROXY_UNIT_PATH, unit: agentSocketProxyUnit(options) },
|
|
1166
1638
|
{ path: AGENT_UPDATE_HELPER_UNIT_PATH, unit: agentUpdateHelperUnit(options) },
|
|
1639
|
+
...options.enforceEgress ? [
|
|
1640
|
+
{ path: AGENT_EGRESS_UNIT_PATH, unit: agentEgressUnit(options) }
|
|
1641
|
+
] : [],
|
|
1167
1642
|
...deploymentEnabled ? [
|
|
1168
1643
|
{ path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) },
|
|
1169
1644
|
{ path: SOFTWARE_HELPER_UNIT_PATH, unit: softwareHelperUnit(options) }
|
|
@@ -1190,6 +1665,10 @@ function planProvision(options) {
|
|
|
1190
1665
|
socketPath: options.socketPath,
|
|
1191
1666
|
user,
|
|
1192
1667
|
steps: [
|
|
1668
|
+
...options.enforceEgress ? [{
|
|
1669
|
+
label: "Ubuntu Agent egress prerequisites",
|
|
1670
|
+
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`
|
|
1671
|
+
}] : [],
|
|
1193
1672
|
{
|
|
1194
1673
|
label: "vault socket access group",
|
|
1195
1674
|
command: `groupadd --system ${VAULT_GROUP} || true`
|
|
@@ -1200,7 +1679,7 @@ function planProvision(options) {
|
|
|
1200
1679
|
},
|
|
1201
1680
|
...sourceBinPath && binPath ? [{
|
|
1202
1681
|
label: "root-owned agent runtime",
|
|
1203
|
-
command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")} ` + `${DEFAULT_AGENT_RELEASE_ROOT}/versions/${
|
|
1682
|
+
command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")} ` + `${DEFAULT_AGENT_RELEASE_ROOT}/versions/${VERSION3}/dist; ` + `install -o root -g root -m 0755 ${sourceBinPath} ` + `${DEFAULT_AGENT_RELEASE_ROOT}/versions/${VERSION3}/dist/fz-agent.js; ` + `ln -sfn versions/${VERSION3} ${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}`
|
|
1204
1683
|
}] : [],
|
|
1205
1684
|
...warpEnabled ? [{
|
|
1206
1685
|
label: "Cloudflare One client for Ubuntu 26.04",
|
|
@@ -1280,6 +1759,7 @@ function planProvision(options) {
|
|
|
1280
1759
|
command: `systemctl enable ${[
|
|
1281
1760
|
"forgezero-agent.socket",
|
|
1282
1761
|
"forgezero-agent-update-helper.service",
|
|
1762
|
+
...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
|
|
1283
1763
|
...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
|
|
1284
1764
|
...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
|
|
1285
1765
|
...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
|
|
@@ -1287,6 +1767,7 @@ function planProvision(options) {
|
|
|
1287
1767
|
"forgezero-agent.service"
|
|
1288
1768
|
].join(" ")}; systemctl reset-failed forgezero-agent.service || true; systemctl restart ${[
|
|
1289
1769
|
"forgezero-agent-update-helper.service",
|
|
1770
|
+
...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
|
|
1290
1771
|
...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
|
|
1291
1772
|
...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
|
|
1292
1773
|
...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
|
|
@@ -1297,6 +1778,10 @@ function planProvision(options) {
|
|
|
1297
1778
|
label: "prove the compute binding is durable",
|
|
1298
1779
|
command: `test -s ${enrolStatePath}`
|
|
1299
1780
|
}] : [],
|
|
1781
|
+
...options.enforceEgress ? [{
|
|
1782
|
+
label: "prove the Agent egress policy is active",
|
|
1783
|
+
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(",")}( |")'` : "") : "")
|
|
1784
|
+
}] : [],
|
|
1300
1785
|
{ label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
|
|
1301
1786
|
{ label: "prove the public Vault socket exists", command: awaitSocketCommand(options.socketPath) },
|
|
1302
1787
|
{ label: "prove the Agent Vault backend exists", command: awaitSocketCommand(agentBackendSocketPath(options.socketPath)) },
|
|
@@ -1338,6 +1823,7 @@ export {
|
|
|
1338
1823
|
agentSocketUnit,
|
|
1339
1824
|
agentSocketProxyUnit,
|
|
1340
1825
|
agentEnrolmentUnit,
|
|
1826
|
+
agentEgressUnit,
|
|
1341
1827
|
agentBackendSocketPath,
|
|
1342
1828
|
WARP_SERVICE_DROP_IN_PATH,
|
|
1343
1829
|
WARP_CONFIG_UNIT_PATH,
|
|
@@ -1351,7 +1837,9 @@ export {
|
|
|
1351
1837
|
DEPLOYMENT_RUNNER_UNIT_PATH,
|
|
1352
1838
|
DEPLOYMENT_RUNNER_SOCKET,
|
|
1353
1839
|
DEPLOYMENT_GROUP,
|
|
1840
|
+
DEFAULT_RUNNER_PUBLIC_TCP_PORTS,
|
|
1354
1841
|
CAPABILITY_CHECKS,
|
|
1355
1842
|
AGENT_SOCKET_UNIT_PATH,
|
|
1356
|
-
AGENT_SOCKET_PROXY_UNIT_PATH
|
|
1843
|
+
AGENT_SOCKET_PROXY_UNIT_PATH,
|
|
1844
|
+
AGENT_EGRESS_UNIT_PATH
|
|
1357
1845
|
};
|