@forgezero/agent 0.1.11 → 0.1.13

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 +545 -488
  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 +152 -26
  9. package/dist/metal-provision.d.ts +4 -3
  10. package/dist/metal-provision.js +152 -26
  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 +22 -1
  16. package/dist/provisioning-pull.js +21 -10
  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.13";
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.11";
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
  };