@forgezero/agent 0.1.103 → 0.1.108

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.103";
920
+ var VERSION3 = "0.1.108";
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,10 +115,7 @@ 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
+ githubOAuthClientSecret: string;
122
119
  }
123
120
  export interface EnrolledComputeBootstrapSecrets {
124
121
  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, createPrivateKey, randomBytes as randomBytes3 } from "crypto";
1429
+ import { createHash as createHash2, createHmac as createHmac2, 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.103";
1463
+ var VERSION = "0.1.108";
1424
1464
 
1425
1465
  // src/software.ts
1426
1466
  var PINNED_BUN_VERSION = "1.3.14";
@@ -3188,10 +3228,8 @@ function validatePlatformSharedEnvironment(input) {
3188
3228
  } else if (input.email !== undefined) {
3189
3229
  throw new Error("Bootstrap email provider must be smtp or jetemail.");
3190
3230
  }
3191
- if (input.githubApp) {
3192
- 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)) {
3193
- throw new Error("GitHub App client id, app id or slug is malformed.");
3194
- }
3231
+ if (!input.githubOAuth || !/^(?:Iv1\.[A-Fa-f0-9]{16}|Ov23li[A-Za-z0-9]{14,})$/.test(input.githubOAuth.clientId)) {
3232
+ throw new Error("GitHub OAuth client id is malformed.");
3195
3233
  }
3196
3234
  boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
3197
3235
  if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
@@ -3323,9 +3361,7 @@ function renderPlatformSharedEnvironment(input) {
3323
3361
  FZ_REALTIME_WORKER_SCRIPT: value.realtime?.workerScriptName ?? "",
3324
3362
  FZ_REALTIME_ENDPOINT: value.realtime?.endpoint ?? "",
3325
3363
  FZ_REALTIME_PRODUCER: value.realtime?.producer ?? "",
3326
- FZ_GITHUB_CLIENT_ID: value.githubApp?.clientId ?? "",
3327
- FZ_GITHUB_APP_ID: value.githubApp?.appId ?? "",
3328
- FZ_GITHUB_APP_SLUG: value.githubApp?.slug ?? "",
3364
+ FZ_GITHUB_OAUTH_CLIENT_ID: value.githubOAuth.clientId,
3329
3365
  FZ_PLATFORM_INITIAL_INVENTORY: value.initialInventory ? JSON.stringify(value.initialInventory) : ""
3330
3366
  };
3331
3367
  return `# Generated by fz bootstrap platform. Non-secret coordinates only.
@@ -3337,9 +3373,7 @@ function platformApiCredentialSpecs(options) {
3337
3373
  const optional = [
3338
3374
  ["fz_smtp.password", options.emailProvider === "smtp"],
3339
3375
  ["fz_jetemail.apiKey", options.emailProvider === "jetemail"],
3340
- ["fz_github.clientSecret", options.githubApp],
3341
- ["fz_github.privateKey", options.githubApp],
3342
- ["fz_github.webhookSecret", options.githubApp],
3376
+ ["fz_oauth.github.clientSecret", options.githubOAuth],
3343
3377
  ["CF_API_TOKEN", options.cloudflareKv],
3344
3378
  ["CF_TUNNEL_TOKEN", options.cloudflareKv],
3345
3379
  ["REALTIME_PUBLISH_SECRET", options.realtime],
@@ -3347,6 +3381,7 @@ function platformApiCredentialSpecs(options) {
3347
3381
  ];
3348
3382
  return [
3349
3383
  { name: "arangodb-jwt", encryptedPath: "/etc/forgezero/creds/arangodb-jwt.cred", required: true },
3384
+ { name: "arangodb-root-password", encryptedPath: "/etc/forgezero/creds/arangodb-root-password.cred", required: true },
3350
3385
  { name: "seed-sync-root", encryptedPath: "/etc/forgezero/creds/seed-sync-root.cred", required: true },
3351
3386
  ...optional.filter(([, present]) => present).map(([name]) => ({
3352
3387
  name,
@@ -3742,9 +3777,7 @@ function validatePlatformBootstrapSecrets(config, input) {
3742
3777
  "backupS3Secret",
3743
3778
  "cloudflareTunnelToken",
3744
3779
  "cloudflareApiToken",
3745
- "githubClientSecret",
3746
- "githubPrivateKeyBase64",
3747
- "githubWebhookSecret"
3780
+ "githubOAuthClientSecret"
3748
3781
  ];
3749
3782
  const unknown = Object.keys(source).filter((key) => !allowed.includes(key));
3750
3783
  if (unknown.length)
@@ -3755,9 +3788,7 @@ function validatePlatformBootstrapSecrets(config, input) {
3755
3788
  const backupS3Secret = typeof source.backupS3Secret === "string" ? source.backupS3Secret.trim() : undefined;
3756
3789
  const cloudflareTunnelToken = typeof source.cloudflareTunnelToken === "string" ? source.cloudflareTunnelToken.trim() : undefined;
3757
3790
  const cloudflareApiToken = typeof source.cloudflareApiToken === "string" ? source.cloudflareApiToken.trim() : undefined;
3758
- const githubClientSecret = typeof source.githubClientSecret === "string" ? source.githubClientSecret.trim() : undefined;
3759
- const githubPrivateKeyBase64 = typeof source.githubPrivateKeyBase64 === "string" ? source.githubPrivateKeyBase64.trim() : undefined;
3760
- const githubWebhookSecret = typeof source.githubWebhookSecret === "string" ? source.githubWebhookSecret.trim() : undefined;
3791
+ const githubOAuthClientSecret = typeof source.githubOAuthClientSecret === "string" ? source.githubOAuthClientSecret.trim() : "";
3761
3792
  if (!/^[a-f0-9]{64}$/i.test(clusterBootstrapCode))
3762
3793
  throw new Error("cluster bootstrap code must contain exactly 64 hexadecimal characters");
3763
3794
  if (!emailSecret || emailSecret.length > 16384 || /[\r\n\0]/.test(emailSecret))
@@ -3777,21 +3808,8 @@ function validatePlatformBootstrapSecrets(config, input) {
3777
3808
  }
3778
3809
  if (cloudflareTunnelToken)
3779
3810
  validateCloudflareBootstrapSecretPair({ cloudflareTunnelToken, cloudflareApiToken });
3780
- const githubConfigured = Boolean(config.runtime.environment.githubApp);
3781
- if (githubConfigured !== Boolean(githubClientSecret && githubPrivateKeyBase64 && githubWebhookSecret)) {
3782
- throw new Error("GitHub App coordinates require client secret, private key and webhook secret together");
3783
- }
3784
- if (githubConfigured) {
3785
- if (githubClientSecret.length < 20 || githubClientSecret.length > 512 || /[\r\n\0]/.test(githubClientSecret) || githubWebhookSecret.length < 32 || githubWebhookSecret.length > 512 || /[\r\n\0]/.test(githubWebhookSecret)) {
3786
- throw new Error("GitHub App client or webhook secret is malformed");
3787
- }
3788
- try {
3789
- const pem = Buffer.from(githubPrivateKeyBase64, "base64").toString("utf8");
3790
- if (createPrivateKey(pem).asymmetricKeyType !== "rsa")
3791
- throw new Error("not RSA");
3792
- } catch {
3793
- throw new Error("GitHub App private key must be a base64-encoded RSA private key");
3794
- }
3811
+ if (githubOAuthClientSecret.length < 20 || githubOAuthClientSecret.length > 512 || /[\r\n\0]/.test(githubOAuthClientSecret)) {
3812
+ throw new Error("GitHub OAuth client secret is malformed");
3795
3813
  }
3796
3814
  return {
3797
3815
  clusterBootstrapCode,
@@ -3799,7 +3817,7 @@ function validatePlatformBootstrapSecrets(config, input) {
3799
3817
  ...enrolmentToken ? { enrolmentToken } : {},
3800
3818
  ...backupS3Secret ? { backupS3Secret } : {},
3801
3819
  ...cloudflareTunnelToken ? { cloudflareTunnelToken, cloudflareApiToken } : {},
3802
- ...githubConfigured ? { githubClientSecret, githubPrivateKeyBase64, githubWebhookSecret } : {}
3820
+ githubOAuthClientSecret
3803
3821
  };
3804
3822
  }
3805
3823
  var platformBootstrapRunner = (config) => config.kind === "platform" && config.database.role === "master";
@@ -3814,6 +3832,7 @@ var STATE_PATH = BOOTSTRAP_STATE_PATH;
3814
3832
  var INTENT_PATH = "/var/lib/forgezero/bootstrap.intent.json";
3815
3833
  var CREDS = "/etc/forgezero/creds";
3816
3834
  var JWT_CREDENTIAL = `${CREDS}/arangodb-jwt.cred`;
3835
+ var ARANGO_ROOT_CREDENTIAL = `${CREDS}/arangodb-root-password.cred`;
3817
3836
  var ENROL_CREDENTIAL = `${CREDS}/enrol-token.cred`;
3818
3837
  var TUNNEL_CREDENTIAL = `${CREDS}/CF_TUNNEL_CONNECTOR_TOKEN.cred`;
3819
3838
  var CF_API_CREDENTIAL = `${CREDS}/CF_API_TOKEN.cred`;
@@ -4099,7 +4118,7 @@ WantedBy=multi-user.target
4099
4118
  function databaseVerifyUnit(config) {
4100
4119
  const { address } = config.database;
4101
4120
  return `[Unit]
4102
- Description=Verify ForgeZero ArangoDB Community 3.11.14 writable Coordinator
4121
+ Description=Secure and verify ForgeZero ArangoDB Community 3.11.14 writable Coordinator
4103
4122
  Requires=forgezero-db.service
4104
4123
  After=forgezero-db.service
4105
4124
  PartOf=forgezero-db.service
@@ -4109,7 +4128,9 @@ Type=oneshot
4109
4128
  User=arangodb
4110
4129
  Group=arangodb
4111
4130
  LoadCredentialEncrypted=arangodb-jwt:${JWT_CREDENTIAL}
4112
- 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));'
4131
+ LoadCredentialEncrypted=arangodb-root-password:${ARANGO_ROOT_CREDENTIAL}
4132
+ 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);'
4133
+ ExecStart=/usr/local/bin/fz-agent database-auth-verify --endpoint=http://${unitEscape(address)}:8529
4113
4134
  RemainAfterExit=yes
4114
4135
  TimeoutStartSec=200
4115
4136
  NoNewPrivileges=true
@@ -4576,6 +4597,11 @@ async function bootstrapStatus(host = localBootstrapHost()) {
4576
4597
  if (nginx.exitCode !== 0)
4577
4598
  problems.push("nginx configuration is invalid");
4578
4599
  if (state.databaseRole !== "none") {
4600
+ for (const credential of [JWT_CREDENTIAL, ARANGO_ROOT_CREDENTIAL]) {
4601
+ services[credential] = host.exists(credential);
4602
+ if (!services[credential])
4603
+ problems.push(`${credential} is missing`);
4604
+ }
4579
4605
  const unitPath = "/etc/systemd/system/forgezero-db.service";
4580
4606
  const expectsNoAgency = state.databaseAgency === "none";
4581
4607
  const unitHasNoAgency = host.exists(unitPath) && host.read(unitPath).includes("--cluster.start-agent=false");
@@ -4715,9 +4741,7 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
4715
4741
  backup: checked4.backupS3Secret,
4716
4742
  cloudflareTunnelToken: checked4.cloudflareTunnelToken,
4717
4743
  cloudflareApiToken: checked4.cloudflareApiToken,
4718
- githubClientSecret: checked4.githubClientSecret,
4719
- githubPrivateKey: checked4.githubPrivateKeyBase64 ? Buffer.from(checked4.githubPrivateKeyBase64, "base64").toString("utf8") : undefined,
4720
- githubWebhookSecret: checked4.githubWebhookSecret
4744
+ githubOAuthClientSecret: checked4.githubOAuthClientSecret
4721
4745
  };
4722
4746
  })() : undefined;
4723
4747
  const enrolledPrivate = config.kind === "enrolled-compute" ? validateEnrolledComputeBootstrapSecrets(config, secrets) : undefined;
@@ -4810,14 +4834,15 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
4810
4834
  if (!host.exists(JWT_CREDENTIAL)) {
4811
4835
  await seal(host, "arangodb-jwt", JWT_CREDENTIAL, derive(root, "forgezero/cluster/arangodb-jwt/v1"));
4812
4836
  }
4837
+ if (!host.exists(ARANGO_ROOT_CREDENTIAL)) {
4838
+ await seal(host, "arangodb-root-password", ARANGO_ROOT_CREDENTIAL, `fzr_${derive(root, "forgezero/cluster/arangodb-root-password/v1")}`);
4839
+ }
4813
4840
  await seal(host, "seed-sync-root", SEED_CREDENTIAL, derive(root, "forgezero/cluster/seed-mesh/v1"));
4814
4841
  await seal(host, "backup-recovery-root", BACKUP_RECOVERY_CREDENTIAL, derive(root, "forgezero/backup/recovery-root/v1"));
4815
4842
  const emailCredentialName = config.runtime.environment.email?.provider === "smtp" ? "fz_smtp.password" : config.runtime.environment.email?.provider === "jetemail" ? "fz_jetemail.apiKey" : undefined;
4816
4843
  for (const [name, source] of Object.entries({
4817
4844
  ...emailCredentialName ? { [emailCredentialName]: platformPrivate.email } : {},
4818
- "fz_github.clientSecret": platformPrivate.githubClientSecret,
4819
- "fz_github.privateKey": platformPrivate.githubPrivateKey,
4820
- "fz_github.webhookSecret": platformPrivate.githubWebhookSecret,
4845
+ "fz_oauth.github.clientSecret": platformPrivate.githubOAuthClientSecret,
4821
4846
  "backup.s3.secretAccessKey": platformPrivate.backup
4822
4847
  })) {
4823
4848
  if (source) {
@@ -4834,7 +4859,7 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
4834
4859
  const credentials = platformApiCredentialSpecs({
4835
4860
  emailProvider: runtime.environment.email?.provider,
4836
4861
  cloudflareKv: cloudflareConfigured,
4837
- githubApp: Boolean(runtime.environment.githubApp),
4862
+ githubOAuth: true,
4838
4863
  realtime: Boolean(runtime.environment.realtime)
4839
4864
  });
4840
4865
  const units = renderPlatformApiUnits({
@@ -5082,6 +5107,7 @@ function strictBootstrapDocument(value) {
5082
5107
  "agentOtlpEndpoint",
5083
5108
  "custodianEmail",
5084
5109
  "email",
5110
+ "githubOAuth",
5085
5111
  "deployProfile",
5086
5112
  "otlpFlushIntervalMs",
5087
5113
  "otlpTraceSampleRatio",
@@ -5092,8 +5118,13 @@ function strictBootstrapDocument(value) {
5092
5118
  "databaseReadPreferredCoordinators"
5093
5119
  ], "runtime environment");
5094
5120
  const environment = runtime.environment;
5121
+ exactKeys(environment.githubOAuth, ["clientId"], "GitHub OAuth config");
5095
5122
  if (environment.initialInventory !== undefined) {
5096
- const inventory = exactKeys(environment.initialInventory, ["metalHostname", "region", "computes", "attestation", "deployment"], "initial inventory");
5123
+ const inventory = exactKeys(environment.initialInventory, ["metalHostname", "metalIdentity", "region", "computes", "attestation", "deployment"], "initial inventory");
5124
+ if (inventory.metalIdentity !== undefined) {
5125
+ const identity = exactKeys(inventory.metalIdentity, ["nodeKey", "publicKeys"], "initial Metal identity");
5126
+ exactKeys(identity.publicKeys, ["ed25519", "mlDsa"], "initial Metal public keys");
5127
+ }
5097
5128
  exactKeys(inventory.region, ["key", "label", "country", "city", "confidentialCapable"], "initial inventory region");
5098
5129
  if (inventory.attestation !== undefined) {
5099
5130
  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],