@forgezero/agent 0.1.10 → 0.1.12

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.
Files changed (48) hide show
  1. package/README.md +36 -9
  2. package/dist/deployment.d.ts +1 -1
  3. package/dist/fz-agent.js +525 -490
  4. package/dist/fz.js +34 -12
  5. package/dist/guest-enrolment.d.ts +2 -1
  6. package/dist/guest-enrolment.js +81 -23
  7. package/dist/index.d.ts +5 -29
  8. package/dist/metal-helper-socket.js +117 -24
  9. package/dist/metal-provision.d.ts +2 -2
  10. package/dist/metal-provision.js +117 -24
  11. package/dist/node-vault.d.ts +9 -0
  12. package/dist/node-vault.js +53 -17
  13. package/dist/provision.d.ts +1 -0
  14. package/dist/provision.js +14 -4
  15. package/dist/provisioning-pull.d.ts +24 -1
  16. package/dist/provisioning-pull.js +30 -11
  17. package/dist/signed-node-http.d.ts +1 -1
  18. package/dist/socket.d.ts +11 -4
  19. package/dist/ubuntu.d.ts +16 -0
  20. package/dist/ubuntu.js +18 -0
  21. package/dist/version.d.ts +2 -0
  22. package/package.json +11 -6
  23. package/dist/attestation-client.test.d.ts +0 -1
  24. package/dist/cache.test.d.ts +0 -1
  25. package/dist/cli/agent-install.test.d.ts +0 -1
  26. package/dist/cli/options.test.d.ts +0 -1
  27. package/dist/cli/run.test.d.ts +0 -1
  28. package/dist/compute.test.d.ts +0 -1
  29. package/dist/control.test.d.ts +0 -1
  30. package/dist/definition.test.d.ts +0 -1
  31. package/dist/deployment-pull.test.d.ts +0 -1
  32. package/dist/deployment-runner.test.d.ts +0 -1
  33. package/dist/deployment-watch.d.ts +0 -36
  34. package/dist/deployment-watch.test.d.ts +0 -1
  35. package/dist/deployment.test.d.ts +0 -1
  36. package/dist/guest-enrolment.test.d.ts +0 -1
  37. package/dist/index.test.d.ts +0 -1
  38. package/dist/metal-helper-socket.test.d.ts +0 -1
  39. package/dist/metal-isolation.test.d.ts +0 -1
  40. package/dist/metal-provision.test.d.ts +0 -1
  41. package/dist/node-vault.test.d.ts +0 -1
  42. package/dist/pipeline.test.d.ts +0 -1
  43. package/dist/provisioning-pull.test.d.ts +0 -1
  44. package/dist/snp-attestation.test.d.ts +0 -1
  45. package/dist/socket.test.d.ts +0 -1
  46. package/dist/ssh-listen.test.d.ts +0 -1
  47. package/dist/ssh-server.test.d.ts +0 -1
  48. package/dist/subscribe.test.d.ts +0 -1
package/dist/fz.js CHANGED
@@ -101,7 +101,7 @@ async function spawnWith(command, env, report = () => {}) {
101
101
  }
102
102
 
103
103
  // src/cli/index.ts
104
- import { readFileSync, unlinkSync, writeFileSync } from "fs";
104
+ import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
105
105
  import { fileURLToPath } from "url";
106
106
  import { DEFAULT_SOCKET } from "@forgezero/vault";
107
107
 
@@ -148,6 +148,7 @@ var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
148
148
  var reasonFor = (mode) => mode === "attested" ? "SEV-SNP guest device present, so the agent can prove what it is running and the platform can refuse it if the measurement is wrong." : "No SEV-SNP guest device. The agent authenticates with its enrolment token and hybrid Ed25519 + ML-DSA signature \u2014 weaker than attestation, stronger than an API key in the application.";
149
149
  var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
150
150
  var DEPLOYMENT_GROUP = "forgezero-deploy";
151
+ var VAULT_GROUP = "forgezero-vault";
151
152
  var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
152
153
  var DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.socket";
153
154
  var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
@@ -317,7 +318,7 @@ function agentUnit(options) {
317
318
  `);
318
319
  const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
319
320
  DeviceAllow=/dev/sev-guest rw` : "";
320
- const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${user} /dev/sev-guest
321
+ const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
321
322
  ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
322
323
  ` : "";
323
324
  return `[Unit]
@@ -328,7 +329,7 @@ ${deploymentDependency}
328
329
  [Service]
329
330
  Type=simple
330
331
  User=${user}
331
- Group=${user}
332
+ Group=${VAULT_GROUP}
332
333
  ${deploymentGroup}
333
334
  LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
334
335
  ${gitCredential}${projectCredentials}${projectCredentials ? `
@@ -347,8 +348,8 @@ LimitCORE=0
347
348
  # scope. So it lives in a directory systemd creates with a known owner rather
348
349
  # than wherever the process happened to have write access.
349
350
  RuntimeDirectory=forgezero
350
- RuntimeDirectoryMode=0710
351
- UMask=0077
351
+ RuntimeDirectoryMode=0750
352
+ UMask=0007
352
353
 
353
354
  # Tenant-controlled commands execute in forgezero-deploy-runner.service. This
354
355
  # credential-bearing process never needs to cross a privilege boundary.
@@ -415,6 +416,10 @@ function planProvision(options) {
415
416
  socketPath: options.socketPath,
416
417
  user,
417
418
  steps: [
419
+ {
420
+ label: "vault socket access group",
421
+ command: `groupadd --system ${VAULT_GROUP} || true`
422
+ },
418
423
  ...sourceBinPath && binPath ? [{
419
424
  label: "root-owned agent runtime",
420
425
  command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
@@ -427,6 +432,10 @@ function planProvision(options) {
427
432
  label: "service account",
428
433
  command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
429
434
  },
435
+ {
436
+ label: "bind service account to vault group",
437
+ command: `usermod -g ${VAULT_GROUP} ${user}`
438
+ },
430
439
  ...deploymentEnabled ? [{
431
440
  label: "credential-free deployment account",
432
441
  command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
@@ -771,10 +780,12 @@ async function resolveIdentity(selector, socketPath) {
771
780
  return chosen;
772
781
  }
773
782
 
783
+ // src/version.ts
784
+ var VERSION = "0.1.12";
785
+
774
786
  // src/cli/index.ts
775
787
  var DEFAULT_MODE = THRESHOLD_MODES[0].id;
776
788
  var RECOMMENDED_MODE = (THRESHOLD_MODES.find((mode) => mode.recommended) ?? THRESHOLD_MODES[0]).id;
777
- var VERSION = "0.1.10";
778
789
  var PACKAGED_AGENT_BIN = fileURLToPath(new URL("./fz-agent.js", import.meta.url));
779
790
  function parseOptions(argv) {
780
791
  const options = {
@@ -1033,13 +1044,24 @@ async function cmdAgent(options, args) {
1033
1044
  out.ok(`Wrote ${auxiliary.path}`);
1034
1045
  }
1035
1046
  if (options.enrol) {
1036
- const prompt = Bun.spawn(["systemd-ask-password", "--timeout=0", "--echo=no", "ForgeZero one-time enrolment token:"], { stdin: "inherit", stdout: "pipe", stderr: "inherit" });
1037
- const token = (await new Response(prompt.stdout).text()).trim();
1038
- if (await prompt.exited !== 0 || !/^fze_[A-Za-z0-9_-]{40,100}$/.test(token)) {
1039
- throw new Error("A valid fze_ enrolment token was not provided.");
1040
- }
1041
- writeFileSync(enrolTokenSourcePath, `${token}
1047
+ if (existsSync(enrolTokenSourcePath)) {
1048
+ const source = statSync(enrolTokenSourcePath);
1049
+ const token = readFileSync(enrolTokenSourcePath, "utf8").trim();
1050
+ if (!source.isFile() || (source.mode & 511) !== 384 || source.uid !== 0) {
1051
+ throw new Error("The preloaded enrolment token must be a root-owned 0600 file in /run.");
1052
+ }
1053
+ if (!/^fze_[A-Za-z0-9_-]{40,100}$/.test(token)) {
1054
+ throw new Error("The preloaded enrolment token is malformed.");
1055
+ }
1056
+ } else {
1057
+ const prompt = Bun.spawn(["systemd-ask-password", "--timeout=0", "--echo=no", "ForgeZero one-time enrolment token:"], { stdin: "inherit", stdout: "pipe", stderr: "inherit" });
1058
+ const token = (await new Response(prompt.stdout).text()).trim();
1059
+ if (await prompt.exited !== 0 || !/^fze_[A-Za-z0-9_-]{40,100}$/.test(token)) {
1060
+ throw new Error("A valid fze_ enrolment token was not provided.");
1061
+ }
1062
+ writeFileSync(enrolTokenSourcePath, `${token}
1042
1063
  `, { mode: 384, flag: "wx" });
1064
+ }
1043
1065
  }
1044
1066
  const transcript = await applyPlan(plan, localRunner);
1045
1067
  for (const step of transcript)
@@ -4,7 +4,8 @@ export interface GuestBinding {
4
4
  computeReference: string;
5
5
  projectKey: string;
6
6
  environmentKey: string;
7
- tenantSlug: string;
7
+ realm: 'platform' | 'tenant';
8
+ tenantSlug?: string;
8
9
  }
9
10
  export interface GuestEnrolmentOptions {
10
11
  apiUrl: string;
@@ -10,11 +10,72 @@ import {
10
10
  writeFileSync
11
11
  } from "node:fs";
12
12
  import { dirname } from "node:path";
13
+
14
+ // src/signed-node-http.ts
15
+ import {
16
+ encodeSignatureHeader,
17
+ generateResponseRecipient,
18
+ openResponse,
19
+ RESPONSE_KEY_HEADER,
20
+ signRequest
21
+ } from "@forgezero/runtime/identity";
22
+
23
+ class SignedNodeHttpError extends Error {
24
+ status;
25
+ constructor(status, message) {
26
+ super(message);
27
+ this.status = status;
28
+ this.name = "SignedNodeHttpError";
29
+ }
30
+ }
31
+ async function postSignedNode(options, path, body, sealedResponse = false) {
32
+ const url = new URL(options.apiUrl);
33
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
34
+ url.search = "";
35
+ url.hash = "";
36
+ const raw = JSON.stringify(body);
37
+ const recipient = sealedResponse ? generateResponseRecipient() : undefined;
38
+ const envelope = signRequest(options.keys, options.nodeKey, {
39
+ method: "POST",
40
+ path: url.pathname,
41
+ query: "",
42
+ body: raw,
43
+ responseKey: recipient?.publicKey
44
+ });
45
+ const signature = encodeSignatureHeader(envelope);
46
+ const response = await (options.fetch ?? globalThis.fetch)(url, {
47
+ method: "POST",
48
+ headers: {
49
+ "content-type": "application/json",
50
+ "x-fz-node": options.nodeKey,
51
+ "x-fz-signature": signature,
52
+ ...recipient ? { [RESPONSE_KEY_HEADER]: recipient.publicKey } : {}
53
+ },
54
+ body: raw,
55
+ signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
56
+ });
57
+ const payload = await response.json().catch(() => null);
58
+ if (!response.ok) {
59
+ const failure = payload;
60
+ const reason = failure ? failure.error?.message ?? failure.message : undefined;
61
+ throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
62
+ }
63
+ if (recipient) {
64
+ try {
65
+ return await openResponse(recipient.secretKey, signature, payload);
66
+ } catch {
67
+ throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
68
+ }
69
+ }
70
+ return payload;
71
+ }
72
+
73
+ // src/guest-enrolment.ts
13
74
  var validBinding = (value, expectedNodeKey) => {
14
75
  if (!value || typeof value !== "object")
15
76
  return false;
16
77
  const row = value;
17
- return ["nodeKey", "computeReference", "projectKey", "environmentKey", "tenantSlug"].every((key) => typeof row[key] === "string" && row[key].length > 0) && (!expectedNodeKey || row.nodeKey === expectedNodeKey);
78
+ return ["nodeKey", "computeReference", "projectKey", "environmentKey"].every((key) => typeof row[key] === "string" && row[key].length > 0) && (row.realm === "platform" || row.realm === "tenant" && typeof row.tenantSlug === "string" && row.tenantSlug.length > 0) && (!expectedNodeKey || row.nodeKey === expectedNodeKey);
18
79
  };
19
80
  function loadGuestBinding(path, expectedNodeKey) {
20
81
  if (!existsSync(path))
@@ -46,34 +107,31 @@ async function enrolGuestIdentity(options) {
46
107
  const token = options.token?.trim() ?? (options.tokenPath ? readFileSync(options.tokenPath, "utf8").trim() : "");
47
108
  if (!token.startsWith("fze_"))
48
109
  throw new Error("guest enrolment credential is malformed");
49
- const url = new URL(options.apiUrl);
50
- url.pathname = `${url.pathname.replace(/\/$/, "")}/v1/compute/enrol`.replace(/\/+/g, "/");
51
- url.search = "";
52
- url.hash = "";
53
- const response = await (options.fetch ?? globalThis.fetch)(url, {
54
- method: "POST",
55
- headers: { "content-type": "application/json" },
56
- body: JSON.stringify({
57
- token,
58
- label: options.label,
59
- gitDeployPublicKey: options.gitDeployPublicKey,
60
- publicKeys: {
61
- ed25519: options.keys.ed25519.publicKey,
62
- mlDsa: options.keys.mlDsa.publicKey
63
- }
64
- }),
65
- signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
66
- });
67
- const payload = await response.json().catch(() => null);
68
- if (!response.ok || !payload?.ok || payload.nodeKey !== options.nodeKey || !payload.computeReference || !payload.projectKey || !payload.environmentKey || !payload.tenantSlug) {
69
- throw new Error(payload?.error?.message || `guest enrolment returned HTTP ${response.status}`);
110
+ const payload = await postSignedNode({
111
+ apiUrl: options.apiUrl,
112
+ nodeKey: options.nodeKey,
113
+ keys: options.keys,
114
+ fetch: options.fetch,
115
+ requestTimeoutMs: options.requestTimeoutMs
116
+ }, "v1/compute/enrol", {
117
+ token,
118
+ label: options.label,
119
+ gitDeployPublicKey: options.gitDeployPublicKey,
120
+ publicKeys: {
121
+ ed25519: options.keys.ed25519.publicKey,
122
+ mlDsa: options.keys.mlDsa.publicKey
123
+ }
124
+ }, true);
125
+ if (!payload?.ok || payload.nodeKey !== options.nodeKey || !payload.computeReference || !payload.projectKey || !payload.environmentKey || payload.realm !== "platform" && (payload.realm !== "tenant" || !payload.tenantSlug)) {
126
+ throw new Error(payload?.error?.message || "guest enrolment response was not accepted");
70
127
  }
71
128
  const binding = {
72
129
  nodeKey: payload.nodeKey,
73
130
  computeReference: payload.computeReference,
74
131
  projectKey: payload.projectKey,
75
132
  environmentKey: payload.environmentKey,
76
- tenantSlug: payload.tenantSlug
133
+ realm: payload.realm,
134
+ ...payload.tenantSlug ? { tenantSlug: payload.tenantSlug } : {}
77
135
  };
78
136
  persistGuestBinding(options.statePath, binding);
79
137
  if (options.consume)
package/dist/index.d.ts CHANGED
@@ -2,30 +2,7 @@
2
2
  import { type NodeKeyPair } from '@forgezero/runtime/identity';
3
3
  import { startAgent, type AgentOptions, type AttestationSource } from './socket';
4
4
  import type { SecretCache } from './cache';
5
- /**
6
- * fz-agent — runs inside managed compute, driven locally before the platform exists and
7
- * over HTTPS after it does. One implementation, two front doors: bootstrap is
8
- * not a special case, it is the general case run first, because a tenant's own
9
- * bare metal has no ForgeZero on it either.
10
- *
11
- * Everything it does is recorded in a hash-chained journal from its first
12
- * command — before there is a database to log into. A flat log written then is
13
- * a log anyone could have edited afterwards; the chain either verifies or
14
- * visibly does not, and it is replayed into `audit_log` once the platform is
15
- * operational.
16
- *
17
- * ## What it serves
18
- *
19
- * A unix socket that SIGNS and never surrenders the key. `@forgezero/vault`
20
- * discovers `/run/forgezero/vault.sock` and prefers it over `FORGEZERO_API_KEY`, so
21
- * moving an application onto managed compute means deleting an environment
22
- * variable rather than changing a line of code — and a machine that used to hold
23
- * a signing seed now holds nothing an attacker can take.
24
- *
25
- * That preference was implemented in the client long before anything listened.
26
- * See `socket.ts` for why it signs rather than handing back a token.
27
- */
28
- export declare const VERSION = "0.1.10";
5
+ export { VERSION } from './version';
29
6
  export { startAgent, handleRequest } from './socket';
30
7
  export type { AgentOptions, AttestationSource, Request, Response } from './socket';
31
8
  export { createSecretCache, CacheError } from './cache';
@@ -35,13 +12,11 @@ export type { DeploymentManager, DeploymentOptions, DeploymentRequest, Deploymen
35
12
  export { DEFAULT_CONTROL_SOCKET, requestControl, startControlServer } from './control';
36
13
  export { pullDeploymentOnce, startDeploymentPull } from './deployment-pull';
37
14
  export type { DeploymentPullOptions, RemoteDeploymentClaim, PullResult } from './deployment-pull';
38
- export { readStaticDeploymentState, writeStaticDeploymentState, startStaticDeploymentWatch } from './deployment-watch';
39
- export type { StaticDeploymentState, StaticDeploymentWatchOptions } from './deployment-watch';
40
15
  export { pullProvisioningOnce, startProvisioningPull } from './provisioning-pull';
41
- export type { CreateRemoteProvisionClaim, ProvisioningPullOptions, RemoteProvisionClaim, ProvisionPullResult, ProvisionRunner, ProvisionResult } from './provisioning-pull';
16
+ export type { CreateRemoteProvisionClaim, ProvisioningPullOptions, RemoteProvisionClaim, GuestAccess, ProvisionPullResult, ProvisionRunner, ProvisionResult } from './provisioning-pull';
42
17
  export { enrolGuestIdentity, loadGuestBinding } from './guest-enrolment';
43
18
  export type { GuestBinding, GuestEnrolmentOptions } from './guest-enrolment';
44
- export { createNodeVaultCache, startNodeVaultSync, tenantNodeApiUrl } from './node-vault';
19
+ export { createNodeVaultCache, projectVaultCacheKey, projectVaultCoordinate, startNodeVaultSync, tenantNodeApiUrl } from './node-vault';
45
20
  export type { NodeVaultOptions, NodeVaultSyncOptions } from './node-vault';
46
21
  export { allocateAddress, allocateCpuPool, cloudInit, guestNameFor, provisionMetalGuest, removeMetalGuest } from './metal-provision';
47
22
  export { validateMetalProfile } from './metal-provision';
@@ -53,6 +28,7 @@ export type { SnpAttestationOptions } from './snp-attestation';
53
28
  export { attestNodeOnce, startNodeAttestation } from './attestation-client';
54
29
  export type { NodeAttestationOptions } from './attestation-client';
55
30
  export { applyMetalIsolation, metalGuestSliceUnit, metalHousekeepingDropIn } from './metal-isolation';
31
+ export { assertSupportedGuestImage, SUPPORTED_GUEST_IMAGE } from './ubuntu';
56
32
  /**
57
33
  * The node seed, on disk, owner-only.
58
34
  *
@@ -128,5 +104,5 @@ export declare function runAgent(config?: AgentConfig): {
128
104
  server: ReturnType<typeof startAgent>;
129
105
  keys: NodeKeyPair;
130
106
  nodeKey: string;
131
- setCache(cache: SecretCache): void;
107
+ setVault(cache: SecretCache, projectKey: string): void;
132
108
  };
@@ -181,6 +181,7 @@ var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
181
181
  var reasonFor = (mode) => mode === "attested" ? "SEV-SNP guest device present, so the agent can prove what it is running and the platform can refuse it if the measurement is wrong." : "No SEV-SNP guest device. The agent authenticates with its enrolment token and hybrid Ed25519 + ML-DSA signature — weaker than attestation, stronger than an API key in the application.";
182
182
  var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
183
183
  var DEPLOYMENT_GROUP = "forgezero-deploy";
184
+ var VAULT_GROUP = "forgezero-vault";
184
185
  var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
185
186
  var DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.socket";
186
187
  var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
@@ -350,7 +351,7 @@ function agentUnit(options) {
350
351
  `);
351
352
  const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
352
353
  DeviceAllow=/dev/sev-guest rw` : "";
353
- const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${user} /dev/sev-guest
354
+ const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
354
355
  ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
355
356
  ` : "";
356
357
  return `[Unit]
@@ -361,7 +362,7 @@ ${deploymentDependency}
361
362
  [Service]
362
363
  Type=simple
363
364
  User=${user}
364
- Group=${user}
365
+ Group=${VAULT_GROUP}
365
366
  ${deploymentGroup}
366
367
  LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
367
368
  ${gitCredential}${projectCredentials}${projectCredentials ? `
@@ -380,8 +381,8 @@ LimitCORE=0
380
381
  # scope. So it lives in a directory systemd creates with a known owner rather
381
382
  # than wherever the process happened to have write access.
382
383
  RuntimeDirectory=forgezero
383
- RuntimeDirectoryMode=0710
384
- UMask=0077
384
+ RuntimeDirectoryMode=0750
385
+ UMask=0007
385
386
 
386
387
  # Tenant-controlled commands execute in forgezero-deploy-runner.service. This
387
388
  # credential-bearing process never needs to cross a privilege boundary.
@@ -448,6 +449,10 @@ function planProvision(options) {
448
449
  socketPath: options.socketPath,
449
450
  user,
450
451
  steps: [
452
+ {
453
+ label: "vault socket access group",
454
+ command: `groupadd --system ${VAULT_GROUP} || true`
455
+ },
451
456
  ...sourceBinPath && binPath ? [{
452
457
  label: "root-owned agent runtime",
453
458
  command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
@@ -460,6 +465,10 @@ function planProvision(options) {
460
465
  label: "service account",
461
466
  command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
462
467
  },
468
+ {
469
+ label: "bind service account to vault group",
470
+ command: `usermod -g ${VAULT_GROUP} ${user}`
471
+ },
463
472
  ...deploymentEnabled ? [{
464
473
  label: "credential-free deployment account",
465
474
  command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
@@ -523,6 +532,21 @@ function planProvision(options) {
523
532
  };
524
533
  }
525
534
 
535
+ // src/ubuntu.ts
536
+ var SUPPORTED_GUEST_IMAGE = Object.freeze({
537
+ key: "ubuntu-resolute-20260731",
538
+ family: "ubuntu-26.04",
539
+ version: "2026-07-31",
540
+ label: "Ubuntu 26.04 LTS Resolute",
541
+ url: "https://cloud-images.ubuntu.com/releases/resolute/release-20260731/ubuntu-26.04-server-cloudimg-amd64.img",
542
+ sha256: "9dc7c5363c0146a08ba0c9aa834d82c2c6dfbb1c471ad9a2f0aba1189e21be05"
543
+ });
544
+ function assertSupportedGuestImage(imageKey) {
545
+ if (imageKey !== SUPPORTED_GUEST_IMAGE.key) {
546
+ throw new Error(`unsupported guest image ${imageKey}; ForgeZero currently supports only ${SUPPORTED_GUEST_IMAGE.key}`);
547
+ }
548
+ }
549
+
526
550
  // src/metal-provision.ts
527
551
  import { createHash } from "node:crypto";
528
552
  import {
@@ -530,6 +554,7 @@ import {
530
554
  mkdirSync,
531
555
  readFileSync,
532
556
  readdirSync,
557
+ rmSync,
533
558
  statSync,
534
559
  unlinkSync,
535
560
  writeFileSync
@@ -584,6 +609,10 @@ function validateMetalProfile(profile) {
584
609
  throw new MetalProvisionError("metal paths must be absolute");
585
610
  }
586
611
  new URL(profile.apiUrl);
612
+ const imageKeys = Object.keys(profile.images);
613
+ if (imageKeys.length !== 1 || imageKeys[0] !== SUPPORTED_GUEST_IMAGE.key || profile.images[SUPPORTED_GUEST_IMAGE.key]?.sha256 !== SUPPORTED_GUEST_IMAGE.sha256) {
614
+ throw new MetalProvisionError(`metal profile must contain only the pinned ${SUPPORTED_GUEST_IMAGE.key} image contract`);
615
+ }
587
616
  if (!Array.isArray(profile.cpuPools) || profile.cpuPools.length === 0) {
588
617
  throw new MetalProvisionError("at least one exclusive CPU pool is required");
589
618
  }
@@ -649,6 +678,21 @@ function allocateAddress(profile, computeKey, rows) {
649
678
  }
650
679
  throw new MetalProvisionError("guest address range is full");
651
680
  }
681
+ function requestedAddress(profile, claim, rows) {
682
+ if (!claim.spec.guestAddress)
683
+ return allocateAddress(profile, claim.computeKey, rows);
684
+ if (!claim.spec.guestAddress.startsWith(`${profile.subnetPrefix}.`)) {
685
+ throw new MetalProvisionError("requested guest address is outside the metal profile");
686
+ }
687
+ const last = Number(claim.spec.guestAddress.slice(profile.subnetPrefix.length + 1));
688
+ if (!Number.isInteger(last) || last < profile.addressStart || last > profile.addressEnd) {
689
+ throw new MetalProvisionError("requested guest address is outside the allocatable range");
690
+ }
691
+ const occupied = rows.find((row) => row.address === claim.spec.guestAddress && row.computeKey !== claim.computeKey);
692
+ if (occupied)
693
+ throw new MetalProvisionError("requested guest address is already allocated");
694
+ return claim.spec.guestAddress;
695
+ }
652
696
  function allocateCpuPool(profile, claim, rows) {
653
697
  const prior = rows.find((row) => row.computeKey === claim.computeKey);
654
698
  if (prior) {
@@ -659,6 +703,15 @@ function allocateCpuPool(profile, claim, rows) {
659
703
  return retained;
660
704
  }
661
705
  const used = new Set(rows.map((row) => row.cpuPoolKey));
706
+ if (claim.spec.cpuPoolKey) {
707
+ const requested = profile.cpuPools.find((pool) => pool.key === claim.spec.cpuPoolKey);
708
+ if (!requested || used.has(requested.key)) {
709
+ throw new MetalProvisionError("requested CPU pool is unavailable");
710
+ }
711
+ if (requested.physicalCores < claim.spec.physicalCores || membersOfLinuxList(requested.cpus, "CPU").length < claim.spec.vcpu)
712
+ throw new MetalProvisionError("requested CPU pool cannot satisfy this guest");
713
+ return requested;
714
+ }
662
715
  const candidates = profile.cpuPools.filter((pool) => !used.has(pool.key) && pool.physicalCores >= claim.spec.physicalCores && membersOfLinuxList(pool.cpus, "CPU").length >= claim.spec.vcpu).sort((left, right) => left.physicalCores - right.physicalCores || membersOfLinuxList(left.cpus, "CPU").length - membersOfLinuxList(right.cpus, "CPU").length || left.key.localeCompare(right.key));
663
716
  const selected = candidates[0];
664
717
  if (!selected)
@@ -671,7 +724,7 @@ var yamlFile = (path, content, permissions) => ` - path: ${JSON.stringify(path)
671
724
  encoding: b64
672
725
  content: ${base64(content)}
673
726
  `;
674
- function guestBootstrapScript(profile, attested = Boolean(profile.confidential)) {
727
+ function guestBootstrapScript(profile, attested = Boolean(profile.confidential), hasEnrolment = true) {
675
728
  const agentBun = "/usr/local/lib/forgezero/bun";
676
729
  const attestationSetup = attested ? `# The report device is not part of the encryption path, so a guest can appear
677
730
  # healthy and encrypted while attestation is silently impossible. Install and
@@ -684,7 +737,9 @@ test -c /dev/sev-guest
684
737
  ` : "";
685
738
  return `#!/usr/bin/env bash
686
739
  set -Eeuo pipefail
687
- ${attestationSetup}useradd --system --no-create-home --shell /usr/sbin/nologin forgezero-agent 2>/dev/null || true
740
+ ${attestationSetup}groupadd --system ${VAULT_GROUP} 2>/dev/null || true
741
+ useradd --system --no-create-home --shell /usr/sbin/nologin forgezero-agent 2>/dev/null || true
742
+ usermod -g ${VAULT_GROUP} forgezero-agent
688
743
  groupadd --system ${DEPLOYMENT_GROUP} 2>/dev/null || true
689
744
  useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} 2>/dev/null || true
690
745
  usermod -a -G ${DEPLOYMENT_GROUP} forgezero-agent
@@ -695,13 +750,13 @@ install -d -o root -g root -m 0755 /opt/forgezero
695
750
  install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 /opt/forgezero/releases
696
751
  install -d -o forgezero-agent -g forgezero-agent -m 0700 /opt/forgezero/cache /opt/forgezero/home
697
752
  install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 /opt/forgezero/runner-home /opt/forgezero/runner-home/cache
698
- if [[ -s /run/forgezero-enrol-token ]]; then
753
+ ${hasEnrolment ? `if [[ -s /run/forgezero-enrol-token ]]; then
699
754
  systemd-creds encrypt --name=enrol-token /run/forgezero-enrol-token /var/lib/forgezero/enrol-token.cred
700
755
  rm -f /run/forgezero-enrol-token
701
756
  fi
702
757
  chown root:root /var/lib/forgezero/enrol-token.cred
703
758
  chmod 0400 /var/lib/forgezero/enrol-token.cred
704
- if [[ ! -x /usr/local/bin/bun ]]; then
759
+ ` : ""}if [[ ! -x /usr/local/bin/bun ]]; then
705
760
  curl -fsSL https://bun.sh/install -o /run/fz-bun-install
706
761
  printf '%s %s
707
762
  ' '${profile.bunInstallerSha256}' /run/fz-bun-install | sha256sum -c -
@@ -740,8 +795,8 @@ systemctl daemon-reload
740
795
  systemctl enable --now forgezero-deploy-runner.socket forgezero-deploy-runner.service forgezero-agent.service
741
796
  `;
742
797
  }
743
- function guestAgentUnit(profile, name, attested = Boolean(profile.confidential)) {
744
- const attestationPrepare = attested ? `ExecStartPre=+/bin/chgrp forgezero-agent /dev/sev-guest
798
+ function guestAgentUnit(profile, name, attested = Boolean(profile.confidential), pull = true) {
799
+ const attestationPrepare = attested ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
745
800
  ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
746
801
  ` : "";
747
802
  const attestationDevice = attested ? `DevicePolicy=closed
@@ -756,7 +811,7 @@ Requires=forgezero-deploy-runner.service
756
811
  [Service]
757
812
  Type=simple
758
813
  User=forgezero-agent
759
- Group=forgezero-agent
814
+ Group=${VAULT_GROUP}
760
815
  SupplementaryGroups=${DEPLOYMENT_GROUP}
761
816
  LoadCredentialEncrypted=agent-seed:/etc/forgezero/creds/agent-seed.cred
762
817
  LoadCredentialEncrypted=git-deploy-key:/etc/forgezero/creds/git-deploy-key.cred
@@ -767,15 +822,15 @@ Environment=FZ_ENROL_STATE_FILE=/var/lib/forgezero/enrolment.json
767
822
  Environment=FZ_NODE_LABEL=${name}
768
823
  Environment=FZ_SOCKET_PATH=/run/forgezero/vault.sock
769
824
  Environment=FZ_DEPLOY_ROOT=/opt/forgezero
770
- Environment=FZ_DEPLOY_PULL=true
825
+ Environment=FZ_DEPLOY_PULL=${pull ? "true" : "false"}
771
826
  Environment=FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}
772
827
  Environment=HOME=/opt/forgezero/home
773
828
  ${attestationPrepare}ExecStart=/usr/local/bin/fz-agent
774
829
  Restart=on-failure
775
830
  RestartSec=5
776
831
  RuntimeDirectory=forgezero
777
- RuntimeDirectoryMode=0710
778
- UMask=0077
832
+ RuntimeDirectoryMode=0750
833
+ UMask=0007
779
834
  LimitCORE=0
780
835
  NoNewPrivileges=true
781
836
  PrivateTmp=true
@@ -825,24 +880,39 @@ WantedBy=multi-user.target
825
880
  `;
826
881
  }
827
882
  function cloudInit(profile, claim, manifest) {
828
- const bootstrap = guestBootstrapScript(profile, claim.spec.confidential);
829
- const agentUnit2 = guestAgentUnit(profile, manifest.name, claim.spec.confidential);
883
+ const enrolled = Boolean(claim.enrolment);
884
+ const bootstrap = guestBootstrapScript(profile, claim.spec.confidential, enrolled);
885
+ const agentUnit2 = guestAgentUnit(profile, manifest.name, claim.spec.confidential, enrolled);
830
886
  const enrolmentDropIn = guestEnrolmentDropIn();
831
887
  const cleanupScript = guestEnrolmentCleanupScript();
832
888
  const cleanupUnit = guestEnrolmentCleanupUnit();
833
889
  const runnerSocketUnit = deploymentRunnerSocketUnit("forgezero-agent");
834
890
  const runnerUnit = deploymentRunnerUnit({ binPath: "/usr/local/bin/fz-agent", deployRoot: "/opt/forgezero" });
891
+ const access = claim.access;
892
+ const users = access ? `users:
893
+ - default
894
+ - name: ${access.sshUser}
895
+ groups: [sudo]
896
+ shell: /bin/bash
897
+ sudo: ALL=(ALL) NOPASSWD:ALL
898
+ ssh_authorized_keys:
899
+ ${access.sshPublicKeys.map((key) => ` - ${JSON.stringify(key)}`).join(`
900
+ `)}
901
+ ` : "";
902
+ const enrolmentFiles = enrolled ? `${yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
903
+ `, "0600")}${yamlFile("/usr/local/sbin/forgezero-enrolment-cleanup", cleanupScript, "0700")}${yamlFile("/etc/systemd/system/forgezero-agent.service.d/enrolment.conf", enrolmentDropIn, "0644")}${yamlFile("/etc/systemd/system/forgezero-enrolment-cleanup.service", cleanupUnit, "0644")}` : "";
835
904
  return {
836
905
  userData: `#cloud-config
837
906
  package_update: true
907
+ ${users}disable_root: true
838
908
  # Git is part of the deployment transport, not a tenant-selected prerequisite:
839
909
  # every dynamically claimed repository must be cloneable on a clean image.
840
910
  packages: [curl, ca-certificates, openssl, openssh-client, git${claim.spec.confidential ? ", python3" : ""}]
841
911
  write_files:
842
- ${yamlFile("/run/forgezero-enrol-token", `${claim.enrolment.token}
843
- `, "0600")}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}${yamlFile("/usr/local/sbin/forgezero-enrolment-cleanup", cleanupScript, "0700")}${yamlFile("/etc/systemd/system/forgezero-deploy-runner.socket", runnerSocketUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-deploy-runner.service", runnerUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-agent.service", agentUnit2, "0644")}${yamlFile("/etc/systemd/system/forgezero-agent.service.d/enrolment.conf", enrolmentDropIn, "0644")}${yamlFile("/etc/systemd/system/forgezero-enrolment-cleanup.service", cleanupUnit, "0644")}runcmd:
912
+ ${enrolmentFiles}${yamlFile("/usr/local/sbin/forgezero-guest-bootstrap", bootstrap, "0700")}${yamlFile("/etc/systemd/system/forgezero-deploy-runner.socket", runnerSocketUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-deploy-runner.service", runnerUnit, "0644")}${yamlFile("/etc/systemd/system/forgezero-agent.service", agentUnit2, "0644")}runcmd:
844
913
  - [ bash, /usr/local/sbin/forgezero-guest-bootstrap ]
845
- - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
914
+ ${enrolled ? ` - [ systemctl, enable, --now, forgezero-enrolment-cleanup.service ]
915
+ ` : ""}
846
916
  `,
847
917
  metaData: `instance-id: ${manifest.name}-${claim.attempt}
848
918
  local-hostname: ${manifest.name}
@@ -866,7 +936,8 @@ var checked = async (exec, argv) => {
866
936
  };
867
937
  async function provisionMetalGuest(profile, claim, exec) {
868
938
  validateMetalProfile(profile);
869
- if (!claim.computeKey || !claim.spec.reference || !SAFE_NAME.test(claim.spec.imageKey) || !Number.isInteger(claim.spec.physicalCores) || claim.spec.physicalCores < 1 || claim.spec.physicalCores > 256 || !Number.isInteger(claim.spec.vcpu) || claim.spec.vcpu < 1 || claim.spec.vcpu > 512 || !Number.isInteger(claim.spec.memoryGib) || claim.spec.memoryGib < 1 || claim.spec.memoryGib > 8192 || !Number.isInteger(claim.spec.diskGib) || claim.spec.diskGib < 8 || claim.spec.diskGib > 65536 || !Number.isInteger(claim.spec.egressGuaranteedMbps) || claim.spec.egressGuaranteedMbps < 0 || !Number.isInteger(claim.spec.egressBurstMbps) || claim.spec.egressBurstMbps < claim.spec.egressGuaranteedMbps)
939
+ assertSupportedGuestImage(claim.spec.imageKey);
940
+ if (!claim.computeKey || !claim.spec.reference || !SAFE_NAME.test(claim.spec.imageKey) || claim.spec.guestName !== undefined && !SAFE_NAME.test(claim.spec.guestName) || claim.spec.cpuPoolKey !== undefined && !SAFE_NAME.test(claim.spec.cpuPoolKey) || !Number.isInteger(claim.spec.physicalCores) || claim.spec.physicalCores < 1 || claim.spec.physicalCores > 256 || !Number.isInteger(claim.spec.vcpu) || claim.spec.vcpu < 1 || claim.spec.vcpu > 512 || !Number.isInteger(claim.spec.memoryGib) || claim.spec.memoryGib < 1 || claim.spec.memoryGib > 8192 || !Number.isInteger(claim.spec.diskGib) || claim.spec.diskGib < 8 || claim.spec.diskGib > 65536 || !Number.isInteger(claim.spec.egressGuaranteedMbps) || claim.spec.egressGuaranteedMbps < 0 || !Number.isInteger(claim.spec.egressBurstMbps) || claim.spec.egressBurstMbps < claim.spec.egressGuaranteedMbps)
870
941
  throw new MetalProvisionError("invalid compute claim");
871
942
  const image = profile.images[claim.spec.imageKey];
872
943
  if (!image || !isAbsolute(image.path) || !SHA256.test(image.sha256)) {
@@ -880,12 +951,22 @@ async function provisionMetalGuest(profile, claim, exec) {
880
951
  for (const path of [profile.stateDir, profile.seedDir, profile.unitDir])
881
952
  mkdirSync(path, { recursive: true, mode: 448 });
882
953
  const manifests = readManifests(profile.stateDir);
883
- const name = guestNameFor(claim.computeKey);
954
+ if (claim.access) {
955
+ if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(claim.access.sshUser)) {
956
+ throw new MetalProvisionError("invalid SSH user");
957
+ }
958
+ if (claim.access.sshPublicKeys.length < 1 || claim.access.sshPublicKeys.length > 16 || claim.access.sshPublicKeys.some((key) => typeof key !== "string" || key.length > 16384 || !/^ssh-(?:ed25519|rsa)\s+[A-Za-z0-9+/]+={0,3}(?:\s+.*)?$/.test(key.trim())))
959
+ throw new MetalProvisionError("invalid SSH public key list");
960
+ }
961
+ const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
962
+ const conflictingName = manifests.find((row) => row.name === name && row.computeKey !== claim.computeKey);
963
+ if (conflictingName)
964
+ throw new MetalProvisionError("requested guest name is already allocated");
884
965
  const manifestPath = join(profile.stateDir, `${name}.json`);
885
966
  const prior = manifests.find((row) => row.computeKey === claim.computeKey);
886
967
  if (prior && prior.reference !== claim.spec.reference)
887
968
  throw new MetalProvisionError("compute identity conflicts with host inventory");
888
- const address = allocateAddress(profile, claim.computeKey, manifests);
969
+ const address = requestedAddress(profile, claim, manifests);
889
970
  const cpuPool = allocateCpuPool(profile, claim, manifests);
890
971
  const manifest = prior ?? {
891
972
  computeKey: claim.computeKey,
@@ -964,18 +1045,22 @@ async function removeMetalGuest(profile, claim, exec) {
964
1045
  validateMetalProfile(profile);
965
1046
  if (claim.action !== "delete")
966
1047
  throw new MetalProvisionError("create claim cannot remove a guest");
967
- const name = guestNameFor(claim.computeKey);
1048
+ const name = claim.spec.guestName ?? guestNameFor(claim.computeKey);
968
1049
  const manifestPath = join(profile.stateDir, `${name}.json`);
969
1050
  if (!existsSync(manifestPath))
970
1051
  return {};
971
1052
  const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
972
- if (manifest.computeKey !== claim.computeKey || manifest.reference !== claim.spec.reference || manifest.name !== name) {
1053
+ const currentIdentity = manifest.computeKey === claim.computeKey && manifest.reference === claim.spec.reference && manifest.name === name;
1054
+ const legacyPlatformIdentity = claim.bootstrap?.kind === "platform-genesis" && claim.computeKey === `platform:${name}` && claim.spec.reference === `platform:${name}` && manifest.name === name && manifest.disk === `/dev/${profile.volumeGroup}/${name}` && manifest.address === claim.spec.guestAddress && (manifest.unit === `fz-guest@${name}.service` || manifest.unit === `forgezero-guest@${name}.service`);
1055
+ if (!currentIdentity && !legacyPlatformIdentity) {
973
1056
  throw new MetalProvisionError("compute identity conflicts with host inventory");
974
1057
  }
975
1058
  const service = `forgezero-guest@${name}.service`;
976
1059
  const unitPath = join(profile.unitDir, service);
977
1060
  if (existsSync(unitPath))
978
1061
  await checked(exec, ["systemctl", "disable", "--now", service]);
1062
+ else if (legacyPlatformIdentity)
1063
+ await checked(exec, ["systemctl", "disable", "--now", service]);
979
1064
  else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
980
1065
  throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
981
1066
  }
@@ -992,6 +1077,14 @@ async function removeMetalGuest(profile, claim, exec) {
992
1077
  ])
993
1078
  if (existsSync(path))
994
1079
  unlinkSync(path);
1080
+ if (legacyPlatformIdentity) {
1081
+ const legacyConfig = `/etc/forgezero/guests/${name}.conf`;
1082
+ const legacyDropIn = join(profile.unitDir, `${service}.d`);
1083
+ if (existsSync(legacyConfig))
1084
+ unlinkSync(legacyConfig);
1085
+ if (existsSync(legacyDropIn))
1086
+ rmSync(legacyDropIn, { recursive: true });
1087
+ }
995
1088
  await checked(exec, ["systemctl", "daemon-reload"]);
996
1089
  return {};
997
1090
  }