@forgezero/agent 0.1.135 → 0.1.137

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/fz.js CHANGED
@@ -6007,7 +6007,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
6007
6007
  var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
6008
6008
 
6009
6009
  // src/version.ts
6010
- var VERSION2 = "0.1.135";
6010
+ var VERSION2 = "0.1.137";
6011
6011
 
6012
6012
  // src/software.ts
6013
6013
  var PINNED_BUN_VERSION = "1.3.14";
@@ -11572,11 +11572,13 @@ function compileDeployment(sourceValue) {
11572
11572
  if (provisioning.metalHostname !== undefined && !/^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(String(provisioning.metalHostname)))
11573
11573
  fail2(`deployment.spec.targets.${targetName}.provisioning.metalHostname`, "is invalid.");
11574
11574
  const provisionedResources = object(provisioning.resources, `deployment.spec.targets.${targetName}.provisioning.resources`);
11575
- exact3(provisionedResources, ["physicalCores", "vcpu", "memoryGib", "diskGib", "egressGuaranteedMbps", "egressBurstMbps", "confidential"], `deployment.spec.targets.${targetName}.provisioning.resources`);
11575
+ exact3(provisionedResources, ["physicalCores", "vcpu", "memoryGib", "diskGib", "diskEncryption", "egressGuaranteedMbps", "egressBurstMbps", "confidential"], `deployment.spec.targets.${targetName}.provisioning.resources`);
11576
11576
  integer(provisionedResources.physicalCores, `deployment.spec.targets.${targetName}.provisioning.resources.physicalCores`, 1, 1024);
11577
11577
  integer(provisionedResources.vcpu, `deployment.spec.targets.${targetName}.provisioning.resources.vcpu`, 1, 4096);
11578
11578
  integer(provisionedResources.memoryGib, `deployment.spec.targets.${targetName}.provisioning.resources.memoryGib`, 1, 4194304);
11579
11579
  integer(provisionedResources.diskGib, `deployment.spec.targets.${targetName}.provisioning.resources.diskGib`, 1, 1048576);
11580
+ if (provisionedResources.diskEncryption !== undefined && !["none", "luks2"].includes(String(provisionedResources.diskEncryption)))
11581
+ fail2(`deployment.spec.targets.${targetName}.provisioning.resources.diskEncryption`, "must be none or luks2.");
11580
11582
  integer(provisionedResources.egressGuaranteedMbps, `deployment.spec.targets.${targetName}.provisioning.resources.egressGuaranteedMbps`, 0, 1e7);
11581
11583
  integer(provisionedResources.egressBurstMbps, `deployment.spec.targets.${targetName}.provisioning.resources.egressBurstMbps`, Number(provisionedResources.egressGuaranteedMbps), 1e7);
11582
11584
  if (typeof provisionedResources.confidential !== "boolean")
@@ -11945,7 +11947,7 @@ function initCoordinates(options) {
11945
11947
  if (!/^[a-z][a-z0-9-]{0,62}$/.test(value))
11946
11948
  throw new Error(`deployment provisioning ${label} must be a lowercase typed name`);
11947
11949
  const resources = options.provisioning.resources;
11948
- for (const [label, value] of Object.entries(resources).filter(([key]) => key !== "confidential")) {
11950
+ for (const [label, value] of Object.entries(resources).filter(([key]) => !["confidential", "diskEncryption"].includes(key))) {
11949
11951
  if (!Number.isSafeInteger(value) || Number(value) < 0)
11950
11952
  throw new Error(`deployment provisioning ${label} must be a non-negative integer`);
11951
11953
  }
@@ -11954,6 +11956,8 @@ function initCoordinates(options) {
11954
11956
  }
11955
11957
  if (result.isolation === "sev-snp" && !resources.confidential)
11956
11958
  throw new Error("deployment provisioning must provide confidential capacity for sev-snp");
11959
+ if (resources.diskEncryption !== undefined && !["none", "luks2"].includes(resources.diskEncryption))
11960
+ throw new Error("deployment provisioning disk encryption mode is unsupported");
11957
11961
  if (!/^\d{1,18}$/.test(options.provisioning.monthlyAmountCents))
11958
11962
  throw new Error("deployment provisioning monthly amount must be a bounded USD-cent amount");
11959
11963
  if (!/^\d{1,18}$/.test(options.provisioning.maxMonthlySpendMinor))
@@ -11965,7 +11969,10 @@ function initCoordinates(options) {
11965
11969
  if (options.provisioning.metalHostname && !/^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(options.provisioning.metalHostname))
11966
11970
  throw new Error("deployment provisioning metal hostname is invalid");
11967
11971
  }
11968
- return { ...result, ...options.provisioning ? { provisioning: options.provisioning } : {} };
11972
+ return { ...result, ...options.provisioning ? { provisioning: {
11973
+ ...options.provisioning,
11974
+ resources: { ...options.provisioning.resources, diskEncryption: options.provisioning.resources.diskEncryption ?? "luks2" }
11975
+ } } : {} };
11969
11976
  }
11970
11977
  function defaultTypeScriptDeployment(name, options = {}) {
11971
11978
  const selected = initCoordinates(options);
@@ -12430,6 +12437,10 @@ function validatePlatformGenesisGuests(guests, expectedCount = 3) {
12430
12437
  throw new Error("platform genesis vCPU count cannot be smaller than physical cores");
12431
12438
  const memoryGib = integer2(input.memoryGib, 1, 8192, "memory");
12432
12439
  const diskGib = integer2(input.diskGib, 8, 65536, "disk");
12440
+ const diskEncryption = input.diskEncryption ?? "none";
12441
+ if (!["none", "luks2"].includes(diskEncryption)) {
12442
+ throw new Error("platform genesis disk encryption mode is unsupported");
12443
+ }
12433
12444
  const egressGuaranteedMbps = integer2(input.egressGuaranteedMbps, 0, 1e6, "guaranteed egress");
12434
12445
  const egressBurstMbps = integer2(input.egressBurstMbps, egressGuaranteedMbps, 1e6, "burst egress");
12435
12446
  return {
@@ -12441,6 +12452,7 @@ function validatePlatformGenesisGuests(guests, expectedCount = 3) {
12441
12452
  vcpu,
12442
12453
  memoryGib,
12443
12454
  diskGib,
12455
+ diskEncryption,
12444
12456
  egressGuaranteedMbps,
12445
12457
  egressBurstMbps,
12446
12458
  confidential: input.confidential
@@ -12471,6 +12483,7 @@ var claimFor = (guest, keys, action) => {
12471
12483
  vcpu: guest.vcpu,
12472
12484
  memoryGib: guest.memoryGib,
12473
12485
  diskGib: guest.diskGib,
12486
+ diskEncryption: guest.diskEncryption,
12474
12487
  egressGuaranteedMbps: guest.egressGuaranteedMbps,
12475
12488
  egressBurstMbps: guest.egressBurstMbps,
12476
12489
  confidential: guest.confidential
@@ -12560,6 +12573,7 @@ function validatePlatformInitialInventory(value) {
12560
12573
  "vcpu",
12561
12574
  "memoryGib",
12562
12575
  "diskGib",
12576
+ "diskEncryption",
12563
12577
  "egressGuaranteedMbps",
12564
12578
  "egressBurstMbps",
12565
12579
  "confidential",
@@ -15866,6 +15880,7 @@ function strictBootstrapDocument(value) {
15866
15880
  "vcpu",
15867
15881
  "memoryGib",
15868
15882
  "diskGib",
15883
+ "diskEncryption",
15869
15884
  "egressGuaranteedMbps",
15870
15885
  "egressBurstMbps",
15871
15886
  "confidential",
@@ -17100,6 +17115,7 @@ async function applyMetalBootstrap(config, options) {
17100
17115
  "qemu-utils",
17101
17116
  "cloud-image-utils",
17102
17117
  "lvm2",
17118
+ "cryptsetup",
17103
17119
  "nftables",
17104
17120
  "curl",
17105
17121
  "ca-certificates"
@@ -17534,11 +17550,12 @@ var validateMetalRequestValue = (value, options = {}) => {
17534
17550
  "vcpu",
17535
17551
  "memoryGib",
17536
17552
  "diskGib",
17553
+ "diskEncryption",
17537
17554
  "egressGuaranteedMbps",
17538
17555
  "egressBurstMbps",
17539
17556
  "confidential"
17540
17557
  ], label);
17541
- if (typeof row2.name !== "string" || typeof row2.address !== "string" || typeof row2.imageKey !== "string" || row2.cpuPoolKey !== undefined && typeof row2.cpuPoolKey !== "string" || !["physicalCores", "vcpu", "memoryGib", "diskGib", "egressGuaranteedMbps", "egressBurstMbps"].every((key) => typeof row2[key] === "number") || typeof row2.confidential !== "boolean") {
17558
+ if (typeof row2.name !== "string" || typeof row2.address !== "string" || typeof row2.imageKey !== "string" || row2.cpuPoolKey !== undefined && typeof row2.cpuPoolKey !== "string" || !["physicalCores", "vcpu", "memoryGib", "diskGib", "egressGuaranteedMbps", "egressBurstMbps"].every((key) => typeof row2[key] === "number") || row2.diskEncryption !== undefined && !["none", "luks2"].includes(row2.diskEncryption) || typeof row2.confidential !== "boolean") {
17542
17559
  throw new Error(`${label} has invalid resource coordinates`);
17543
17560
  }
17544
17561
  return {
@@ -17550,6 +17567,7 @@ var validateMetalRequestValue = (value, options = {}) => {
17550
17567
  vcpu: row2.vcpu,
17551
17568
  memoryGib: row2.memoryGib,
17552
17569
  diskGib: row2.diskGib,
17570
+ diskEncryption: row2.diskEncryption ?? "none",
17553
17571
  egressGuaranteedMbps: row2.egressGuaranteedMbps,
17554
17572
  egressBurstMbps: row2.egressBurstMbps,
17555
17573
  confidential: row2.confidential
@@ -19153,6 +19171,13 @@ function parseOptions(argv2) {
19153
19171
  } else if (token === "--provision-metal") {
19154
19172
  options.deployProvisionMetal = argv2[++index];
19155
19173
  options.deployInitCustomized = true;
19174
+ } else if (token === "--provision-disk-encryption") {
19175
+ const value = argv2[++index];
19176
+ options.deployInitCustomized = true;
19177
+ if (value === "none" || value === "luks2")
19178
+ options.deployProvisionDiskEncryption = value;
19179
+ else
19180
+ options.optionError = "--provision-disk-encryption must be none or luks2.";
19156
19181
  } else if (["--provision-physical-cores", "--provision-vcpu", "--provision-memory-gib", "--provision-disk-gib", "--provision-guaranteed-mbps", "--provision-burst-mbps"].includes(token)) {
19157
19182
  const value = Number(argv2[++index] ?? "");
19158
19183
  options.deployInitCustomized = true;
@@ -20001,6 +20026,7 @@ function interactiveGenesisGuests() {
20001
20026
  vcpu: bootstrapNumber(`Guest ${index} vCPUs`),
20002
20027
  memoryGib: bootstrapNumber(`Guest ${index} memory GiB`),
20003
20028
  diskGib: bootstrapNumber(`Guest ${index} disk GiB`),
20029
+ diskEncryption: bootstrapBoolean(`Guest ${index} encrypts its persistent disk with LUKS2?`, "yes") ? "luks2" : "none",
20004
20030
  egressGuaranteedMbps: bootstrapNumber(`Guest ${index} guaranteed egress Mbps`),
20005
20031
  egressBurstMbps: bootstrapNumber(`Guest ${index} burst egress Mbps`),
20006
20032
  confidential: bootstrapBoolean(`Guest ${index} requires confidential SEV-SNP compute?`)
@@ -21014,6 +21040,7 @@ async function cmdDeploy(options, args) {
21014
21040
  options.deployProvisionMemoryGib,
21015
21041
  options.deployProvisionDiskGib,
21016
21042
  options.deployProvisionGuaranteedMbps,
21043
+ options.deployProvisionDiskEncryption,
21017
21044
  options.deployProvisionBurstMbps,
21018
21045
  options.deployProvisionMonthlyAmountCents,
21019
21046
  options.deployMaxMonthlySpendMinor
@@ -21029,6 +21056,7 @@ async function cmdDeploy(options, args) {
21029
21056
  vcpu: options.deployProvisionVcpu ?? 0,
21030
21057
  memoryGib: options.deployProvisionMemoryGib ?? 0,
21031
21058
  diskGib: options.deployProvisionDiskGib ?? 0,
21059
+ diskEncryption: options.deployProvisionDiskEncryption ?? "luks2",
21032
21060
  egressGuaranteedMbps: options.deployProvisionGuaranteedMbps ?? 0,
21033
21061
  egressBurstMbps: options.deployProvisionBurstMbps ?? 0,
21034
21062
  confidential: options.deployIsolation === "sev-snp"
@@ -21376,7 +21404,7 @@ function usage() {
21376
21404
  --provision-environment <key> --provision-ownership <platform|tenant-metal>
21377
21405
  --provision-metal <host> Required only for tenant-metal ownership
21378
21406
  --provision-physical-cores <n> --provision-vcpu <n>
21379
- --provision-memory-gib <n> --provision-disk-gib <n>
21407
+ --provision-memory-gib <n> --provision-disk-gib <n> --provision-disk-encryption <none|luks2>
21380
21408
  --provision-guaranteed-mbps <n> --provision-burst-mbps <n>
21381
21409
  --provision-monthly-amount-cents <n> Exact approved USD cost per created compute
21382
21410
  --max-monthly-spend-minor <n> Hard approved scale-out spend ceiling
@@ -366,7 +366,7 @@ function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
366
366
  }
367
367
 
368
368
  // src/version.ts
369
- var VERSION = "0.1.135";
369
+ var VERSION = "0.1.137";
370
370
 
371
371
  // src/otel-collector.ts
372
372
  var FORGEZERO_OTEL_COLLECTOR_UNIT = "forgezero-otel-collector.service";
@@ -1058,6 +1058,7 @@ async function applyMetalBootstrap(config, options) {
1058
1058
  "qemu-utils",
1059
1059
  "cloud-image-utils",
1060
1060
  "lvm2",
1061
+ "cryptsetup",
1061
1062
  "nftables",
1062
1063
  "curl",
1063
1064
  "ca-certificates"
@@ -56,6 +56,10 @@ function guestUnit(spec) {
56
56
  `ExecStopPost=-/usr/sbin/ip link del ${spec.tap}`
57
57
  ].join(`
58
58
  `) : "";
59
+ const encryptedDisk = spec.diskEncryption ? `LoadCredentialEncrypted=${spec.diskEncryption.credentialName}:${spec.diskEncryption.credentialPath}
60
+ ExecStartPre=/usr/sbin/cryptsetup open --type luks2 --key-file %d/${spec.diskEncryption.credentialName} ${spec.diskEncryption.source} ${spec.diskEncryption.mapper}
61
+ ExecStopPost=-/usr/sbin/cryptsetup close ${spec.diskEncryption.mapper}
62
+ ` : "";
59
63
  return `[Unit]
60
64
  Description=ForgeZero guest ${spec.name}
61
65
  Documentation=https://www.forgezero.net
@@ -65,7 +69,7 @@ Wants=network-online.target
65
69
  [Service]
66
70
  Type=simple
67
71
  Slice=forgezero-guests.slice
68
- ${tapSetup}
72
+ ${encryptedDisk}${tapSetup}
69
73
  ExecStart=${argv}
70
74
  Restart=always
71
75
  RestartSec=5
@@ -754,8 +758,9 @@ function assertSupportedGuestImage(imageKey, confidential = false) {
754
758
  }
755
759
 
756
760
  // src/metal-provision.ts
757
- import { createHash as createHash2 } from "node:crypto";
761
+ import { createHash as createHash2, randomBytes } from "node:crypto";
758
762
  import {
763
+ chmodSync as chmodSync2,
759
764
  existsSync as existsSync2,
760
765
  mkdirSync as mkdirSync2,
761
766
  readFileSync as readFileSync2,
@@ -828,6 +833,7 @@ var macForAddress = (address) => {
828
833
  }
829
834
  return `52:54:00:f0:${octets[2].toString(16).padStart(2, "0")}:${octets[3].toString(16).padStart(2, "0")}`;
830
835
  };
836
+ var shouldCloseGuestMapper = (openedHere, active) => openedHere && active;
831
837
  function validateMetalProfile(profile) {
832
838
  if (!SAFE_NAME.test(profile.volumeGroup))
833
839
  throw new MetalProvisionError("invalid volume group");
@@ -989,6 +995,8 @@ function guestBootstrapOperations(profile, attested = Boolean(profile.confidenti
989
995
  validateMetalProfile(profile);
990
996
  const agentBun = "/usr/local/lib/forgezero/bun";
991
997
  return [
998
+ ["/usr/sbin/swapoff", "--all"],
999
+ ["/usr/bin/systemctl", "mask", "--now", "swap.target"],
992
1000
  ...attested ? [
993
1001
  ["/usr/sbin/modprobe", "sev-guest"],
994
1002
  ["/usr/bin/test", "-c", "/dev/sev-guest"]
@@ -1065,6 +1073,7 @@ ${access.sshPublicKeys.map((key) => ` - ${JSON.stringify(key)}`).join(`
1065
1073
  return {
1066
1074
  userData: `#cloud-config
1067
1075
  package_update: true
1076
+ swap: { size: 0 }
1068
1077
  ${users}disable_root: true
1069
1078
  # Git is part of the deployment transport, not a tenant-selected prerequisite:
1070
1079
  # every dynamically claimed repository must be cloneable on a clean image.
@@ -1097,7 +1106,8 @@ var checked = async (exec, argv) => {
1097
1106
  async function provisionMetalGuest(profile, claim, exec) {
1098
1107
  validateMetalProfile(profile);
1099
1108
  assertSupportedGuestImage(claim.spec.imageKey, claim.spec.confidential);
1100
- 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)
1109
+ const diskEncryption = claim.spec.diskEncryption ?? "none";
1110
+ 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 || !["none", "luks2"].includes(diskEncryption) || !Number.isInteger(claim.spec.egressGuaranteedMbps) || claim.spec.egressGuaranteedMbps < 0 || !Number.isInteger(claim.spec.egressBurstMbps) || claim.spec.egressBurstMbps < claim.spec.egressGuaranteedMbps)
1101
1111
  throw new MetalProvisionError("invalid compute claim");
1102
1112
  const image = profile.images[claim.spec.imageKey];
1103
1113
  if (!image || !isAbsolute(image.path) || !SHA256.test(image.sha256)) {
@@ -1126,6 +1136,9 @@ async function provisionMetalGuest(profile, claim, exec) {
1126
1136
  const prior = manifests.find((row) => row.computeKey === claim.computeKey);
1127
1137
  if (prior && prior.reference !== claim.spec.reference)
1128
1138
  throw new MetalProvisionError("compute identity conflicts with host inventory");
1139
+ if (prior && (prior.diskEncryption ?? "none") !== diskEncryption) {
1140
+ throw new MetalProvisionError("an allocated guest disk encryption mode is immutable");
1141
+ }
1129
1142
  const address = requestedAddress(profile, claim, manifests);
1130
1143
  const cpuPool = allocateCpuPool(profile, claim, manifests);
1131
1144
  const manifest = prior ?? {
@@ -1137,6 +1150,7 @@ async function provisionMetalGuest(profile, claim, exec) {
1137
1150
  cpuPoolKey: cpuPool.key,
1138
1151
  allowedCpus: cpuPool.cpus,
1139
1152
  allowedMemoryNodes: cpuPool.memoryNodes,
1153
+ diskEncryption,
1140
1154
  phase: "allocating"
1141
1155
  };
1142
1156
  const save = () => writeFileSync2(manifestPath, `${JSON.stringify(manifest, null, 2)}
@@ -1146,8 +1160,72 @@ async function provisionMetalGuest(profile, claim, exec) {
1146
1160
  const exists = (await exec(["lvs", "--noheadings", lv])).exitCode === 0;
1147
1161
  if (!exists)
1148
1162
  await checked(exec, ["lvcreate", "-y", "-n", name, "-L", `${claim.spec.diskGib}G`, profile.volumeGroup]);
1163
+ const encrypted = diskEncryption === "luks2";
1164
+ const mapper = `fz-${createHash2("sha256").update(claim.computeKey).digest("hex").slice(0, 20)}`;
1165
+ const mappedDisk = `/dev/mapper/${mapper}`;
1166
+ const credentialName = `guest-disk-${createHash2("sha256").update(claim.computeKey).digest("hex").slice(0, 20)}`;
1167
+ const credentialPath = `/etc/forgezero/creds/${credentialName}.cred`;
1168
+ const keyPath = `/run/forgezero/${credentialName}.key`;
1169
+ let imageTarget = lv;
1170
+ let mapperOpenedHere = false;
1171
+ if (encrypted) {
1172
+ mkdirSync2(dirname2(credentialPath), { recursive: true, mode: 448 });
1173
+ mkdirSync2(dirname2(keyPath), { recursive: true, mode: 448 });
1174
+ if (existsSync2(keyPath))
1175
+ unlinkSync2(keyPath);
1176
+ if (!existsSync2(credentialPath)) {
1177
+ if (exists && (await exec(["cryptsetup", "isLuks", lv])).exitCode === 0) {
1178
+ throw new MetalProvisionError("encrypted guest disk exists without its sealed unlock credential");
1179
+ }
1180
+ writeFileSync2(keyPath, randomBytes(64), { mode: 384, flag: "wx" });
1181
+ try {
1182
+ await checked(exec, ["systemd-creds", "encrypt", `--name=${credentialName}`, keyPath, credentialPath]);
1183
+ chmodSync2(credentialPath, 384);
1184
+ } catch (cause) {
1185
+ if (existsSync2(credentialPath))
1186
+ unlinkSync2(credentialPath);
1187
+ throw cause;
1188
+ }
1189
+ } else {
1190
+ await checked(exec, ["systemd-creds", "decrypt", `--name=${credentialName}`, credentialPath, keyPath]);
1191
+ }
1192
+ try {
1193
+ if ((await exec(["cryptsetup", "isLuks", lv])).exitCode !== 0) {
1194
+ await checked(exec, [
1195
+ "cryptsetup",
1196
+ "luksFormat",
1197
+ "--batch-mode",
1198
+ "--type",
1199
+ "luks2",
1200
+ "--cipher",
1201
+ "aes-xts-plain64",
1202
+ "--key-size",
1203
+ "512",
1204
+ "--key-file",
1205
+ keyPath,
1206
+ lv
1207
+ ]);
1208
+ }
1209
+ if ((await exec(["cryptsetup", "status", mapper])).exitCode !== 0) {
1210
+ await checked(exec, ["cryptsetup", "open", "--type", "luks2", "--key-file", keyPath, lv, mapper]);
1211
+ mapperOpenedHere = true;
1212
+ }
1213
+ imageTarget = mappedDisk;
1214
+ if (manifest.phase === "allocating") {
1215
+ await checked(exec, ["qemu-img", "convert", "-O", "raw", image.path, imageTarget]);
1216
+ manifest.phase = "image-ready";
1217
+ save();
1218
+ }
1219
+ } finally {
1220
+ if (existsSync2(keyPath))
1221
+ unlinkSync2(keyPath);
1222
+ if (shouldCloseGuestMapper(mapperOpenedHere, (await exec(["cryptsetup", "status", mapper])).exitCode === 0)) {
1223
+ await checked(exec, ["cryptsetup", "close", mapper]);
1224
+ }
1225
+ }
1226
+ }
1149
1227
  if (manifest.phase === "allocating") {
1150
- await checked(exec, ["qemu-img", "convert", "-O", "raw", image.path, lv]);
1228
+ await checked(exec, ["qemu-img", "convert", "-O", "raw", image.path, imageTarget]);
1151
1229
  manifest.phase = "image-ready";
1152
1230
  save();
1153
1231
  }
@@ -1171,7 +1249,8 @@ async function provisionMetalGuest(profile, claim, exec) {
1171
1249
  memoryGib: claim.spec.memoryGib,
1172
1250
  allowedCpus: manifest.allowedCpus,
1173
1251
  allowedMemoryNodes: manifest.allowedMemoryNodes,
1174
- disk: lv,
1252
+ disk: encrypted ? mappedDisk : lv,
1253
+ ...encrypted ? { diskEncryption: { source: lv, mapper, credentialName, credentialPath } } : {},
1175
1254
  seed,
1176
1255
  bridge: profile.bridge,
1177
1256
  mac: manifest.mac,
@@ -1229,6 +1308,7 @@ function legacyPlatformManifest(profile, claim, name) {
1229
1308
  cpuPoolKey: claim.spec.cpuPoolKey ?? "",
1230
1309
  allowedCpus: values.get("CPUSET") ?? "",
1231
1310
  allowedMemoryNodes: values.get("NUMA"),
1311
+ diskEncryption: "none",
1232
1312
  phase: "running",
1233
1313
  disk,
1234
1314
  unit: `forgezero-guest@${name}.service`
@@ -1243,6 +1323,9 @@ async function removeMetalGuest(profile, claim, exec) {
1243
1323
  const service = `forgezero-guest@${name}.service`;
1244
1324
  const unitPath = join2(profile.unitDir, service);
1245
1325
  const lv = `/dev/${profile.volumeGroup}/${name}`;
1326
+ const mapper = `fz-${createHash2("sha256").update(claim.computeKey).digest("hex").slice(0, 20)}`;
1327
+ const credentialName = `guest-disk-${createHash2("sha256").update(claim.computeKey).digest("hex").slice(0, 20)}`;
1328
+ const credentialPath = `/etc/forgezero/creds/${credentialName}.cred`;
1246
1329
  const manifest = existsSync2(manifestPath) ? JSON.parse(readFileSync2(manifestPath, "utf8")) : legacyPlatformManifest(profile, claim, name);
1247
1330
  if (!manifest) {
1248
1331
  const seedBase = join2(profile.seedDir, name);
@@ -1277,9 +1360,14 @@ async function removeMetalGuest(profile, claim, exec) {
1277
1360
  else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
1278
1361
  throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
1279
1362
  }
1363
+ const encrypted = (manifest.diskEncryption ?? "none") === "luks2";
1364
+ if (encrypted && (await exec(["cryptsetup", "status", mapper])).exitCode === 0) {
1365
+ throw new MetalProvisionError("guest disk mapping remained active after the guest stopped");
1366
+ }
1280
1367
  if ((await exec(["lvs", "--noheadings", lv])).exitCode === 0)
1281
1368
  await checked(exec, ["lvremove", "-fy", lv]);
1282
1369
  for (const path2 of [
1370
+ ...encrypted ? [credentialPath] : [],
1283
1371
  unitPath,
1284
1372
  join2(profile.seedDir, `${name}-seed.iso`),
1285
1373
  join2(profile.seedDir, `${name}-user-data`),
@@ -1302,7 +1390,7 @@ async function removeMetalGuest(profile, claim, exec) {
1302
1390
  }
1303
1391
 
1304
1392
  // src/metal-helper-socket.ts
1305
- import { chmodSync as chmodSync2, existsSync as existsSync3, unlinkSync as unlinkSync3 } from "node:fs";
1393
+ import { chmodSync as chmodSync3, existsSync as existsSync3, unlinkSync as unlinkSync3 } from "node:fs";
1306
1394
  import { connect, createServer } from "node:net";
1307
1395
  var DEFAULT_METAL_HELPER_SOCKET = "/run/forgezero-metal/helper.sock";
1308
1396
  var MAX_REQUEST_BYTES = 32 * 1024;
@@ -1382,7 +1470,7 @@ function startMetalHelper(options) {
1382
1470
  });
1383
1471
  socket.on("error", () => socket.destroy());
1384
1472
  });
1385
- server.listen(socketPath, () => chmodSync2(socketPath, 432));
1473
+ server.listen(socketPath, () => chmodSync3(socketPath, 432));
1386
1474
  return {
1387
1475
  server,
1388
1476
  async stop() {
@@ -63,6 +63,7 @@ export interface GuestManifest {
63
63
  cpuPoolKey: string;
64
64
  allowedCpus: string;
65
65
  allowedMemoryNodes?: string;
66
+ diskEncryption?: 'none' | 'luks2';
66
67
  phase: 'allocating' | 'image-ready' | 'running';
67
68
  }
68
69
  export declare class MetalProvisionError extends Error {
@@ -71,6 +72,8 @@ export declare const guestNameFor: (computeKey: string) => string;
71
72
  /** Linux interface names are capped at 15 bytes. */
72
73
  export declare const tapNameFor: (computeKey: string) => string;
73
74
  export declare const macForAddress: (address: string) => string;
75
+ /** A reconciler may close only the transient mapper it opened itself. */
76
+ export declare const shouldCloseGuestMapper: (openedHere: boolean, active: boolean) => boolean;
74
77
  export declare function validateMetalProfile(profile: MetalProvisionProfile): void;
75
78
  export declare function allocateAddress(profile: MetalProvisionProfile, computeKey: string, rows: GuestManifest[]): string;
76
79
  /** Stable tight-fit allocation of exact physical cores inside one NUMA-local pool. */
@@ -56,6 +56,10 @@ function guestUnit(spec) {
56
56
  `ExecStopPost=-/usr/sbin/ip link del ${spec.tap}`
57
57
  ].join(`
58
58
  `) : "";
59
+ const encryptedDisk = spec.diskEncryption ? `LoadCredentialEncrypted=${spec.diskEncryption.credentialName}:${spec.diskEncryption.credentialPath}
60
+ ExecStartPre=/usr/sbin/cryptsetup open --type luks2 --key-file %d/${spec.diskEncryption.credentialName} ${spec.diskEncryption.source} ${spec.diskEncryption.mapper}
61
+ ExecStopPost=-/usr/sbin/cryptsetup close ${spec.diskEncryption.mapper}
62
+ ` : "";
59
63
  return `[Unit]
60
64
  Description=ForgeZero guest ${spec.name}
61
65
  Documentation=https://www.forgezero.net
@@ -65,7 +69,7 @@ Wants=network-online.target
65
69
  [Service]
66
70
  Type=simple
67
71
  Slice=forgezero-guests.slice
68
- ${tapSetup}
72
+ ${encryptedDisk}${tapSetup}
69
73
  ExecStart=${argv}
70
74
  Restart=always
71
75
  RestartSec=5
@@ -754,8 +758,9 @@ function assertSupportedGuestImage(imageKey, confidential = false) {
754
758
  }
755
759
 
756
760
  // src/metal-provision.ts
757
- import { createHash as createHash2 } from "node:crypto";
761
+ import { createHash as createHash2, randomBytes } from "node:crypto";
758
762
  import {
763
+ chmodSync as chmodSync2,
759
764
  existsSync as existsSync2,
760
765
  mkdirSync as mkdirSync2,
761
766
  readFileSync as readFileSync2,
@@ -828,6 +833,7 @@ var macForAddress = (address) => {
828
833
  }
829
834
  return `52:54:00:f0:${octets[2].toString(16).padStart(2, "0")}:${octets[3].toString(16).padStart(2, "0")}`;
830
835
  };
836
+ var shouldCloseGuestMapper = (openedHere, active) => openedHere && active;
831
837
  function validateMetalProfile(profile) {
832
838
  if (!SAFE_NAME.test(profile.volumeGroup))
833
839
  throw new MetalProvisionError("invalid volume group");
@@ -989,6 +995,8 @@ function guestBootstrapOperations(profile, attested = Boolean(profile.confidenti
989
995
  validateMetalProfile(profile);
990
996
  const agentBun = "/usr/local/lib/forgezero/bun";
991
997
  return [
998
+ ["/usr/sbin/swapoff", "--all"],
999
+ ["/usr/bin/systemctl", "mask", "--now", "swap.target"],
992
1000
  ...attested ? [
993
1001
  ["/usr/sbin/modprobe", "sev-guest"],
994
1002
  ["/usr/bin/test", "-c", "/dev/sev-guest"]
@@ -1065,6 +1073,7 @@ ${access.sshPublicKeys.map((key) => ` - ${JSON.stringify(key)}`).join(`
1065
1073
  return {
1066
1074
  userData: `#cloud-config
1067
1075
  package_update: true
1076
+ swap: { size: 0 }
1068
1077
  ${users}disable_root: true
1069
1078
  # Git is part of the deployment transport, not a tenant-selected prerequisite:
1070
1079
  # every dynamically claimed repository must be cloneable on a clean image.
@@ -1097,7 +1106,8 @@ var checked = async (exec, argv) => {
1097
1106
  async function provisionMetalGuest(profile, claim, exec) {
1098
1107
  validateMetalProfile(profile);
1099
1108
  assertSupportedGuestImage(claim.spec.imageKey, claim.spec.confidential);
1100
- 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)
1109
+ const diskEncryption = claim.spec.diskEncryption ?? "none";
1110
+ 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 || !["none", "luks2"].includes(diskEncryption) || !Number.isInteger(claim.spec.egressGuaranteedMbps) || claim.spec.egressGuaranteedMbps < 0 || !Number.isInteger(claim.spec.egressBurstMbps) || claim.spec.egressBurstMbps < claim.spec.egressGuaranteedMbps)
1101
1111
  throw new MetalProvisionError("invalid compute claim");
1102
1112
  const image = profile.images[claim.spec.imageKey];
1103
1113
  if (!image || !isAbsolute(image.path) || !SHA256.test(image.sha256)) {
@@ -1126,6 +1136,9 @@ async function provisionMetalGuest(profile, claim, exec) {
1126
1136
  const prior = manifests.find((row) => row.computeKey === claim.computeKey);
1127
1137
  if (prior && prior.reference !== claim.spec.reference)
1128
1138
  throw new MetalProvisionError("compute identity conflicts with host inventory");
1139
+ if (prior && (prior.diskEncryption ?? "none") !== diskEncryption) {
1140
+ throw new MetalProvisionError("an allocated guest disk encryption mode is immutable");
1141
+ }
1129
1142
  const address = requestedAddress(profile, claim, manifests);
1130
1143
  const cpuPool = allocateCpuPool(profile, claim, manifests);
1131
1144
  const manifest = prior ?? {
@@ -1137,6 +1150,7 @@ async function provisionMetalGuest(profile, claim, exec) {
1137
1150
  cpuPoolKey: cpuPool.key,
1138
1151
  allowedCpus: cpuPool.cpus,
1139
1152
  allowedMemoryNodes: cpuPool.memoryNodes,
1153
+ diskEncryption,
1140
1154
  phase: "allocating"
1141
1155
  };
1142
1156
  const save = () => writeFileSync2(manifestPath, `${JSON.stringify(manifest, null, 2)}
@@ -1146,8 +1160,72 @@ async function provisionMetalGuest(profile, claim, exec) {
1146
1160
  const exists = (await exec(["lvs", "--noheadings", lv])).exitCode === 0;
1147
1161
  if (!exists)
1148
1162
  await checked(exec, ["lvcreate", "-y", "-n", name, "-L", `${claim.spec.diskGib}G`, profile.volumeGroup]);
1163
+ const encrypted = diskEncryption === "luks2";
1164
+ const mapper = `fz-${createHash2("sha256").update(claim.computeKey).digest("hex").slice(0, 20)}`;
1165
+ const mappedDisk = `/dev/mapper/${mapper}`;
1166
+ const credentialName = `guest-disk-${createHash2("sha256").update(claim.computeKey).digest("hex").slice(0, 20)}`;
1167
+ const credentialPath = `/etc/forgezero/creds/${credentialName}.cred`;
1168
+ const keyPath = `/run/forgezero/${credentialName}.key`;
1169
+ let imageTarget = lv;
1170
+ let mapperOpenedHere = false;
1171
+ if (encrypted) {
1172
+ mkdirSync2(dirname2(credentialPath), { recursive: true, mode: 448 });
1173
+ mkdirSync2(dirname2(keyPath), { recursive: true, mode: 448 });
1174
+ if (existsSync2(keyPath))
1175
+ unlinkSync2(keyPath);
1176
+ if (!existsSync2(credentialPath)) {
1177
+ if (exists && (await exec(["cryptsetup", "isLuks", lv])).exitCode === 0) {
1178
+ throw new MetalProvisionError("encrypted guest disk exists without its sealed unlock credential");
1179
+ }
1180
+ writeFileSync2(keyPath, randomBytes(64), { mode: 384, flag: "wx" });
1181
+ try {
1182
+ await checked(exec, ["systemd-creds", "encrypt", `--name=${credentialName}`, keyPath, credentialPath]);
1183
+ chmodSync2(credentialPath, 384);
1184
+ } catch (cause) {
1185
+ if (existsSync2(credentialPath))
1186
+ unlinkSync2(credentialPath);
1187
+ throw cause;
1188
+ }
1189
+ } else {
1190
+ await checked(exec, ["systemd-creds", "decrypt", `--name=${credentialName}`, credentialPath, keyPath]);
1191
+ }
1192
+ try {
1193
+ if ((await exec(["cryptsetup", "isLuks", lv])).exitCode !== 0) {
1194
+ await checked(exec, [
1195
+ "cryptsetup",
1196
+ "luksFormat",
1197
+ "--batch-mode",
1198
+ "--type",
1199
+ "luks2",
1200
+ "--cipher",
1201
+ "aes-xts-plain64",
1202
+ "--key-size",
1203
+ "512",
1204
+ "--key-file",
1205
+ keyPath,
1206
+ lv
1207
+ ]);
1208
+ }
1209
+ if ((await exec(["cryptsetup", "status", mapper])).exitCode !== 0) {
1210
+ await checked(exec, ["cryptsetup", "open", "--type", "luks2", "--key-file", keyPath, lv, mapper]);
1211
+ mapperOpenedHere = true;
1212
+ }
1213
+ imageTarget = mappedDisk;
1214
+ if (manifest.phase === "allocating") {
1215
+ await checked(exec, ["qemu-img", "convert", "-O", "raw", image.path, imageTarget]);
1216
+ manifest.phase = "image-ready";
1217
+ save();
1218
+ }
1219
+ } finally {
1220
+ if (existsSync2(keyPath))
1221
+ unlinkSync2(keyPath);
1222
+ if (shouldCloseGuestMapper(mapperOpenedHere, (await exec(["cryptsetup", "status", mapper])).exitCode === 0)) {
1223
+ await checked(exec, ["cryptsetup", "close", mapper]);
1224
+ }
1225
+ }
1226
+ }
1149
1227
  if (manifest.phase === "allocating") {
1150
- await checked(exec, ["qemu-img", "convert", "-O", "raw", image.path, lv]);
1228
+ await checked(exec, ["qemu-img", "convert", "-O", "raw", image.path, imageTarget]);
1151
1229
  manifest.phase = "image-ready";
1152
1230
  save();
1153
1231
  }
@@ -1171,7 +1249,8 @@ async function provisionMetalGuest(profile, claim, exec) {
1171
1249
  memoryGib: claim.spec.memoryGib,
1172
1250
  allowedCpus: manifest.allowedCpus,
1173
1251
  allowedMemoryNodes: manifest.allowedMemoryNodes,
1174
- disk: lv,
1252
+ disk: encrypted ? mappedDisk : lv,
1253
+ ...encrypted ? { diskEncryption: { source: lv, mapper, credentialName, credentialPath } } : {},
1175
1254
  seed,
1176
1255
  bridge: profile.bridge,
1177
1256
  mac: manifest.mac,
@@ -1229,6 +1308,7 @@ function legacyPlatformManifest(profile, claim, name) {
1229
1308
  cpuPoolKey: claim.spec.cpuPoolKey ?? "",
1230
1309
  allowedCpus: values.get("CPUSET") ?? "",
1231
1310
  allowedMemoryNodes: values.get("NUMA"),
1311
+ diskEncryption: "none",
1232
1312
  phase: "running",
1233
1313
  disk,
1234
1314
  unit: `forgezero-guest@${name}.service`
@@ -1243,6 +1323,9 @@ async function removeMetalGuest(profile, claim, exec) {
1243
1323
  const service = `forgezero-guest@${name}.service`;
1244
1324
  const unitPath = join2(profile.unitDir, service);
1245
1325
  const lv = `/dev/${profile.volumeGroup}/${name}`;
1326
+ const mapper = `fz-${createHash2("sha256").update(claim.computeKey).digest("hex").slice(0, 20)}`;
1327
+ const credentialName = `guest-disk-${createHash2("sha256").update(claim.computeKey).digest("hex").slice(0, 20)}`;
1328
+ const credentialPath = `/etc/forgezero/creds/${credentialName}.cred`;
1246
1329
  const manifest = existsSync2(manifestPath) ? JSON.parse(readFileSync2(manifestPath, "utf8")) : legacyPlatformManifest(profile, claim, name);
1247
1330
  if (!manifest) {
1248
1331
  const seedBase = join2(profile.seedDir, name);
@@ -1277,9 +1360,14 @@ async function removeMetalGuest(profile, claim, exec) {
1277
1360
  else if ((await exec(["systemctl", "is-active", service])).exitCode === 0) {
1278
1361
  throw new MetalProvisionError("guest unit is active but its owned unit file is missing");
1279
1362
  }
1363
+ const encrypted = (manifest.diskEncryption ?? "none") === "luks2";
1364
+ if (encrypted && (await exec(["cryptsetup", "status", mapper])).exitCode === 0) {
1365
+ throw new MetalProvisionError("guest disk mapping remained active after the guest stopped");
1366
+ }
1280
1367
  if ((await exec(["lvs", "--noheadings", lv])).exitCode === 0)
1281
1368
  await checked(exec, ["lvremove", "-fy", lv]);
1282
1369
  for (const path2 of [
1370
+ ...encrypted ? [credentialPath] : [],
1283
1371
  unitPath,
1284
1372
  join2(profile.seedDir, `${name}-seed.iso`),
1285
1373
  join2(profile.seedDir, `${name}-user-data`),
@@ -1303,6 +1391,7 @@ async function removeMetalGuest(profile, claim, exec) {
1303
1391
  export {
1304
1392
  validateMetalProfile,
1305
1393
  tapNameFor,
1394
+ shouldCloseGuestMapper,
1306
1395
  removeMetalGuest,
1307
1396
  provisionMetalGuest,
1308
1397
  macForAddress,