@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.
@@ -917,7 +917,7 @@ async function postSignedNode(options, path, body) {
917
917
  }
918
918
 
919
919
  // src/version.ts
920
- var VERSION3 = "0.1.102";
920
+ var VERSION3 = "0.1.107";
921
921
 
922
922
  // src/agent-heartbeat.ts
923
923
  function readAgentHostMetrics() {
@@ -30,3 +30,9 @@ export declare const bootstrapBundleBranch: (value: unknown) => string;
30
30
  export declare function parseBootstrapBundleManifest(value: unknown): BootstrapBundleManifest;
31
31
  export declare function readBootstrapBundle(bundlePath: string, manifestPath?: string): Promise<BootstrapBundleBuildResult>;
32
32
  export declare function buildBootstrapBundle(input: BootstrapBundleBuildInput, exec?: BootstrapBundleCommand): Promise<BootstrapBundleBuildResult>;
33
+ /**
34
+ * Make the reviewed branch head the canonical launch bundle without silently
35
+ * reusing an older release. The previous verified pair is retained beside the
36
+ * new pair so an attended operator can inspect or restore it.
37
+ */
38
+ export declare function refreshBootstrapBundle(input: BootstrapBundleBuildInput, exec?: BootstrapBundleCommand): Promise<BootstrapBundleBuildResult>;
@@ -154,8 +154,49 @@ async function buildBootstrapBundle(input, exec = run) {
154
154
  throw cause;
155
155
  }
156
156
  }
157
+ async function refreshBootstrapBundle(input, exec = run) {
158
+ const outputPath = resolve(input.outputPath);
159
+ const manifestPath = `${outputPath}.json`;
160
+ if (existsSync(outputPath) !== existsSync(manifestPath)) {
161
+ throw new Error("bootstrap bundle and manifest must either both exist or both be absent");
162
+ }
163
+ const candidatePath = `${outputPath}.candidate.${process.pid}.${randomBytes(6).toString("hex")}`;
164
+ const candidate = await buildBootstrapBundle({ ...input, outputPath: candidatePath }, exec);
165
+ try {
166
+ if (!existsSync(outputPath)) {
167
+ renameSync(candidate.bundlePath, outputPath);
168
+ renameSync(candidate.manifestPath, manifestPath);
169
+ return readBootstrapBundle(outputPath);
170
+ }
171
+ const current = await readBootstrapBundle(outputPath);
172
+ if (current.manifest.branch === candidate.manifest.branch && current.manifest.revision === candidate.manifest.revision) {
173
+ rmSync(candidate.bundlePath, { force: true });
174
+ rmSync(candidate.manifestPath, { force: true });
175
+ return current;
176
+ }
177
+ const archivePath = `${outputPath}.before-${current.manifest.revision.slice(0, 7)}-${Date.now()}`;
178
+ const archiveManifestPath = `${archivePath}.json`;
179
+ renameSync(outputPath, archivePath);
180
+ renameSync(manifestPath, archiveManifestPath);
181
+ try {
182
+ renameSync(candidate.bundlePath, outputPath);
183
+ renameSync(candidate.manifestPath, manifestPath);
184
+ return readBootstrapBundle(outputPath);
185
+ } catch (cause) {
186
+ rmSync(outputPath, { force: true });
187
+ rmSync(manifestPath, { force: true });
188
+ renameSync(archivePath, outputPath);
189
+ renameSync(archiveManifestPath, manifestPath);
190
+ throw cause;
191
+ }
192
+ } finally {
193
+ rmSync(candidate.bundlePath, { force: true });
194
+ rmSync(candidate.manifestPath, { force: true });
195
+ }
196
+ }
157
197
  export {
158
198
  sha256File,
199
+ refreshBootstrapBundle,
159
200
  readBootstrapBundle,
160
201
  parseBootstrapBundleManifest,
161
202
  buildBootstrapBundle,
@@ -115,6 +115,10 @@ export interface PlatformBootstrapSecrets {
115
115
  backupS3Secret?: string;
116
116
  cloudflareTunnelToken?: string;
117
117
  cloudflareApiToken?: string;
118
+ githubClientSecret?: string;
119
+ /** One-line base64 form accepted by prompts/env; decoded before systemd sealing. */
120
+ githubPrivateKeyBase64?: string;
121
+ githubWebhookSecret?: string;
118
122
  }
119
123
  export interface EnrolledComputeBootstrapSecrets {
120
124
  enrolmentToken: string;
package/dist/bootstrap.js CHANGED
@@ -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");
@@ -0,0 +1,6 @@
1
+ export interface DatabaseAuthVerifyOptions {
2
+ readCredential(): string;
3
+ fetcher?: typeof fetch;
4
+ }
5
+ /** Proves root is not empty and the sealed break-glass credential is active. */
6
+ export declare function verifyDatabaseAuthentication(rawEndpoint: string, options: DatabaseAuthVerifyOptions): Promise<void>;
@@ -42,17 +42,33 @@ export interface DeploymentConnectivityTopology {
42
42
  localPeerIdentities: readonly string[];
43
43
  remoteRelayAddresses: readonly string[];
44
44
  remoteRelayIdentities: readonly string[];
45
+ remoteMemberAddresses: readonly string[];
45
46
  remoteSiteCidrs: readonly string[];
46
47
  memberIdentities: readonly string[];
47
48
  healthPort: number;
48
49
  routedTcpPorts: readonly number[];
49
50
  generation: string;
50
51
  }
52
+ export interface DeploymentFirewallRule {
53
+ ruleKey: string;
54
+ action: 'allow' | 'deny';
55
+ protocol: 'tcp' | 'udp';
56
+ sourceAddresses: readonly string[];
57
+ portFrom: number;
58
+ portTo: number;
59
+ priority: number;
60
+ }
61
+ /** API-compiled, exact-address policy. Names and selectors never reach the host. */
62
+ export interface DeploymentFirewallPolicy {
63
+ generation: `sha256:${string}`;
64
+ rules: readonly DeploymentFirewallRule[];
65
+ }
51
66
  export interface DeploymentConnectivityRequest {
52
67
  key: string;
53
68
  intent: DeploymentConnectivityIntent;
54
69
  capabilities: DeploymentConnectivityCapabilities;
55
70
  topology?: DeploymentConnectivityTopology;
71
+ firewallPolicy?: DeploymentFirewallPolicy;
56
72
  }
57
73
  export interface DeploymentConnectivityEvidence {
58
74
  key: string;
@@ -74,6 +90,11 @@ export interface DeploymentConnectivityEvidence {
74
90
  role: DeploymentConnectivityTopology['role'];
75
91
  probed: string[];
76
92
  };
93
+ firewall?: {
94
+ generation: string;
95
+ rules: number;
96
+ active: true;
97
+ };
77
98
  }
78
99
  export interface DeploymentConnectivityHost {
79
100
  seal(name: string, path: string, value: string): Promise<void>;
@@ -1,7 +1,7 @@
1
1
  // src/deployment-connectivity.ts
2
2
  import { createHash } from "node:crypto";
3
3
  import { mkdirSync, renameSync, writeFileSync } from "node:fs";
4
- import { createConnection } from "node:net";
4
+ import { createConnection, isIP } from "node:net";
5
5
  import { dirname } from "node:path";
6
6
 
7
7
  // src/process-input.ts
@@ -88,21 +88,99 @@ function validate(request) {
88
88
  }
89
89
  } else if (request.capabilities.private)
90
90
  throw new Error("unexpected private deployment capability");
91
+ if (request.firewallPolicy) {
92
+ const policy = request.firewallPolicy;
93
+ if (!/^sha256:[a-f0-9]{64}$/.test(policy.generation) || !Array.isArray(policy.rules) || policy.rules.length > 256) {
94
+ throw new Error("deployment firewall policy is malformed");
95
+ }
96
+ let expanded = 0;
97
+ for (const rule of policy.rules) {
98
+ expanded += rule.sourceAddresses.length;
99
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(rule.ruleKey) || !["allow", "deny"].includes(rule.action) || !["tcp", "udp"].includes(rule.protocol) || rule.sourceAddresses.length < 1 || rule.sourceAddresses.length > 1024 || new Set(rule.sourceAddresses).size !== rule.sourceAddresses.length || rule.sourceAddresses.some((address) => isIP(address) !== 4) || !Number.isSafeInteger(rule.portFrom) || rule.portFrom < 1 || rule.portFrom > 65535 || !Number.isSafeInteger(rule.portTo) || rule.portTo < rule.portFrom || rule.portTo > 65535 || !Number.isSafeInteger(rule.priority) || rule.priority < 0 || rule.priority > 1e6) {
100
+ throw new Error("deployment firewall policy is malformed");
101
+ }
102
+ }
103
+ if (expanded > 2048)
104
+ throw new Error("deployment firewall policy expands beyond its rule limit");
105
+ }
106
+ }
107
+ async function replaceTaggedUfwRules(host, comment) {
108
+ const status = await checked(host, ["/usr/sbin/ufw", "status", "numbered"], "firewall inventory");
109
+ const numbers = status.split(`
110
+ `).flatMap((line) => {
111
+ if (!line.includes(comment))
112
+ return [];
113
+ const match = line.match(/^\s*\[\s*(\d{1,6})\]/);
114
+ return match ? [Number(match[1])] : [];
115
+ }).filter((value) => Number.isSafeInteger(value) && value > 0).sort((left, right) => right - left);
116
+ for (const number of numbers)
117
+ await checked(host, ["/usr/sbin/ufw", "--force", "delete", String(number)], `stale firewall rule ${number}`);
91
118
  }
92
119
  async function applyDeploymentConnectivity(request, host = defaultHost) {
93
120
  validate(request);
94
121
  const id = idFor(request.key);
95
122
  const evidence = { key: request.key };
123
+ if (request.firewallPolicy) {
124
+ const policy = request.firewallPolicy;
125
+ const comment = `fz-policy-${id}`;
126
+ await replaceTaggedUfwRules(host, comment);
127
+ const ordered = [...policy.rules].sort((left, right) => left.priority - right.priority || (left.action === right.action ? left.ruleKey.localeCompare(right.ruleKey) : left.action === "deny" ? -1 : 1));
128
+ const commands = [];
129
+ for (const rule of ordered)
130
+ for (const source of rule.sourceAddresses)
131
+ commands.push([
132
+ "/usr/sbin/ufw",
133
+ "insert",
134
+ "1",
135
+ rule.action,
136
+ "from",
137
+ source,
138
+ "to",
139
+ "any",
140
+ "port",
141
+ rule.portFrom === rule.portTo ? String(rule.portFrom) : `${rule.portFrom}:${rule.portTo}`,
142
+ "proto",
143
+ rule.protocol,
144
+ "comment",
145
+ comment
146
+ ]);
147
+ const ranges = new Map;
148
+ for (const rule of ordered)
149
+ ranges.set(`${rule.protocol}:${rule.portFrom}:${rule.portTo}`, rule);
150
+ for (const range of ranges.values())
151
+ commands.push([
152
+ "/usr/sbin/ufw",
153
+ "insert",
154
+ "1",
155
+ "deny",
156
+ "to",
157
+ "any",
158
+ "port",
159
+ range.portFrom === range.portTo ? String(range.portFrom) : `${range.portFrom}:${range.portTo}`,
160
+ "proto",
161
+ range.protocol,
162
+ "comment",
163
+ comment
164
+ ]);
165
+ for (const command of commands.toReversed())
166
+ await checked(host, command, "deployment firewall rule");
167
+ evidence.firewall = { generation: policy.generation, rules: commands.length, active: true };
168
+ }
96
169
  const topology = request.topology;
97
170
  if (topology) {
98
- const values = [topology.localRelayAddress, ...topology.localPeerAddresses, ...topology.remoteRelayAddresses];
171
+ const values = [
172
+ topology.localRelayAddress,
173
+ ...topology.localPeerAddresses,
174
+ ...topology.remoteRelayAddresses,
175
+ ...topology.remoteMemberAddresses
176
+ ];
99
177
  const identities = [
100
178
  topology.nodeIdentity,
101
179
  ...topology.localPeerIdentities,
102
180
  ...topology.remoteRelayIdentities,
103
181
  ...topology.memberIdentities
104
182
  ];
105
- if (!/^[A-Za-z0-9.-]{1,253}$/.test(topology.site) || !/^(?:\d{1,3}\.){3}0\/24$/.test(topology.siteCidr) || values.some((value) => !/^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)) || identities.some((value) => !/^[A-Za-z0-9.-]{1,253}$/.test(value)) || topology.localPeerIdentities.length !== topology.localPeerAddresses.length || topology.remoteRelayIdentities.length !== topology.remoteRelayAddresses.length || topology.memberIdentities.length < 1 || topology.memberIdentities.length > 1024 || new Set(topology.memberIdentities).size !== topology.memberIdentities.length || !topology.memberIdentities.includes(topology.nodeIdentity) || !/^sha256:[a-f0-9]{64}$/.test(topology.generation) || (topology.role === "member" ? topology.remoteRelayAddresses.length !== 0 : topology.remoteSiteCidrs.length !== topology.remoteRelayAddresses.length) || topology.remoteSiteCidrs.some((value) => !/^(?:\d{1,3}\.){3}0\/24$/.test(value)) || new Set(topology.remoteSiteCidrs).size !== topology.remoteSiteCidrs.length || !Number.isSafeInteger(topology.healthPort) || topology.healthPort < 1 || topology.healthPort > 65535 || topology.routedTcpPorts.length < 1 || topology.routedTcpPorts.length > 64 || new Set(topology.routedTcpPorts).size !== topology.routedTcpPorts.length || !topology.routedTcpPorts.includes(topology.healthPort) || topology.routedTcpPorts.some((port) => !Number.isSafeInteger(port) || port < 1 || port > 65535) || topology.role === "member" && topology.transport !== "private-lan") {
183
+ if (!/^[A-Za-z0-9.-]{1,253}$/.test(topology.site) || !/^(?:\d{1,3}\.){3}0\/24$/.test(topology.siteCidr) || values.some((value) => isIP(value) !== 4) || identities.some((value) => !/^[A-Za-z0-9.-]{1,253}$/.test(value)) || topology.localPeerIdentities.length !== topology.localPeerAddresses.length || topology.remoteRelayIdentities.length !== topology.remoteRelayAddresses.length || topology.remoteMemberAddresses.length > 1024 || new Set(topology.remoteMemberAddresses).size !== topology.remoteMemberAddresses.length || topology.memberIdentities.length < 1 || topology.memberIdentities.length > 1024 || new Set(topology.memberIdentities).size !== topology.memberIdentities.length || !topology.memberIdentities.includes(topology.nodeIdentity) || !/^sha256:[a-f0-9]{64}$/.test(topology.generation) || (topology.role === "member" ? topology.remoteRelayAddresses.length !== 0 : topology.remoteSiteCidrs.length !== topology.remoteRelayAddresses.length) || topology.remoteSiteCidrs.some((value) => !/^(?:\d{1,3}\.){3}0\/24$/.test(value)) || new Set(topology.remoteSiteCidrs).size !== topology.remoteSiteCidrs.length || !Number.isSafeInteger(topology.healthPort) || topology.healthPort < 1 || topology.healthPort > 65535 || topology.routedTcpPorts.length < 1 || topology.routedTcpPorts.length > 64 || new Set(topology.routedTcpPorts).size !== topology.routedTcpPorts.length || !topology.routedTcpPorts.includes(topology.healthPort) || topology.routedTcpPorts.some((port) => !Number.isSafeInteger(port) || port < 1 || port > 65535) || topology.role === "member" && topology.transport !== "private-lan") {
106
184
  throw new Error("deployment topology is malformed");
107
185
  }
108
186
  if (request.intent.private?.mode === "cloudflare-warp" !== (topology.transport === "cloudflare-warp")) {
@@ -229,28 +307,46 @@ ${forwarding}${noOp}${starts}${starts ? `
229
307
  [Install]
230
308
  WantedBy=multi-user.target
231
309
  `, 420);
232
- for (const source of [...new Set([topology.siteCidr, ...topology.remoteSiteCidrs])]) {
310
+ const firewallComment = `fz-topology-${id}`;
311
+ await replaceTaggedUfwRules(host, firewallComment);
312
+ for (const source of [...new Set([...topology.localPeerAddresses, ...topology.remoteMemberAddresses])]) {
233
313
  for (const port of topology.routedTcpPorts) {
234
- await checked(host, ["/usr/sbin/ufw", "allow", "from", source, "to", "any", "port", String(port), "proto", "tcp"], `private service ${source}:${port}`);
314
+ await checked(host, [
315
+ "/usr/sbin/ufw",
316
+ "allow",
317
+ "from",
318
+ source,
319
+ "to",
320
+ "any",
321
+ "port",
322
+ String(port),
323
+ "proto",
324
+ "tcp",
325
+ "comment",
326
+ firewallComment
327
+ ], `private service ${source}:${port}`);
235
328
  }
236
329
  }
237
330
  if (topology.role !== "member")
238
- for (const remoteCidr of topology.remoteSiteCidrs) {
239
- for (const port of topology.routedTcpPorts) {
240
- await checked(host, [
241
- "/usr/sbin/ufw",
242
- "route",
243
- "allow",
244
- "proto",
245
- "tcp",
246
- "from",
247
- topology.siteCidr,
248
- "to",
249
- remoteCidr,
250
- "port",
251
- String(port)
252
- ], `private routed service ${topology.siteCidr}->${remoteCidr}:${port}`);
253
- }
331
+ for (const remoteAddress of topology.remoteMemberAddresses) {
332
+ for (const source of topology.localPeerAddresses)
333
+ for (const port of topology.routedTcpPorts) {
334
+ await checked(host, [
335
+ "/usr/sbin/ufw",
336
+ "route",
337
+ "allow",
338
+ "proto",
339
+ "tcp",
340
+ "from",
341
+ source,
342
+ "to",
343
+ remoteAddress,
344
+ "port",
345
+ String(port),
346
+ "comment",
347
+ firewallComment
348
+ ], `private routed service ${source}->${remoteAddress}:${port}`);
349
+ }
254
350
  }
255
351
  await checked(host, ["/usr/bin/systemctl", "daemon-reload"], "topology daemon reload");
256
352
  await checked(host, ["/usr/bin/systemctl", "enable", "--now", routeUnit], "topology routes");
@@ -1,5 +1,5 @@
1
1
  import type { NodeKeyPair } from '@forgezero/runtime/identity';
2
- import type { DeploymentManager, DeploymentResult, GitSourceAuth } from './deployment';
2
+ import { type DeploymentManager, type DeploymentResult, type GitSourceAuth } from './deployment';
3
3
  import type { AgentOperationTelemetry } from './telemetry-runtime';
4
4
  export interface RemoteDeploymentClaim {
5
5
  runKey: string;
@@ -21,6 +21,8 @@ export interface DeploymentTopologyAssignment extends DeploymentTopologyPlacemen
21
21
  localPeerIdentities: readonly string[];
22
22
  remoteRelayAddresses: readonly string[];
23
23
  remoteRelayIdentities: readonly string[];
24
+ /** Exact deployment members at other Metal sites; used for host firewall authority. */
25
+ remoteMemberAddresses: readonly string[];
24
26
  remoteSiteCidrs: readonly string[];
25
27
  memberIdentities: readonly string[];
26
28
  healthPort: number;
@@ -80,6 +80,7 @@ function planDeploymentTopology(args) {
80
80
  const relay = active.get(site);
81
81
  const remoteRelayAddresses = siteNames.filter((other) => other !== site).map((other) => active.get(other).privateAddress);
82
82
  const remoteRelayIdentities = siteNames.filter((other) => other !== site).map((other) => active.get(other).nodeIdentity);
83
+ const remoteMemberAddresses = siteNames.filter((other) => other !== site).flatMap((other) => sites.get(other).map(({ privateAddress }) => privateAddress));
83
84
  const remoteSiteCidrs = siteNames.filter((other) => other !== site).map((other) => siteCidrs.get(other));
84
85
  return members.map((placement, index) => {
85
86
  const role = index === 0 ? "relay" : index === 1 && topology.relays.standbyPerMetal === 1 ? "standby" : "member";
@@ -97,6 +98,7 @@ function planDeploymentTopology(args) {
97
98
  localPeerIdentities,
98
99
  remoteRelayAddresses: crossSite ? remoteRelayAddresses : [],
99
100
  remoteRelayIdentities: crossSite ? remoteRelayIdentities : [],
101
+ remoteMemberAddresses: siteNames.length > 1 ? remoteMemberAddresses : [],
100
102
  remoteSiteCidrs: siteNames.length > 1 ? remoteSiteCidrs : [],
101
103
  healthPort: topology.relays.healthPort,
102
104
  routedTcpPorts: [...topology.relays.routedTcpPorts],