@forgezero/agent 0.1.103 → 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.103";
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,
package/dist/bootstrap.js CHANGED
@@ -1384,6 +1384,46 @@ 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
1429
  import { createHash as createHash2, createHmac as createHmac2, createPrivateKey, randomBytes as randomBytes3 } from "crypto";
@@ -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.107";
1424
1464
 
1425
1465
  // src/software.ts
1426
1466
  var PINNED_BUN_VERSION = "1.3.14";
@@ -3347,6 +3387,7 @@ function platformApiCredentialSpecs(options) {
3347
3387
  ];
3348
3388
  return [
3349
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 },
3350
3391
  { name: "seed-sync-root", encryptedPath: "/etc/forgezero/creds/seed-sync-root.cred", required: true },
3351
3392
  ...optional.filter(([, present]) => present).map(([name]) => ({
3352
3393
  name,
@@ -3814,6 +3855,7 @@ var STATE_PATH = BOOTSTRAP_STATE_PATH;
3814
3855
  var INTENT_PATH = "/var/lib/forgezero/bootstrap.intent.json";
3815
3856
  var CREDS = "/etc/forgezero/creds";
3816
3857
  var JWT_CREDENTIAL = `${CREDS}/arangodb-jwt.cred`;
3858
+ var ARANGO_ROOT_CREDENTIAL = `${CREDS}/arangodb-root-password.cred`;
3817
3859
  var ENROL_CREDENTIAL = `${CREDS}/enrol-token.cred`;
3818
3860
  var TUNNEL_CREDENTIAL = `${CREDS}/CF_TUNNEL_CONNECTOR_TOKEN.cred`;
3819
3861
  var CF_API_CREDENTIAL = `${CREDS}/CF_API_TOKEN.cred`;
@@ -4099,7 +4141,7 @@ WantedBy=multi-user.target
4099
4141
  function databaseVerifyUnit(config) {
4100
4142
  const { address } = config.database;
4101
4143
  return `[Unit]
4102
- Description=Verify ForgeZero ArangoDB Community 3.11.14 writable Coordinator
4144
+ Description=Secure and verify ForgeZero ArangoDB Community 3.11.14 writable Coordinator
4103
4145
  Requires=forgezero-db.service
4104
4146
  After=forgezero-db.service
4105
4147
  PartOf=forgezero-db.service
@@ -4109,7 +4151,9 @@ Type=oneshot
4109
4151
  User=arangodb
4110
4152
  Group=arangodb
4111
4153
  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));'
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
4113
4157
  RemainAfterExit=yes
4114
4158
  TimeoutStartSec=200
4115
4159
  NoNewPrivileges=true
@@ -4576,6 +4620,11 @@ async function bootstrapStatus(host = localBootstrapHost()) {
4576
4620
  if (nginx.exitCode !== 0)
4577
4621
  problems.push("nginx configuration is invalid");
4578
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
+ }
4579
4628
  const unitPath = "/etc/systemd/system/forgezero-db.service";
4580
4629
  const expectsNoAgency = state.databaseAgency === "none";
4581
4630
  const unitHasNoAgency = host.exists(unitPath) && host.read(unitPath).includes("--cluster.start-agent=false");
@@ -4810,6 +4859,9 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
4810
4859
  if (!host.exists(JWT_CREDENTIAL)) {
4811
4860
  await seal(host, "arangodb-jwt", JWT_CREDENTIAL, derive(root, "forgezero/cluster/arangodb-jwt/v1"));
4812
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
+ }
4813
4865
  await seal(host, "seed-sync-root", SEED_CREDENTIAL, derive(root, "forgezero/cluster/seed-mesh/v1"));
4814
4866
  await seal(host, "backup-recovery-root", BACKUP_RECOVERY_CREDENTIAL, derive(root, "forgezero/backup/recovery-root/v1"));
4815
4867
  const emailCredentialName = config.runtime.environment.email?.provider === "smtp" ? "fz_smtp.password" : config.runtime.environment.email?.provider === "jetemail" ? "fz_jetemail.apiKey" : undefined;
@@ -5093,7 +5145,11 @@ function strictBootstrapDocument(value) {
5093
5145
  ], "runtime environment");
5094
5146
  const environment = runtime.environment;
5095
5147
  if (environment.initialInventory !== undefined) {
5096
- 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
+ }
5097
5153
  exactKeys(inventory.region, ["key", "label", "country", "city", "confidentialCapable"], "initial inventory region");
5098
5154
  if (inventory.attestation !== undefined) {
5099
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],
@@ -38,6 +38,7 @@ export interface DeploymentRequest {
38
38
  sharing: ResolvedDeploymentTarget['allocation']['sharing'];
39
39
  resources: ResolvedDeploymentTarget['allocation']['resources'];
40
40
  connectivity?: DeploymentConnectivityIntent;
41
+ firewallPolicy?: import('./deployment-connectivity').DeploymentFirewallPolicy;
41
42
  topology?: {
42
43
  site: string;
43
44
  nodeIdentity: string;
@@ -50,6 +51,7 @@ export interface DeploymentRequest {
50
51
  localPeerIdentities: readonly string[];
51
52
  remoteRelayAddresses: readonly string[];
52
53
  remoteRelayIdentities: readonly string[];
54
+ remoteMemberAddresses: readonly string[];
53
55
  remoteSiteCidrs: readonly string[];
54
56
  memberIdentities: readonly string[];
55
57
  healthPort: number;
@@ -75,6 +77,8 @@ export interface DeploymentResult {
75
77
  release: string;
76
78
  ok: boolean;
77
79
  phases: readonly RunResult[];
80
+ /** Bounded, credential-redacted execution evidence suitable for control-plane history. */
81
+ execution: DeploymentExecution;
78
82
  capacity?: CapacityCalibration & {
79
83
  evidencePath: string;
80
84
  recommendedCoordinate: {
@@ -83,6 +87,18 @@ export interface DeploymentResult {
83
87
  };
84
88
  };
85
89
  }
90
+ export interface DeploymentExecutionStep {
91
+ phase: string;
92
+ step: string;
93
+ outcome: 'ok' | 'failed' | 'skipped';
94
+ exitCode: number | null;
95
+ durationMs: number;
96
+ /** Present only for a failed step; known injected credentials are redacted first. */
97
+ detail?: string;
98
+ }
99
+ export interface DeploymentExecution {
100
+ steps: readonly DeploymentExecutionStep[];
101
+ }
86
102
  export interface ResolvedDeploymentTarget {
87
103
  name: string;
88
104
  profiles: readonly string[];
@@ -245,7 +261,8 @@ export interface DeploymentOptions {
245
261
  }
246
262
  export declare class DeploymentError extends Error {
247
263
  readonly code: 'BAD_REVISION' | 'SOURCE_FAILED' | 'PIPELINE_FAILED' | 'SECRET_MISSING';
248
- constructor(code: 'BAD_REVISION' | 'SOURCE_FAILED' | 'PIPELINE_FAILED' | 'SECRET_MISSING', message: string);
264
+ readonly execution?: DeploymentExecution | undefined;
265
+ constructor(code: 'BAD_REVISION' | 'SOURCE_FAILED' | 'PIPELINE_FAILED' | 'SECRET_MISSING', message: string, execution?: DeploymentExecution | undefined);
249
266
  }
250
267
  /**
251
268
  * One source and one pipeline owner.