@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
@@ -68,8 +68,8 @@ export declare function validateMetalProfile(profile: MetalProvisionProfile): vo
68
68
  export declare function allocateAddress(profile: MetalProvisionProfile, computeKey: string, rows: GuestManifest[]): string;
69
69
  /** Stable tight-fit allocation of one exclusive CPU/NUMA pool. */
70
70
  export declare function allocateCpuPool(profile: MetalProvisionProfile, claim: Pick<RemoteProvisionClaim, 'computeKey' | 'spec'>, rows: GuestManifest[]): MetalCpuPool;
71
- export declare function guestBootstrapScript(profile: MetalProvisionProfile, attested?: boolean): string;
72
- export declare function guestAgentUnit(profile: MetalProvisionProfile, name: string, attested?: boolean): string;
71
+ export declare function guestBootstrapScript(profile: MetalProvisionProfile, attested?: boolean, hasEnrolment?: boolean): string;
72
+ export declare function guestAgentUnit(profile: MetalProvisionProfile, name: string, attested?: boolean, pull?: boolean): string;
73
73
  /** Removed by the root cleanup service once the non-secret binding is durable. */
74
74
  export declare function guestEnrolmentDropIn(): string;
75
75
  export declare function guestEnrolmentCleanupScript(): string;
@@ -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
  }
@@ -5,9 +5,18 @@ export interface NodeVaultOptions extends Omit<SignedNodeHttpOptions, 'apiUrl'>
5
5
  /** Tenant node base, for example https://api.example/api/t/acme. */
6
6
  apiUrl: string;
7
7
  keys: NodeKeyPair;
8
+ /** The sole tenant project this enrolled compute may replicate. */
9
+ projectKey: string;
8
10
  ttlMs?: number;
9
11
  maxStaleMs?: number;
10
12
  }
13
+ export interface ProjectVaultCoordinate {
14
+ environment: string;
15
+ name: string;
16
+ }
17
+ /** Internal cache key. JSON avoids separator ambiguity in user-selected names. */
18
+ export declare function projectVaultCacheKey(coordinate: ProjectVaultCoordinate): string;
19
+ export declare function projectVaultCoordinate(key: string): ProjectVaultCoordinate;
11
20
  /** Turn the public API origin into the realm-selected node endpoint. */
12
21
  export declare function tenantNodeApiUrl(apiUrl: string, tenantSlug: string): string;
13
22
  /** Project/environment selection is server-owned through the compute binding. */
@@ -92,7 +92,13 @@ function createSecretCache(options) {
92
92
  }
93
93
 
94
94
  // src/signed-node-http.ts
95
- import { signRequest } from "@forgezero/runtime/identity";
95
+ import {
96
+ encodeSignatureHeader,
97
+ generateResponseRecipient,
98
+ openResponse,
99
+ RESPONSE_KEY_HEADER,
100
+ signRequest
101
+ } from "@forgezero/runtime/identity";
96
102
 
97
103
  class SignedNodeHttpError extends Error {
98
104
  status;
@@ -102,30 +108,28 @@ class SignedNodeHttpError extends Error {
102
108
  this.name = "SignedNodeHttpError";
103
109
  }
104
110
  }
105
- var signatureHeader = (envelope) => Buffer.from(JSON.stringify({
106
- timestamp: envelope.timestamp,
107
- nonce: envelope.nonce,
108
- edSignature: envelope.edSignature,
109
- mlDsaSignature: envelope.mlDsaSignature
110
- })).toString("base64url");
111
- async function postSignedNode(options, path, body) {
111
+ async function postSignedNode(options, path, body, sealedResponse = false) {
112
112
  const url = new URL(options.apiUrl);
113
113
  url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
114
114
  url.search = "";
115
115
  url.hash = "";
116
116
  const raw = JSON.stringify(body);
117
+ const recipient = sealedResponse ? generateResponseRecipient() : undefined;
117
118
  const envelope = signRequest(options.keys, options.nodeKey, {
118
119
  method: "POST",
119
120
  path: url.pathname,
120
121
  query: "",
121
- body: raw
122
+ body: raw,
123
+ responseKey: recipient?.publicKey
122
124
  });
125
+ const signature = encodeSignatureHeader(envelope);
123
126
  const response = await (options.fetch ?? globalThis.fetch)(url, {
124
127
  method: "POST",
125
128
  headers: {
126
129
  "content-type": "application/json",
127
130
  "x-fz-node": options.nodeKey,
128
- "x-fz-signature": signatureHeader(envelope)
131
+ "x-fz-signature": signature,
132
+ ...recipient ? { [RESPONSE_KEY_HEADER]: recipient.publicKey } : {}
129
133
  },
130
134
  body: raw,
131
135
  signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
@@ -136,10 +140,35 @@ async function postSignedNode(options, path, body) {
136
140
  const reason = failure ? failure.error?.message ?? failure.message : undefined;
137
141
  throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
138
142
  }
143
+ if (recipient) {
144
+ try {
145
+ return await openResponse(recipient.secretKey, signature, payload);
146
+ } catch {
147
+ throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
148
+ }
149
+ }
139
150
  return payload;
140
151
  }
141
152
 
142
153
  // src/node-vault.ts
154
+ var SCOPE_PART = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
155
+ function projectVaultCacheKey(coordinate) {
156
+ if (!SCOPE_PART.test(coordinate.environment) || !SCOPE_PART.test(coordinate.name)) {
157
+ throw new Error("node vault coordinate is malformed");
158
+ }
159
+ return JSON.stringify([coordinate.environment, coordinate.name]);
160
+ }
161
+ function projectVaultCoordinate(key) {
162
+ let value;
163
+ try {
164
+ value = JSON.parse(key);
165
+ } catch {
166
+ throw new Error("node vault cache key is malformed");
167
+ }
168
+ if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== "string" || typeof value[1] !== "string" || !SCOPE_PART.test(value[0]) || !SCOPE_PART.test(value[1]))
169
+ throw new Error("node vault cache key is malformed");
170
+ return { environment: value[0], name: value[1] };
171
+ }
143
172
  function tenantNodeApiUrl(apiUrl, tenantSlug) {
144
173
  if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(tenantSlug))
145
174
  throw new Error("tenant slug is malformed");
@@ -150,19 +179,20 @@ function tenantNodeApiUrl(apiUrl, tenantSlug) {
150
179
  return url.toString().replace(/\/$/, "");
151
180
  }
152
181
  function createNodeVaultCache(options) {
153
- const post = (operation, body) => postSignedNode(options, `v1/node/vault/${operation}`, body);
182
+ const post = (operation, body) => postSignedNode(options, `v1/node/vault/${operation}`, body, true);
154
183
  return createSecretCache({
155
184
  ttlMs: options.ttlMs,
156
185
  maxStaleMs: options.maxStaleMs,
157
186
  list: async () => {
158
187
  const payload = await post("list", {});
159
- if (!Array.isArray(payload.names) || payload.names.some((name) => typeof name !== "string")) {
188
+ if (!Array.isArray(payload.entries) || payload.entries.some((entry) => !entry || typeof entry.environment !== "string" || typeof entry.name !== "string")) {
160
189
  throw new Error("node vault list response is malformed");
161
190
  }
162
- return payload.names;
191
+ return payload.entries.map(projectVaultCacheKey);
163
192
  },
164
- fetch: async (name) => {
165
- const payload = await post("read", { name });
193
+ fetch: async (key) => {
194
+ const coordinate = projectVaultCoordinate(key);
195
+ const payload = await post("read", coordinate);
166
196
  if (typeof payload.value !== "string" || !Number.isSafeInteger(payload.version)) {
167
197
  throw new Error("node vault read response is malformed");
168
198
  }
@@ -170,9 +200,13 @@ function createNodeVaultCache(options) {
170
200
  },
171
201
  changes: async (since) => {
172
202
  const payload = await post("changes", { since });
173
- if (!Number.isSafeInteger(payload.version) || !Array.isArray(payload.changed) || payload.changed.some((name) => typeof name !== "string"))
203
+ if (!Number.isSafeInteger(payload.version) || !Array.isArray(payload.changed) || payload.changed.some((entry) => !entry || typeof entry.environment !== "string" || typeof entry.name !== "string"))
174
204
  throw new Error("node vault changes response is malformed");
175
- return { version: payload.version, changed: payload.changed, resync: payload.resync };
205
+ return {
206
+ version: payload.version,
207
+ changed: payload.changed.map(projectVaultCacheKey),
208
+ resync: payload.resync
209
+ };
176
210
  }
177
211
  });
178
212
  }
@@ -207,5 +241,7 @@ function startNodeVaultSync(cache, options = {}) {
207
241
  export {
208
242
  tenantNodeApiUrl,
209
243
  startNodeVaultSync,
244
+ projectVaultCoordinate,
245
+ projectVaultCacheKey,
210
246
  createNodeVaultCache
211
247
  };
@@ -108,6 +108,7 @@ export interface UnitOptions {
108
108
  }
109
109
  export declare const DEPLOYMENT_RUNNER_USER = "forgezero-runner";
110
110
  export declare const DEPLOYMENT_GROUP = "forgezero-deploy";
111
+ export declare const VAULT_GROUP = "forgezero-vault";
111
112
  export declare const DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
112
113
  export declare const DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.socket";
113
114
  export declare const DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
package/dist/provision.js CHANGED
@@ -41,6 +41,7 @@ var modeFor = (capabilities) => capabilities.snpGuest ? "attested" : "enrolled";
41
41
  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.";
42
42
  var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
43
43
  var DEPLOYMENT_GROUP = "forgezero-deploy";
44
+ var VAULT_GROUP = "forgezero-vault";
44
45
  var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
45
46
  var DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.socket";
46
47
  var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
@@ -210,7 +211,7 @@ function agentUnit(options) {
210
211
  `);
211
212
  const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
212
213
  DeviceAllow=/dev/sev-guest rw` : "";
213
- const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${user} /dev/sev-guest
214
+ const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
214
215
  ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
215
216
  ` : "";
216
217
  return `[Unit]
@@ -221,7 +222,7 @@ ${deploymentDependency}
221
222
  [Service]
222
223
  Type=simple
223
224
  User=${user}
224
- Group=${user}
225
+ Group=${VAULT_GROUP}
225
226
  ${deploymentGroup}
226
227
  LoadCredentialEncrypted=agent-seed:${seedCredentialPath}
227
228
  ${gitCredential}${projectCredentials}${projectCredentials ? `
@@ -240,8 +241,8 @@ LimitCORE=0
240
241
  # scope. So it lives in a directory systemd creates with a known owner rather
241
242
  # than wherever the process happened to have write access.
242
243
  RuntimeDirectory=forgezero
243
- RuntimeDirectoryMode=0710
244
- UMask=0077
244
+ RuntimeDirectoryMode=0750
245
+ UMask=0007
245
246
 
246
247
  # Tenant-controlled commands execute in forgezero-deploy-runner.service. This
247
248
  # credential-bearing process never needs to cross a privilege boundary.
@@ -308,6 +309,10 @@ function planProvision(options) {
308
309
  socketPath: options.socketPath,
309
310
  user,
310
311
  steps: [
312
+ {
313
+ label: "vault socket access group",
314
+ command: `groupadd --system ${VAULT_GROUP} || true`
315
+ },
311
316
  ...sourceBinPath && binPath ? [{
312
317
  label: "root-owned agent runtime",
313
318
  command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
@@ -320,6 +325,10 @@ function planProvision(options) {
320
325
  label: "service account",
321
326
  command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
322
327
  },
328
+ {
329
+ label: "bind service account to vault group",
330
+ command: `usermod -g ${VAULT_GROUP} ${user}`
331
+ },
323
332
  ...deploymentEnabled ? [{
324
333
  label: "credential-free deployment account",
325
334
  command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
@@ -391,6 +400,7 @@ export {
391
400
  atLeast,
392
401
  agentUnit,
393
402
  agentEnrolmentUnit,
403
+ VAULT_GROUP,
394
404
  UNIT_PATH,
395
405
  ENROLMENT_UNIT_PATH,
396
406
  DEPLOYMENT_RUNNER_USER,
@@ -8,6 +8,10 @@ interface RemoteProvisionClaimBase {
8
8
  spec: {
9
9
  reference: string;
10
10
  imageKey: string;
11
+ /** Stable bootstrap placement. Online tenant placement normally omits these. */
12
+ guestName?: string;
13
+ guestAddress?: string;
14
+ cpuPoolKey?: string;
11
15
  physicalCores: number;
12
16
  vcpu: number;
13
17
  memoryGib: number;
@@ -17,17 +21,34 @@ interface RemoteProvisionClaimBase {
17
21
  confidential: boolean;
18
22
  };
19
23
  }
24
+ export interface GuestAccess {
25
+ sshUser: string;
26
+ /** Public keys only. Private SSH material is never part of a claim. */
27
+ sshPublicKeys: string[];
28
+ }
20
29
  export type CreateRemoteProvisionClaim = RemoteProvisionClaimBase & {
21
30
  action?: 'create';
31
+ access?: GuestAccess;
32
+ } & ({
22
33
  enrolment: {
23
34
  token: string;
24
35
  tenantKey: string;
25
36
  projectKey: string;
26
37
  environmentKey: string;
27
38
  };
28
- };
39
+ bootstrap?: never;
40
+ } | {
41
+ bootstrap: {
42
+ kind: 'platform-genesis';
43
+ };
44
+ enrolment?: never;
45
+ });
29
46
  export type RemoteProvisionClaim = CreateRemoteProvisionClaim | (RemoteProvisionClaimBase & {
30
47
  action: 'delete';
48
+ /** Present only for the operator-laptop authority before platform genesis. */
49
+ bootstrap?: {
50
+ kind: 'platform-genesis';
51
+ };
31
52
  });
32
53
  export interface ProvisionResult {
33
54
  guestAddress?: string;
@@ -36,6 +57,8 @@ export type ProvisionRunner = (claim: RemoteProvisionClaim) => Promise<Provision
36
57
  export interface ProvisioningPullOptions extends SignedNodeHttpOptions {
37
58
  keys: NodeKeyPair;
38
59
  run: ProvisionRunner;
60
+ /** Inventory record this physical host was deliberately configured to serve. */
61
+ metalHostname?: string;
39
62
  intervalMs?: number;
40
63
  completionAttempts?: number;
41
64
  sleep?: (ms: number) => Promise<void>;