@forgezero/agent 0.1.102 → 0.1.107

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.
@@ -43,6 +43,14 @@ export interface MetalBootstrapApplyOptions {
43
43
  }
44
44
  export declare class MetalBootstrapError extends Error {
45
45
  }
46
+ export interface MetalPublicIdentity {
47
+ nodeKey: string;
48
+ publicKeys: {
49
+ ed25519: string;
50
+ mlDsa: string;
51
+ };
52
+ }
53
+ export declare function parseMetalPublicIdentity(value: string): MetalPublicIdentity;
46
54
  export declare function normalizeMetalPublicIdentity(value: string): string;
47
55
  export interface MetalBootstrapValidationOptions {
48
56
  /**
@@ -366,7 +366,7 @@ function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
366
366
  }
367
367
 
368
368
  // src/version.ts
369
- var VERSION = "0.1.102";
369
+ var VERSION = "0.1.107";
370
370
 
371
371
  // src/otel-collector.ts
372
372
  var FORGEZERO_OTEL_COLLECTOR_UNIT = "forgezero-otel-collector.service";
@@ -543,7 +543,7 @@ var SUPPORTED_BUN_RELEASE_SHA256 = "951ee2aee855f08595aeec6225226a298d3fea83a3dc
543
543
 
544
544
  class MetalBootstrapError extends Error {
545
545
  }
546
- function normalizeMetalPublicIdentity(value) {
546
+ function parseMetalPublicIdentity(value) {
547
547
  let parsed;
548
548
  try {
549
549
  parsed = JSON.parse(value);
@@ -558,7 +558,13 @@ function normalizeMetalPublicIdentity(value) {
558
558
  if (Object.keys(identity).some((key) => !["nodeKey", "publicKeys"].includes(key)) || typeof identity.nodeKey !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(identity.nodeKey) || !keys || Object.keys(keys).some((key) => !["ed25519", "mlDsa"].includes(key)) || keys.ed25519 !== identity.nodeKey || typeof keys.mlDsa !== "string" || !/^[A-Za-z0-9_-]{1,8192}$/.test(keys.mlDsa)) {
559
559
  throw new MetalBootstrapError("metal public identity proof was invalid");
560
560
  }
561
- return JSON.stringify(parsed);
561
+ return {
562
+ nodeKey: identity.nodeKey,
563
+ publicKeys: { ed25519: keys.ed25519, mlDsa: keys.mlDsa }
564
+ };
565
+ }
566
+ function normalizeMetalPublicIdentity(value) {
567
+ return JSON.stringify(parseMetalPublicIdentity(value));
562
568
  }
563
569
  var defaultExec2 = async (argv, stdin) => {
564
570
  const child = Bun.spawn([...argv], {
@@ -1258,6 +1264,7 @@ export {
1258
1264
  renderMetalUnits,
1259
1265
  readMetalBootstrapConfig,
1260
1266
  planMetalBootstrap,
1267
+ parseMetalPublicIdentity,
1261
1268
  normalizeMetalPublicIdentity,
1262
1269
  metalBootstrapStatus,
1263
1270
  applyMetalBootstrap,
@@ -1,4 +1,5 @@
1
1
  import { type PlatformBootstrapConfig, type PlatformBootstrapSecrets } from './bootstrap';
2
+ import { type MetalPublicIdentity } from './metal-bootstrap';
2
3
  import { type PlatformGenesisGuest } from './platform-genesis';
3
4
  export interface OperatorSshHop {
4
5
  address: string;
@@ -20,12 +21,20 @@ export interface OperatorMetalBootstrapRequest {
20
21
  kind: 'metal-remote';
21
22
  target: OperatorPlatformBootstrapRequest['target'];
22
23
  metalConfigFile: string;
24
+ /** Secret-free, pinned-host identity evidence written automatically after apply. */
25
+ metalIdentityEvidenceFile?: string;
23
26
  genesis: {
24
27
  nodes: readonly [PlatformGenesisGuest, PlatformGenesisGuest, PlatformGenesisGuest];
25
28
  rehearsalNode?: PlatformGenesisGuest;
26
29
  sshPublicKeyFiles: string[];
27
30
  };
28
31
  }
32
+ export interface OperatorMetalIdentityEvidence extends MetalPublicIdentity {
33
+ format: 1;
34
+ kind: 'forgezero-operator-metal-identity';
35
+ metalHostname: string;
36
+ metalHostKeySha256: string;
37
+ }
29
38
  export interface OperatorGuestHostKeyEvidence {
30
39
  format: 1;
31
40
  kind: 'forgezero-operator-guest-host-keys';
@@ -139,6 +148,7 @@ export declare function readOperatorMetalBootstrapRequest(path: string, options?
139
148
  validateMetalConfig?: boolean;
140
149
  }): OperatorMetalBootstrapRequest;
141
150
  export declare function writeOperatorMetalBootstrapRequest(path: string, request: OperatorMetalBootstrapRequest): string;
151
+ export declare function readOperatorMetalIdentityEvidence(path: string, request: OperatorMetalBootstrapRequest): OperatorMetalIdentityEvidence;
142
152
  export declare function readOperatorGuestHostKeyEvidence(path: string, request?: OperatorMetalBootstrapRequest): OperatorGuestHostKeyEvidence;
143
153
  export declare function writeOperatorGuestHostKeyEvidence(path: string, evidence: OperatorGuestHostKeyEvidence, request?: OperatorMetalBootstrapRequest): string;
144
154
  export declare function planOperatorMetalBootstrap(request: OperatorMetalBootstrapRequest, mode: OperatorMetalBootstrapMode): OperatorMetalBootstrapPlan;
@@ -1384,9 +1384,49 @@ async function buildBootstrapBundle(input, exec = run) {
1384
1384
  throw cause;
1385
1385
  }
1386
1386
  }
1387
+ async function refreshBootstrapBundle(input, exec = run) {
1388
+ const outputPath = resolve2(input.outputPath);
1389
+ const manifestPath = `${outputPath}.json`;
1390
+ if (existsSync(outputPath) !== existsSync(manifestPath)) {
1391
+ throw new Error("bootstrap bundle and manifest must either both exist or both be absent");
1392
+ }
1393
+ const candidatePath = `${outputPath}.candidate.${process.pid}.${randomBytes(6).toString("hex")}`;
1394
+ const candidate = await buildBootstrapBundle({ ...input, outputPath: candidatePath }, exec);
1395
+ try {
1396
+ if (!existsSync(outputPath)) {
1397
+ renameSync(candidate.bundlePath, outputPath);
1398
+ renameSync(candidate.manifestPath, manifestPath);
1399
+ return readBootstrapBundle(outputPath);
1400
+ }
1401
+ const current = await readBootstrapBundle(outputPath);
1402
+ if (current.manifest.branch === candidate.manifest.branch && current.manifest.revision === candidate.manifest.revision) {
1403
+ rmSync(candidate.bundlePath, { force: true });
1404
+ rmSync(candidate.manifestPath, { force: true });
1405
+ return current;
1406
+ }
1407
+ const archivePath = `${outputPath}.before-${current.manifest.revision.slice(0, 7)}-${Date.now()}`;
1408
+ const archiveManifestPath = `${archivePath}.json`;
1409
+ renameSync(outputPath, archivePath);
1410
+ renameSync(manifestPath, archiveManifestPath);
1411
+ try {
1412
+ renameSync(candidate.bundlePath, outputPath);
1413
+ renameSync(candidate.manifestPath, manifestPath);
1414
+ return readBootstrapBundle(outputPath);
1415
+ } catch (cause) {
1416
+ rmSync(outputPath, { force: true });
1417
+ rmSync(manifestPath, { force: true });
1418
+ renameSync(archivePath, outputPath);
1419
+ renameSync(archiveManifestPath, manifestPath);
1420
+ throw cause;
1421
+ }
1422
+ } finally {
1423
+ rmSync(candidate.bundlePath, { force: true });
1424
+ rmSync(candidate.manifestPath, { force: true });
1425
+ }
1426
+ }
1387
1427
 
1388
1428
  // src/bootstrap.ts
1389
- import { createHash as createHash2, createHmac as createHmac2, randomBytes as randomBytes3 } from "crypto";
1429
+ import { createHash as createHash2, createHmac as createHmac2, createPrivateKey, randomBytes as randomBytes3 } from "crypto";
1390
1430
  import {
1391
1431
  chmodSync as chmodSync3,
1392
1432
  existsSync as existsSync5,
@@ -1420,7 +1460,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
1420
1460
  var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
1421
1461
 
1422
1462
  // src/version.ts
1423
- var VERSION = "0.1.102";
1463
+ var VERSION = "0.1.107";
1424
1464
 
1425
1465
  // src/software.ts
1426
1466
  var PINNED_BUN_VERSION = "1.3.14";
@@ -3090,10 +3130,16 @@ var httpsOrigin = (name, raw) => {
3090
3130
  };
3091
3131
  var agentTelemetryOrigin = (raw) => raw === "http://127.0.0.1:4318" ? raw : httpsOrigin("agentOtlpEndpoint", raw);
3092
3132
  function validatePlatformInitialInventory(value) {
3093
- if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !["metalHostname", "region", "computes", "attestation", "deployment"].includes(key)) || !/^[A-Za-z0-9][A-Za-z0-9.-]{1,252}$/.test(value.metalHostname) || !value.region || typeof value.region !== "object" || Array.isArray(value.region) || Object.keys(value.region).some((key) => !["key", "label", "country", "city", "confidentialCapable"].includes(key)) || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(value.region.key) || !/^[A-Z]{2}$/.test(value.region.country) || value.region.confidentialCapable !== true || !Array.isArray(value.computes)) {
3133
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !["metalHostname", "metalIdentity", "region", "computes", "attestation", "deployment"].includes(key)) || !/^[A-Za-z0-9][A-Za-z0-9.-]{1,252}$/.test(value.metalHostname) || !value.region || typeof value.region !== "object" || Array.isArray(value.region) || Object.keys(value.region).some((key) => !["key", "label", "country", "city", "confidentialCapable"].includes(key)) || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(value.region.key) || !/^[A-Z]{2}$/.test(value.region.country) || value.region.confidentialCapable !== true || !Array.isArray(value.computes)) {
3094
3134
  throw new Error("initial platform inventory coordinates are invalid");
3095
3135
  }
3096
3136
  safeAtom("initialInventory.region.label", value.region.label);
3137
+ if (value.metalIdentity !== undefined) {
3138
+ const identity = value.metalIdentity;
3139
+ if (!identity || typeof identity !== "object" || Array.isArray(identity) || Object.keys(identity).some((key) => !["nodeKey", "publicKeys"].includes(key)) || !/^[A-Za-z0-9_-]{43}$/.test(identity.nodeKey) || !identity.publicKeys || identity.publicKeys.ed25519 !== identity.nodeKey || !/^[A-Za-z0-9_-]{1,8192}$/.test(identity.publicKeys.mlDsa) || Object.keys(identity.publicKeys).some((key) => !["ed25519", "mlDsa"].includes(key))) {
3140
+ throw new Error("initial platform Metal identity is invalid");
3141
+ }
3142
+ }
3097
3143
  if (value.region.city !== undefined)
3098
3144
  safeAtom("initialInventory.region.city", value.region.city);
3099
3145
  if (value.deployment !== undefined && (!value.deployment || typeof value.deployment !== "object" || Array.isArray(value.deployment) || Object.keys(value.deployment).some((key) => !["source", "branch", "revision", "bundleSha256"].includes(key)) || value.deployment.source !== "bootstrap-bundle" || !["dev", "main"].includes(value.deployment.branch) || !/^[a-f0-9]{40}$/.test(value.deployment.revision) || !/^[a-f0-9]{64}$/.test(value.deployment.bundleSha256)))
@@ -3128,6 +3174,7 @@ function validatePlatformInitialInventory(value) {
3128
3174
  }
3129
3175
  return {
3130
3176
  metalHostname: value.metalHostname,
3177
+ ...value.metalIdentity ? { metalIdentity: structuredClone(value.metalIdentity) } : {},
3131
3178
  region: { ...value.region },
3132
3179
  computes: value.computes.map((compute, index) => ({
3133
3180
  ...computes[index],
@@ -3181,6 +3228,11 @@ function validatePlatformSharedEnvironment(input) {
3181
3228
  } else if (input.email !== undefined) {
3182
3229
  throw new Error("Bootstrap email provider must be smtp or jetemail.");
3183
3230
  }
3231
+ if (input.githubApp) {
3232
+ if (!/^(?:Iv1\.[A-Fa-f0-9]{16}|Ov23li[A-Za-z0-9]{14,})$/.test(input.githubApp.clientId) || !/^[1-9][0-9]{0,19}$/.test(input.githubApp.appId) || !/^[a-z0-9][a-z0-9-]{0,99}$/.test(input.githubApp.slug)) {
3233
+ throw new Error("GitHub App client id, app id or slug is malformed.");
3234
+ }
3235
+ }
3184
3236
  boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
3185
3237
  if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
3186
3238
  throw new Error("seedSyncMembers must contain 3 to 64 unique physical host identities.");
@@ -3311,6 +3363,9 @@ function renderPlatformSharedEnvironment(input) {
3311
3363
  FZ_REALTIME_WORKER_SCRIPT: value.realtime?.workerScriptName ?? "",
3312
3364
  FZ_REALTIME_ENDPOINT: value.realtime?.endpoint ?? "",
3313
3365
  FZ_REALTIME_PRODUCER: value.realtime?.producer ?? "",
3366
+ FZ_GITHUB_CLIENT_ID: value.githubApp?.clientId ?? "",
3367
+ FZ_GITHUB_APP_ID: value.githubApp?.appId ?? "",
3368
+ FZ_GITHUB_APP_SLUG: value.githubApp?.slug ?? "",
3314
3369
  FZ_PLATFORM_INITIAL_INVENTORY: value.initialInventory ? JSON.stringify(value.initialInventory) : ""
3315
3370
  };
3316
3371
  return `# Generated by fz bootstrap platform. Non-secret coordinates only.
@@ -3322,6 +3377,9 @@ function platformApiCredentialSpecs(options) {
3322
3377
  const optional = [
3323
3378
  ["fz_smtp.password", options.emailProvider === "smtp"],
3324
3379
  ["fz_jetemail.apiKey", options.emailProvider === "jetemail"],
3380
+ ["fz_github.clientSecret", options.githubApp],
3381
+ ["fz_github.privateKey", options.githubApp],
3382
+ ["fz_github.webhookSecret", options.githubApp],
3325
3383
  ["CF_API_TOKEN", options.cloudflareKv],
3326
3384
  ["CF_TUNNEL_TOKEN", options.cloudflareKv],
3327
3385
  ["REALTIME_PUBLISH_SECRET", options.realtime],
@@ -3329,6 +3387,7 @@ function platformApiCredentialSpecs(options) {
3329
3387
  ];
3330
3388
  return [
3331
3389
  { name: "arangodb-jwt", encryptedPath: "/etc/forgezero/creds/arangodb-jwt.cred", required: true },
3390
+ { name: "arangodb-root-password", encryptedPath: "/etc/forgezero/creds/arangodb-root-password.cred", required: true },
3332
3391
  { name: "seed-sync-root", encryptedPath: "/etc/forgezero/creds/seed-sync-root.cred", required: true },
3333
3392
  ...optional.filter(([, present]) => present).map(([name]) => ({
3334
3393
  name,
@@ -3717,7 +3776,17 @@ function validatePlatformBootstrapSecrets(config, input) {
3717
3776
  if (!input || typeof input !== "object" || Array.isArray(input))
3718
3777
  throw new Error("bootstrap credential input must be an object");
3719
3778
  const source = input;
3720
- const allowed = ["clusterBootstrapCode", "emailSecret", "enrolmentToken", "backupS3Secret", "cloudflareTunnelToken", "cloudflareApiToken"];
3779
+ const allowed = [
3780
+ "clusterBootstrapCode",
3781
+ "emailSecret",
3782
+ "enrolmentToken",
3783
+ "backupS3Secret",
3784
+ "cloudflareTunnelToken",
3785
+ "cloudflareApiToken",
3786
+ "githubClientSecret",
3787
+ "githubPrivateKeyBase64",
3788
+ "githubWebhookSecret"
3789
+ ];
3721
3790
  const unknown = Object.keys(source).filter((key) => !allowed.includes(key));
3722
3791
  if (unknown.length)
3723
3792
  throw new Error(`bootstrap credential input contains unsupported field ${unknown[0]}`);
@@ -3727,6 +3796,9 @@ function validatePlatformBootstrapSecrets(config, input) {
3727
3796
  const backupS3Secret = typeof source.backupS3Secret === "string" ? source.backupS3Secret.trim() : undefined;
3728
3797
  const cloudflareTunnelToken = typeof source.cloudflareTunnelToken === "string" ? source.cloudflareTunnelToken.trim() : undefined;
3729
3798
  const cloudflareApiToken = typeof source.cloudflareApiToken === "string" ? source.cloudflareApiToken.trim() : undefined;
3799
+ const githubClientSecret = typeof source.githubClientSecret === "string" ? source.githubClientSecret.trim() : undefined;
3800
+ const githubPrivateKeyBase64 = typeof source.githubPrivateKeyBase64 === "string" ? source.githubPrivateKeyBase64.trim() : undefined;
3801
+ const githubWebhookSecret = typeof source.githubWebhookSecret === "string" ? source.githubWebhookSecret.trim() : undefined;
3730
3802
  if (!/^[a-f0-9]{64}$/i.test(clusterBootstrapCode))
3731
3803
  throw new Error("cluster bootstrap code must contain exactly 64 hexadecimal characters");
3732
3804
  if (!emailSecret || emailSecret.length > 16384 || /[\r\n\0]/.test(emailSecret))
@@ -3746,12 +3818,29 @@ function validatePlatformBootstrapSecrets(config, input) {
3746
3818
  }
3747
3819
  if (cloudflareTunnelToken)
3748
3820
  validateCloudflareBootstrapSecretPair({ cloudflareTunnelToken, cloudflareApiToken });
3821
+ const githubConfigured = Boolean(config.runtime.environment.githubApp);
3822
+ if (githubConfigured !== Boolean(githubClientSecret && githubPrivateKeyBase64 && githubWebhookSecret)) {
3823
+ throw new Error("GitHub App coordinates require client secret, private key and webhook secret together");
3824
+ }
3825
+ if (githubConfigured) {
3826
+ if (githubClientSecret.length < 20 || githubClientSecret.length > 512 || /[\r\n\0]/.test(githubClientSecret) || githubWebhookSecret.length < 32 || githubWebhookSecret.length > 512 || /[\r\n\0]/.test(githubWebhookSecret)) {
3827
+ throw new Error("GitHub App client or webhook secret is malformed");
3828
+ }
3829
+ try {
3830
+ const pem = Buffer.from(githubPrivateKeyBase64, "base64").toString("utf8");
3831
+ if (createPrivateKey(pem).asymmetricKeyType !== "rsa")
3832
+ throw new Error("not RSA");
3833
+ } catch {
3834
+ throw new Error("GitHub App private key must be a base64-encoded RSA private key");
3835
+ }
3836
+ }
3749
3837
  return {
3750
3838
  clusterBootstrapCode,
3751
3839
  emailSecret,
3752
3840
  ...enrolmentToken ? { enrolmentToken } : {},
3753
3841
  ...backupS3Secret ? { backupS3Secret } : {},
3754
- ...cloudflareTunnelToken ? { cloudflareTunnelToken, cloudflareApiToken } : {}
3842
+ ...cloudflareTunnelToken ? { cloudflareTunnelToken, cloudflareApiToken } : {},
3843
+ ...githubConfigured ? { githubClientSecret, githubPrivateKeyBase64, githubWebhookSecret } : {}
3755
3844
  };
3756
3845
  }
3757
3846
  var platformBootstrapRunner = (config) => config.kind === "platform" && config.database.role === "master";
@@ -3766,6 +3855,7 @@ var STATE_PATH = BOOTSTRAP_STATE_PATH;
3766
3855
  var INTENT_PATH = "/var/lib/forgezero/bootstrap.intent.json";
3767
3856
  var CREDS = "/etc/forgezero/creds";
3768
3857
  var JWT_CREDENTIAL = `${CREDS}/arangodb-jwt.cred`;
3858
+ var ARANGO_ROOT_CREDENTIAL = `${CREDS}/arangodb-root-password.cred`;
3769
3859
  var ENROL_CREDENTIAL = `${CREDS}/enrol-token.cred`;
3770
3860
  var TUNNEL_CREDENTIAL = `${CREDS}/CF_TUNNEL_CONNECTOR_TOKEN.cred`;
3771
3861
  var CF_API_CREDENTIAL = `${CREDS}/CF_API_TOKEN.cred`;
@@ -4051,7 +4141,7 @@ WantedBy=multi-user.target
4051
4141
  function databaseVerifyUnit(config) {
4052
4142
  const { address } = config.database;
4053
4143
  return `[Unit]
4054
- Description=Verify ForgeZero ArangoDB Community 3.11.14 writable Coordinator
4144
+ Description=Secure and verify ForgeZero ArangoDB Community 3.11.14 writable Coordinator
4055
4145
  Requires=forgezero-db.service
4056
4146
  After=forgezero-db.service
4057
4147
  PartOf=forgezero-db.service
@@ -4061,7 +4151,9 @@ Type=oneshot
4061
4151
  User=arangodb
4062
4152
  Group=arangodb
4063
4153
  LoadCredentialEncrypted=arangodb-jwt:${JWT_CREDENTIAL}
4064
- ExecStart=/usr/bin/arangosh --server.endpoint tcp://${unitEscape(address)}:8529 --server.jwt-secret-keyfile %d/arangodb-jwt --javascript.execute-string 'const c=require("@arangodb").db._connection;let last;let ok=false;for(let i=0;i<90;i++){try{const v=c.GET("/_api/version?details=true");const s=c.GET("/_admin/status");const m=c.GET("/_admin/server/mode");if(!v.error&&v.version==="3.11.14"&&v.details&&v.details.license==="community"&&!s.error&&s.serverInfo&&s.serverInfo.role==="COORDINATOR"&&!m.error&&m.mode==="default"){ok=true;break;}last={v,s,m};}catch(e){last=String(e);}require("internal").wait(2);}if(!ok)throw new Error("writable Community Coordinator verification failed: "+JSON.stringify(last));'
4154
+ LoadCredentialEncrypted=arangodb-root-password:${ARANGO_ROOT_CREDENTIAL}
4155
+ ExecStart=/usr/bin/arangosh --server.endpoint tcp://${unitEscape(address)}:8529 --server.jwt-secret-keyfile %d/arangodb-jwt --javascript.execute-string 'const c=require("@arangodb").db._connection;let last;let ok=false;for(let i=0;i<90;i++){try{const v=c.GET("/_api/version?details=true");const s=c.GET("/_admin/status");const m=c.GET("/_admin/server/mode");if(!v.error&&v.version==="3.11.14"&&v.details&&v.details.license==="community"&&!s.error&&s.serverInfo&&s.serverInfo.role==="COORDINATOR"&&!m.error&&m.mode==="default"){ok=true;break;}last={v,s,m};}catch(e){last=String(e);}require("internal").wait(2);}if(!ok)throw new Error("writable Community Coordinator verification failed: "+JSON.stringify(last));const password=require("fs").read("%d/arangodb-root-password").trim();if(password.length<48)throw new Error("ArangoDB root credential is malformed");require("@arangodb/users").update("root",password,true);'
4156
+ ExecStart=/usr/local/bin/fz-agent database-auth-verify --endpoint=http://${unitEscape(address)}:8529
4065
4157
  RemainAfterExit=yes
4066
4158
  TimeoutStartSec=200
4067
4159
  NoNewPrivileges=true
@@ -4303,6 +4395,12 @@ function approvedPlatformRepairIdentityDigests(config) {
4303
4395
  delete beforeAttestation.runtime.environment.initialInventory.attestation;
4304
4396
  prior.push(beforeAttestation);
4305
4397
  }
4398
+ const metalIdentity = config.runtime.environment.initialInventory?.metalIdentity;
4399
+ if (metalIdentity && config.enrolment.source === "genesis-derived") {
4400
+ const beforeMetalIdentity = structuredClone(config);
4401
+ delete beforeMetalIdentity.runtime.environment.initialInventory.metalIdentity;
4402
+ prior.push(beforeMetalIdentity);
4403
+ }
4306
4404
  if (config.database.role === "joiner" && config.enrolment.source === "genesis-derived" && config.runtime.environment.custodianEmail) {
4307
4405
  const beforeReplicaCustodianEmail = structuredClone(config);
4308
4406
  delete beforeReplicaCustodianEmail.runtime.environment.custodianEmail;
@@ -4522,6 +4620,11 @@ async function bootstrapStatus(host = localBootstrapHost()) {
4522
4620
  if (nginx.exitCode !== 0)
4523
4621
  problems.push("nginx configuration is invalid");
4524
4622
  if (state.databaseRole !== "none") {
4623
+ for (const credential of [JWT_CREDENTIAL, ARANGO_ROOT_CREDENTIAL]) {
4624
+ services[credential] = host.exists(credential);
4625
+ if (!services[credential])
4626
+ problems.push(`${credential} is missing`);
4627
+ }
4525
4628
  const unitPath = "/etc/systemd/system/forgezero-db.service";
4526
4629
  const expectsNoAgency = state.databaseAgency === "none";
4527
4630
  const unitHasNoAgency = host.exists(unitPath) && host.read(unitPath).includes("--cluster.start-agent=false");
@@ -4660,7 +4763,10 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
4660
4763
  enrolmentToken: checked4.enrolmentToken,
4661
4764
  backup: checked4.backupS3Secret,
4662
4765
  cloudflareTunnelToken: checked4.cloudflareTunnelToken,
4663
- cloudflareApiToken: checked4.cloudflareApiToken
4766
+ cloudflareApiToken: checked4.cloudflareApiToken,
4767
+ githubClientSecret: checked4.githubClientSecret,
4768
+ githubPrivateKey: checked4.githubPrivateKeyBase64 ? Buffer.from(checked4.githubPrivateKeyBase64, "base64").toString("utf8") : undefined,
4769
+ githubWebhookSecret: checked4.githubWebhookSecret
4664
4770
  };
4665
4771
  })() : undefined;
4666
4772
  const enrolledPrivate = config.kind === "enrolled-compute" ? validateEnrolledComputeBootstrapSecrets(config, secrets) : undefined;
@@ -4753,11 +4859,17 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
4753
4859
  if (!host.exists(JWT_CREDENTIAL)) {
4754
4860
  await seal(host, "arangodb-jwt", JWT_CREDENTIAL, derive(root, "forgezero/cluster/arangodb-jwt/v1"));
4755
4861
  }
4862
+ if (!host.exists(ARANGO_ROOT_CREDENTIAL)) {
4863
+ await seal(host, "arangodb-root-password", ARANGO_ROOT_CREDENTIAL, `fzr_${derive(root, "forgezero/cluster/arangodb-root-password/v1")}`);
4864
+ }
4756
4865
  await seal(host, "seed-sync-root", SEED_CREDENTIAL, derive(root, "forgezero/cluster/seed-mesh/v1"));
4757
4866
  await seal(host, "backup-recovery-root", BACKUP_RECOVERY_CREDENTIAL, derive(root, "forgezero/backup/recovery-root/v1"));
4758
4867
  const emailCredentialName = config.runtime.environment.email?.provider === "smtp" ? "fz_smtp.password" : config.runtime.environment.email?.provider === "jetemail" ? "fz_jetemail.apiKey" : undefined;
4759
4868
  for (const [name, source] of Object.entries({
4760
4869
  ...emailCredentialName ? { [emailCredentialName]: platformPrivate.email } : {},
4870
+ "fz_github.clientSecret": platformPrivate.githubClientSecret,
4871
+ "fz_github.privateKey": platformPrivate.githubPrivateKey,
4872
+ "fz_github.webhookSecret": platformPrivate.githubWebhookSecret,
4761
4873
  "backup.s3.secretAccessKey": platformPrivate.backup
4762
4874
  })) {
4763
4875
  if (source) {
@@ -4774,6 +4886,7 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
4774
4886
  const credentials = platformApiCredentialSpecs({
4775
4887
  emailProvider: runtime.environment.email?.provider,
4776
4888
  cloudflareKv: cloudflareConfigured,
4889
+ githubApp: Boolean(runtime.environment.githubApp),
4777
4890
  realtime: Boolean(runtime.environment.realtime)
4778
4891
  });
4779
4892
  const units = renderPlatformApiUnits({
@@ -5032,7 +5145,11 @@ function strictBootstrapDocument(value) {
5032
5145
  ], "runtime environment");
5033
5146
  const environment = runtime.environment;
5034
5147
  if (environment.initialInventory !== undefined) {
5035
- const inventory = exactKeys(environment.initialInventory, ["metalHostname", "region", "computes", "attestation", "deployment"], "initial inventory");
5148
+ const inventory = exactKeys(environment.initialInventory, ["metalHostname", "metalIdentity", "region", "computes", "attestation", "deployment"], "initial inventory");
5149
+ if (inventory.metalIdentity !== undefined) {
5150
+ const identity = exactKeys(inventory.metalIdentity, ["nodeKey", "publicKeys"], "initial Metal identity");
5151
+ exactKeys(identity.publicKeys, ["ed25519", "mlDsa"], "initial Metal public keys");
5152
+ }
5036
5153
  exactKeys(inventory.region, ["key", "label", "country", "city", "confidentialCapable"], "initial inventory region");
5037
5154
  if (inventory.attestation !== undefined) {
5038
5155
  const attestation = exactKeys(inventory.attestation, ["measurement", "tcbFloor"], "initial attestation evidence");
@@ -5501,7 +5618,7 @@ var SUPPORTED_BUN_RELEASE_SHA256 = "951ee2aee855f08595aeec6225226a298d3fea83a3dc
5501
5618
 
5502
5619
  class MetalBootstrapError extends Error {
5503
5620
  }
5504
- function normalizeMetalPublicIdentity(value) {
5621
+ function parseMetalPublicIdentity(value) {
5505
5622
  let parsed;
5506
5623
  try {
5507
5624
  parsed = JSON.parse(value);
@@ -5516,7 +5633,13 @@ function normalizeMetalPublicIdentity(value) {
5516
5633
  if (Object.keys(identity).some((key) => !["nodeKey", "publicKeys"].includes(key)) || typeof identity.nodeKey !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(identity.nodeKey) || !keys || Object.keys(keys).some((key) => !["ed25519", "mlDsa"].includes(key)) || keys.ed25519 !== identity.nodeKey || typeof keys.mlDsa !== "string" || !/^[A-Za-z0-9_-]{1,8192}$/.test(keys.mlDsa)) {
5517
5634
  throw new MetalBootstrapError("metal public identity proof was invalid");
5518
5635
  }
5519
- return JSON.stringify(parsed);
5636
+ return {
5637
+ nodeKey: identity.nodeKey,
5638
+ publicKeys: { ed25519: keys.ed25519, mlDsa: keys.mlDsa }
5639
+ };
5640
+ }
5641
+ function normalizeMetalPublicIdentity(value) {
5642
+ return JSON.stringify(parseMetalPublicIdentity(value));
5520
5643
  }
5521
5644
  var defaultExec2 = async (argv, stdin) => {
5522
5645
  const child = Bun.spawn([...argv], {
@@ -6412,7 +6535,7 @@ function writeOperatorPlatformBootstrapFleetRequest(path, requestFiles, cloudfla
6412
6535
  return writeOwnerJson2(path, request, "operator fleet request");
6413
6536
  }
6414
6537
  var validateMetalRequestValue = (value, options = {}) => {
6415
- const root = exactKeys3(value, ["kind", "target", "metalConfigFile", "genesis"], "operator metal request");
6538
+ const root = exactKeys3(value, ["kind", "target", "metalConfigFile", "metalIdentityEvidenceFile", "genesis"], "operator metal request");
6416
6539
  if (root.kind !== "metal-remote")
6417
6540
  throw new Error("operator metal bootstrap kind must be metal-remote");
6418
6541
  const targetValue = exactKeys3(root.target, ["address", "port", "user", "hostKey", "hostKeySha256", "identityPublicKeyFile", "agentSocket"], "operator metal target");
@@ -6431,6 +6554,12 @@ var validateMetalRequestValue = (value, options = {}) => {
6431
6554
  if (typeof root.metalConfigFile !== "string")
6432
6555
  throw new Error("metalConfigFile must be an absolute path");
6433
6556
  canonicalOutputPath(root.metalConfigFile, "metalConfigFile");
6557
+ if (root.metalIdentityEvidenceFile !== undefined && typeof root.metalIdentityEvidenceFile !== "string") {
6558
+ throw new Error("metalIdentityEvidenceFile must be an absolute path");
6559
+ }
6560
+ if (typeof root.metalIdentityEvidenceFile === "string") {
6561
+ canonicalOutputPath(root.metalIdentityEvidenceFile, "metalIdentityEvidenceFile");
6562
+ }
6434
6563
  if (options.validateMetalConfig !== false)
6435
6564
  readMetalBootstrapConfig(root.metalConfigFile);
6436
6565
  const genesis = exactKeys3(root.genesis, ["nodes", "rehearsalNode", "sshPublicKeyFiles"], "operator metal genesis");
@@ -6479,6 +6608,7 @@ var validateMetalRequestValue = (value, options = {}) => {
6479
6608
  return {
6480
6609
  kind: "metal-remote",
6481
6610
  metalConfigFile: root.metalConfigFile,
6611
+ ...typeof root.metalIdentityEvidenceFile === "string" ? { metalIdentityEvidenceFile: root.metalIdentityEvidenceFile } : {},
6482
6612
  target: {
6483
6613
  ...target,
6484
6614
  identityPublicKeyFile: targetValue.identityPublicKeyFile,
@@ -6499,6 +6629,25 @@ function writeOperatorMetalBootstrapRequest(path, request) {
6499
6629
  validateMetalRequestValue(request);
6500
6630
  return writeOwnerJson2(path, request, "operator metal request");
6501
6631
  }
6632
+ function readOperatorMetalIdentityEvidence(path, request) {
6633
+ const value = JSON.parse(new TextDecoder().decode(ownerFile(path, REQUEST_LIMIT, "operator metal identity evidence")));
6634
+ const root = exactKeys3(value, ["format", "kind", "metalHostname", "metalHostKeySha256", "nodeKey", "publicKeys"], "operator metal identity evidence");
6635
+ if (root.format !== 1 || root.kind !== "forgezero-operator-metal-identity" || typeof root.metalHostname !== "string" || typeof root.metalHostKeySha256 !== "string" || typeof root.nodeKey !== "string" || !root.publicKeys || typeof root.publicKeys !== "object") {
6636
+ throw new Error("operator metal identity evidence is malformed");
6637
+ }
6638
+ const identity = parseMetalPublicIdentity(JSON.stringify({ nodeKey: root.nodeKey, publicKeys: root.publicKeys }));
6639
+ const config = readMetalBootstrapConfig(request.metalConfigFile, { allowHistoricalRelease: true });
6640
+ if (root.metalHostname !== config.metalHostname || root.metalHostKeySha256 !== request.target.hostKeySha256) {
6641
+ throw new Error("operator metal identity evidence does not match the reviewed Metal target");
6642
+ }
6643
+ return {
6644
+ format: 1,
6645
+ kind: "forgezero-operator-metal-identity",
6646
+ metalHostname: root.metalHostname,
6647
+ metalHostKeySha256: root.metalHostKeySha256,
6648
+ ...identity
6649
+ };
6650
+ }
6502
6651
  var validateGuestHostKeyEvidenceValue = (value, request) => {
6503
6652
  const root = exactKeys3(value, ["format", "kind", "metalHostKeySha256", "nodes"], "operator guest host-key evidence");
6504
6653
  if (root.format !== 1 || root.kind !== "forgezero-operator-guest-host-keys" || typeof root.metalHostKeySha256 !== "string" || !Array.isArray(root.nodes)) {
@@ -7279,6 +7428,25 @@ async function applyOperatorMetalBootstrap(request, mode, options = {}) {
7279
7428
  `${REMOTE_STAGE}/metal-config.json`,
7280
7429
  "--apply"
7281
7430
  ], "remote typed metal bootstrap");
7431
+ if (request.metalIdentityEvidenceFile) {
7432
+ let applied;
7433
+ try {
7434
+ applied = JSON.parse(output);
7435
+ } catch {
7436
+ throw new Error("remote Metal bootstrap did not return identity evidence");
7437
+ }
7438
+ const publicIdentity2 = applied?.publicIdentity;
7439
+ if (typeof publicIdentity2 !== "string")
7440
+ throw new Error("remote Metal bootstrap omitted its public identity");
7441
+ const identity = parseMetalPublicIdentity(publicIdentity2);
7442
+ writeOwnerJson2(request.metalIdentityEvidenceFile, {
7443
+ format: 1,
7444
+ kind: "forgezero-operator-metal-identity",
7445
+ metalHostname: config.metalHostname,
7446
+ metalHostKeySha256: request.target.hostKeySha256,
7447
+ ...identity
7448
+ }, "operator metal identity evidence");
7449
+ }
7282
7450
  return { plan, output };
7283
7451
  } finally {
7284
7452
  if (knownHosts) {
@@ -7299,6 +7467,7 @@ export {
7299
7467
  redactOperatorSecretDiagnostic,
7300
7468
  readOperatorPlatformBootstrapRequest,
7301
7469
  readOperatorPlatformBootstrapFleetRequest,
7470
+ readOperatorMetalIdentityEvidence,
7302
7471
  readOperatorMetalBootstrapRequest,
7303
7472
  readOperatorGuestHostKeyEvidence,
7304
7473
  planOperatorPlatformFleetBootstrap,
@@ -19,6 +19,14 @@ export interface PlatformInitialInventoryCompute {
19
19
  }
20
20
  export interface PlatformInitialInventory {
21
21
  metalHostname: string;
22
+ /** Hybrid public identity proved by the pinned Metal bootstrap transport. */
23
+ metalIdentity?: {
24
+ nodeKey: string;
25
+ publicKeys: {
26
+ ed25519: string;
27
+ mlDsa: string;
28
+ };
29
+ };
22
30
  region: {
23
31
  key: string;
24
32
  label: string;
@@ -60,6 +68,16 @@ export type PlatformBootstrapEmail = {
60
68
  from: string;
61
69
  eu: boolean;
62
70
  };
71
+ /**
72
+ * Public coordinates for one GitHub App. Its client secret, RSA private key and
73
+ * webhook secret are attended inputs sealed as systemd credentials and later
74
+ * imported into the unlocked platform Vault.
75
+ */
76
+ export interface PlatformBootstrapGitHubApp {
77
+ clientId: string;
78
+ appId: string;
79
+ slug: string;
80
+ }
63
81
  export interface PlatformSharedEnvironment {
64
82
  softwareProfile: PlatformSoftwareProfile;
65
83
  databaseRole: PlatformDatabaseRole;
@@ -91,6 +109,7 @@ export interface PlatformSharedEnvironment {
91
109
  otlpTraceSampleRatio: number;
92
110
  custodianEmail?: string;
93
111
  email?: PlatformBootstrapEmail;
112
+ githubApp?: PlatformBootstrapGitHubApp;
94
113
  backup?: {
95
114
  endpoint: string;
96
115
  region: string;
@@ -118,12 +137,13 @@ export declare function validatePlatformSharedEnvironment(input: PlatformSharedE
118
137
  /** Render only non-secret runtime coordinates. Passwords/tokens have no field in this contract. */
119
138
  export declare function renderPlatformSharedEnvironment(input: PlatformSharedEnvironment): string;
120
139
  export interface SystemdCredentialSpec {
121
- name: 'arangodb-jwt' | 'seed-sync-root' | 'fz_smtp.password' | 'fz_jetemail.apiKey' | 'CF_API_TOKEN' | 'CF_TUNNEL_TOKEN' | 'REALTIME_PUBLISH_SECRET' | 'REALTIME_TICKET_SECRET';
140
+ name: 'arangodb-jwt' | 'arangodb-root-password' | 'seed-sync-root' | 'fz_smtp.password' | 'fz_jetemail.apiKey' | 'fz_github.clientSecret' | 'fz_github.privateKey' | 'fz_github.webhookSecret' | 'CF_API_TOKEN' | 'CF_TUNNEL_TOKEN' | 'REALTIME_PUBLISH_SECRET' | 'REALTIME_TICKET_SECRET';
122
141
  encryptedPath: string;
123
142
  required: boolean;
124
143
  }
125
144
  export declare function platformApiCredentialSpecs(options: {
126
145
  emailProvider?: PlatformBootstrapEmail['provider'];
146
+ githubApp: boolean;
127
147
  cloudflareKv: boolean;
128
148
  realtime: boolean;
129
149
  }): SystemdCredentialSpec[];
@@ -730,10 +730,16 @@ var httpsOrigin = (name, raw) => {
730
730
  };
731
731
  var agentTelemetryOrigin = (raw) => raw === "http://127.0.0.1:4318" ? raw : httpsOrigin("agentOtlpEndpoint", raw);
732
732
  function validatePlatformInitialInventory(value) {
733
- if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !["metalHostname", "region", "computes", "attestation", "deployment"].includes(key)) || !/^[A-Za-z0-9][A-Za-z0-9.-]{1,252}$/.test(value.metalHostname) || !value.region || typeof value.region !== "object" || Array.isArray(value.region) || Object.keys(value.region).some((key) => !["key", "label", "country", "city", "confidentialCapable"].includes(key)) || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(value.region.key) || !/^[A-Z]{2}$/.test(value.region.country) || value.region.confidentialCapable !== true || !Array.isArray(value.computes)) {
733
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !["metalHostname", "metalIdentity", "region", "computes", "attestation", "deployment"].includes(key)) || !/^[A-Za-z0-9][A-Za-z0-9.-]{1,252}$/.test(value.metalHostname) || !value.region || typeof value.region !== "object" || Array.isArray(value.region) || Object.keys(value.region).some((key) => !["key", "label", "country", "city", "confidentialCapable"].includes(key)) || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(value.region.key) || !/^[A-Z]{2}$/.test(value.region.country) || value.region.confidentialCapable !== true || !Array.isArray(value.computes)) {
734
734
  throw new Error("initial platform inventory coordinates are invalid");
735
735
  }
736
736
  safeAtom("initialInventory.region.label", value.region.label);
737
+ if (value.metalIdentity !== undefined) {
738
+ const identity = value.metalIdentity;
739
+ if (!identity || typeof identity !== "object" || Array.isArray(identity) || Object.keys(identity).some((key) => !["nodeKey", "publicKeys"].includes(key)) || !/^[A-Za-z0-9_-]{43}$/.test(identity.nodeKey) || !identity.publicKeys || identity.publicKeys.ed25519 !== identity.nodeKey || !/^[A-Za-z0-9_-]{1,8192}$/.test(identity.publicKeys.mlDsa) || Object.keys(identity.publicKeys).some((key) => !["ed25519", "mlDsa"].includes(key))) {
740
+ throw new Error("initial platform Metal identity is invalid");
741
+ }
742
+ }
737
743
  if (value.region.city !== undefined)
738
744
  safeAtom("initialInventory.region.city", value.region.city);
739
745
  if (value.deployment !== undefined && (!value.deployment || typeof value.deployment !== "object" || Array.isArray(value.deployment) || Object.keys(value.deployment).some((key) => !["source", "branch", "revision", "bundleSha256"].includes(key)) || value.deployment.source !== "bootstrap-bundle" || !["dev", "main"].includes(value.deployment.branch) || !/^[a-f0-9]{40}$/.test(value.deployment.revision) || !/^[a-f0-9]{64}$/.test(value.deployment.bundleSha256)))
@@ -768,6 +774,7 @@ function validatePlatformInitialInventory(value) {
768
774
  }
769
775
  return {
770
776
  metalHostname: value.metalHostname,
777
+ ...value.metalIdentity ? { metalIdentity: structuredClone(value.metalIdentity) } : {},
771
778
  region: { ...value.region },
772
779
  computes: value.computes.map((compute, index) => ({
773
780
  ...computes[index],
@@ -821,6 +828,11 @@ function validatePlatformSharedEnvironment(input) {
821
828
  } else if (input.email !== undefined) {
822
829
  throw new Error("Bootstrap email provider must be smtp or jetemail.");
823
830
  }
831
+ if (input.githubApp) {
832
+ if (!/^(?:Iv1\.[A-Fa-f0-9]{16}|Ov23li[A-Za-z0-9]{14,})$/.test(input.githubApp.clientId) || !/^[1-9][0-9]{0,19}$/.test(input.githubApp.appId) || !/^[a-z0-9][a-z0-9-]{0,99}$/.test(input.githubApp.slug)) {
833
+ throw new Error("GitHub App client id, app id or slug is malformed.");
834
+ }
835
+ }
824
836
  boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
825
837
  if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
826
838
  throw new Error("seedSyncMembers must contain 3 to 64 unique physical host identities.");
@@ -951,6 +963,9 @@ function renderPlatformSharedEnvironment(input) {
951
963
  FZ_REALTIME_WORKER_SCRIPT: value.realtime?.workerScriptName ?? "",
952
964
  FZ_REALTIME_ENDPOINT: value.realtime?.endpoint ?? "",
953
965
  FZ_REALTIME_PRODUCER: value.realtime?.producer ?? "",
966
+ FZ_GITHUB_CLIENT_ID: value.githubApp?.clientId ?? "",
967
+ FZ_GITHUB_APP_ID: value.githubApp?.appId ?? "",
968
+ FZ_GITHUB_APP_SLUG: value.githubApp?.slug ?? "",
954
969
  FZ_PLATFORM_INITIAL_INVENTORY: value.initialInventory ? JSON.stringify(value.initialInventory) : ""
955
970
  };
956
971
  return `# Generated by fz bootstrap platform. Non-secret coordinates only.
@@ -962,6 +977,9 @@ function platformApiCredentialSpecs(options) {
962
977
  const optional = [
963
978
  ["fz_smtp.password", options.emailProvider === "smtp"],
964
979
  ["fz_jetemail.apiKey", options.emailProvider === "jetemail"],
980
+ ["fz_github.clientSecret", options.githubApp],
981
+ ["fz_github.privateKey", options.githubApp],
982
+ ["fz_github.webhookSecret", options.githubApp],
965
983
  ["CF_API_TOKEN", options.cloudflareKv],
966
984
  ["CF_TUNNEL_TOKEN", options.cloudflareKv],
967
985
  ["REALTIME_PUBLISH_SECRET", options.realtime],
@@ -969,6 +987,7 @@ function platformApiCredentialSpecs(options) {
969
987
  ];
970
988
  return [
971
989
  { name: "arangodb-jwt", encryptedPath: "/etc/forgezero/creds/arangodb-jwt.cred", required: true },
990
+ { name: "arangodb-root-password", encryptedPath: "/etc/forgezero/creds/arangodb-root-password.cred", required: true },
972
991
  { name: "seed-sync-root", encryptedPath: "/etc/forgezero/creds/seed-sync-root.cred", required: true },
973
992
  ...optional.filter(([, present]) => present).map(([name]) => ({
974
993
  name,