@forgezero/agent 0.1.79 → 0.1.81

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.
@@ -748,6 +748,32 @@ function membersOfLinuxList(value, label) {
748
748
  throw new MetalProvisionError(`${label} list overlaps itself`);
749
749
  return members;
750
750
  }
751
+ function linuxList(members) {
752
+ const sorted = [...new Set(members)].sort((left, right) => left - right);
753
+ const ranges = [];
754
+ for (let index = 0;index < sorted.length; ) {
755
+ const start = sorted[index];
756
+ let end = start;
757
+ while (index + 1 < sorted.length && sorted[index + 1] === end + 1) {
758
+ index += 1;
759
+ end = sorted[index];
760
+ }
761
+ ranges.push(start === end ? String(start) : `${start}-${end}`);
762
+ index += 1;
763
+ }
764
+ return ranges.join(",");
765
+ }
766
+ function physicalCoreGroups(pool) {
767
+ const cpus = membersOfLinuxList(pool.cpus, "CPU");
768
+ if (cpus.length % pool.physicalCores !== 0) {
769
+ throw new MetalProvisionError("CPU pool threads must divide evenly across physical cores");
770
+ }
771
+ const threadsPerCore = cpus.length / pool.physicalCores;
772
+ if (!Number.isInteger(threadsPerCore) || threadsPerCore < 1 || threadsPerCore > 8) {
773
+ throw new MetalProvisionError("invalid CPU pool threads-per-core topology");
774
+ }
775
+ return Array.from({ length: pool.physicalCores }, (_, core) => Array.from({ length: threadsPerCore }, (_2, thread) => cpus[core + thread * pool.physicalCores]));
776
+ }
751
777
  var guestNameFor = (computeKey) => `fzg-${createHash2("sha256").update(computeKey).digest("hex").slice(0, 16)}`;
752
778
  var tapNameFor = (computeKey) => `fzt${createHash2("sha256").update(computeKey).digest("hex").slice(0, 12)}`;
753
779
  var macForAddress = (address) => {
@@ -805,6 +831,7 @@ function validateMetalProfile(profile) {
805
831
  if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
806
832
  throw new MetalProvisionError("invalid CPU pool physical-core count");
807
833
  }
834
+ physicalCoreGroups(pool);
808
835
  for (const cpu of cpus) {
809
836
  if (assigned.has(cpu))
810
837
  throw new MetalProvisionError("CPU pools overlap");
@@ -874,26 +901,38 @@ function allocateCpuPool(profile, claim, rows) {
874
901
  const prior = rows.find((row) => row.computeKey === claim.computeKey);
875
902
  if (prior) {
876
903
  const retained = profile.cpuPools.find((pool) => pool.key === prior.cpuPoolKey);
877
- if (!retained || retained.cpus !== prior.allowedCpus || retained.memoryNodes !== prior.allowedMemoryNodes) {
904
+ const groups2 = retained ? physicalCoreGroups(retained) : [];
905
+ const retainedCpus = new Set(membersOfLinuxList(prior.allowedCpus, "persisted guest CPU"));
906
+ const matchedGroups = groups2.filter((group) => group.every((cpu) => retainedCpus.has(cpu)));
907
+ if (!retained || retained.memoryNodes !== prior.allowedMemoryNodes || matchedGroups.length !== claim.spec.physicalCores || matchedGroups.flat().length !== retainedCpus.size || retainedCpus.size < claim.spec.vcpu || claim.spec.cpuPoolKey !== undefined && claim.spec.cpuPoolKey !== retained.key) {
878
908
  throw new MetalProvisionError("persisted guest CPU pool no longer matches the host profile");
879
909
  }
880
- return retained;
910
+ return {
911
+ key: retained.key,
912
+ cpus: prior.allowedCpus,
913
+ physicalCores: matchedGroups.length,
914
+ memoryNodes: prior.allowedMemoryNodes
915
+ };
881
916
  }
882
- const used = new Set(rows.map((row) => row.cpuPoolKey));
883
- if (claim.spec.cpuPoolKey) {
884
- const requested = profile.cpuPools.find((pool) => pool.key === claim.spec.cpuPoolKey);
885
- if (!requested || used.has(requested.key)) {
886
- throw new MetalProvisionError("requested CPU pool is unavailable");
887
- }
888
- if (requested.physicalCores < claim.spec.physicalCores || membersOfLinuxList(requested.cpus, "CPU").length < claim.spec.vcpu)
889
- throw new MetalProvisionError("requested CPU pool cannot satisfy this guest");
890
- return requested;
891
- }
892
- const candidates = profile.cpuPools.filter((pool) => !used.has(pool.key) && pool.physicalCores >= claim.spec.physicalCores && membersOfLinuxList(pool.cpus, "CPU").length >= claim.spec.vcpu).sort((left, right) => left.physicalCores - right.physicalCores || membersOfLinuxList(left.cpus, "CPU").length - membersOfLinuxList(right.cpus, "CPU").length || left.key.localeCompare(right.key));
917
+ const usedCpus = new Set(rows.flatMap((row) => membersOfLinuxList(row.allowedCpus, "persisted guest CPU")));
918
+ const candidates = profile.cpuPools.filter((pool) => !claim.spec.cpuPoolKey || pool.key === claim.spec.cpuPoolKey).map((pool) => ({
919
+ pool,
920
+ available: physicalCoreGroups(pool).filter((group) => group.every((cpu) => !usedCpus.has(cpu)))
921
+ })).filter(({ available }) => {
922
+ const selected2 = available.slice(0, claim.spec.physicalCores);
923
+ return selected2.length === claim.spec.physicalCores && selected2.flat().length >= claim.spec.vcpu;
924
+ }).sort((left, right) => left.available.length - right.available.length || left.pool.key.localeCompare(right.pool.key));
893
925
  const selected = candidates[0];
894
- if (!selected)
895
- throw new MetalProvisionError("no exclusive CPU pool can satisfy this guest");
896
- return selected;
926
+ if (!selected) {
927
+ throw new MetalProvisionError(claim.spec.cpuPoolKey ? "requested CPU pool cannot satisfy this guest" : "no exclusive CPU capacity can satisfy this guest");
928
+ }
929
+ const groups = selected.available.slice(0, claim.spec.physicalCores);
930
+ return {
931
+ key: selected.pool.key,
932
+ cpus: linuxList(groups.flat()),
933
+ physicalCores: groups.length,
934
+ memoryNodes: selected.pool.memoryNodes
935
+ };
897
936
  }
898
937
  var base64 = (value) => Buffer.from(value).toString("base64");
899
938
  var yamlFile = (path2, content, permissions) => ` - path: ${JSON.stringify(path2)}
@@ -1416,7 +1416,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
1416
1416
  var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
1417
1417
 
1418
1418
  // src/version.ts
1419
- var VERSION = "0.1.79";
1419
+ var VERSION = "0.1.81";
1420
1420
 
1421
1421
  // src/software.ts
1422
1422
  var PINNED_BUN_VERSION = "1.3.14";
@@ -3089,7 +3089,14 @@ function validatePlatformSharedEnvironment(input) {
3089
3089
  throw new Error("Bootstrap email provider must be smtp or jetemail.");
3090
3090
  }
3091
3091
  boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
3092
- boundedInteger("seedSyncMembers", input.seedSyncMembers, 1, 64);
3092
+ if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
3093
+ throw new Error("seedSyncMembers must contain 3 to 64 unique physical host identities.");
3094
+ }
3095
+ for (const member of input.seedSyncMembers)
3096
+ safeAtom("seedSyncMembers item", member);
3097
+ if (!input.seedSyncMembers.includes(input.nodeHostname)) {
3098
+ throw new Error("seedSyncMembers must include this nodeHostname.");
3099
+ }
3093
3100
  boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
3094
3101
  boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
3095
3102
  boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
@@ -3169,7 +3176,7 @@ function renderPlatformSharedEnvironment(input) {
3169
3176
  FZ_DB_MASTER: value.databaseMaster ?? "",
3170
3177
  FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
3171
3178
  FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
3172
- FZ_SEED_SYNC_MEMBERS: String(value.seedSyncMembers),
3179
+ FZ_SEED_SYNC_MEMBERS: value.seedSyncMembers.join(","),
3173
3180
  FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
3174
3181
  FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
3175
3182
  FZ_SHARED_DIR: value.sharedDirectory,
@@ -3238,7 +3245,12 @@ function platformApiCredentialSpecs(options) {
3238
3245
  ];
3239
3246
  }
3240
3247
  function renderPlatformApiUnits(input) {
3241
- for (const path of [input.sharedDirectory, input.sharedEnvironmentFile, input.slotsDirectory]) {
3248
+ for (const path of [
3249
+ input.sharedDirectory,
3250
+ input.sharedEnvironmentFile,
3251
+ input.slotsDirectory,
3252
+ ...input.topologyEnvironmentFile ? [input.topologyEnvironmentFile] : []
3253
+ ]) {
3242
3254
  if (!path.startsWith("/") || /[\r\n]/.test(path))
3243
3255
  throw new Error("Runtime paths must be absolute and single-line.");
3244
3256
  }
@@ -3252,6 +3264,8 @@ function renderPlatformApiUnits(input) {
3252
3264
  const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
3253
3265
  `);
3254
3266
  const capacityEnvironment = input.capacityEnvironmentFile ? `EnvironmentFile=-${input.capacityEnvironmentFile}
3267
+ ` : "";
3268
+ const topologyEnvironment = input.topologyEnvironmentFile ? `EnvironmentFile=-${input.topologyEnvironmentFile}
3255
3269
  ` : "";
3256
3270
  const template = `[Unit]
3257
3271
  Description=ForgeZero (%i slot)
@@ -3265,7 +3279,7 @@ WorkingDirectory=${input.slotsDirectory}/%i
3265
3279
  Environment=NODE_ENV=production
3266
3280
  Environment=FZ_SLOT=%i
3267
3281
  EnvironmentFile=${input.sharedEnvironmentFile}
3268
- ${capacityEnvironment}${credentials}
3282
+ ${capacityEnvironment}${topologyEnvironment}${credentials}
3269
3283
  ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
3270
3284
  Restart=always
3271
3285
  RestartSec=2
@@ -4517,7 +4531,8 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
4517
4531
  greenPort: runtime.greenPort,
4518
4532
  collectorUnit: runtime.environment.otlpCollectorUnit,
4519
4533
  credentials,
4520
- capacityEnvironmentFile: "/etc/forgezero/capacity.env"
4534
+ capacityEnvironmentFile: "/etc/forgezero/capacity.env",
4535
+ topologyEnvironmentFile: "/etc/forgezero/deployment-topology.env"
4521
4536
  });
4522
4537
  const edge = renderPlatformNginx({
4523
4538
  publicPort: runtime.environment.publicApiPort,
@@ -5012,6 +5027,17 @@ function membersOfLinuxList(value, label) {
5012
5027
  throw new MetalProvisionError(`${label} list overlaps itself`);
5013
5028
  return members;
5014
5029
  }
5030
+ function physicalCoreGroups(pool) {
5031
+ const cpus = membersOfLinuxList(pool.cpus, "CPU");
5032
+ if (cpus.length % pool.physicalCores !== 0) {
5033
+ throw new MetalProvisionError("CPU pool threads must divide evenly across physical cores");
5034
+ }
5035
+ const threadsPerCore = cpus.length / pool.physicalCores;
5036
+ if (!Number.isInteger(threadsPerCore) || threadsPerCore < 1 || threadsPerCore > 8) {
5037
+ throw new MetalProvisionError("invalid CPU pool threads-per-core topology");
5038
+ }
5039
+ return Array.from({ length: pool.physicalCores }, (_, core) => Array.from({ length: threadsPerCore }, (_2, thread) => cpus[core + thread * pool.physicalCores]));
5040
+ }
5015
5041
  function validateMetalProfile(profile) {
5016
5042
  if (!SAFE_NAME2.test(profile.volumeGroup))
5017
5043
  throw new MetalProvisionError("invalid volume group");
@@ -5060,6 +5086,7 @@ function validateMetalProfile(profile) {
5060
5086
  if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
5061
5087
  throw new MetalProvisionError("invalid CPU pool physical-core count");
5062
5088
  }
5089
+ physicalCoreGroups(pool);
5063
5090
  for (const cpu of cpus) {
5064
5091
  if (assigned.has(cpu))
5065
5092
  throw new MetalProvisionError("CPU pools overlap");
@@ -62,7 +62,8 @@ export interface PlatformSharedEnvironment {
62
62
  publicApiPort: number;
63
63
  sharedDirectory: string;
64
64
  seedSyncPeers: string[];
65
- seedSyncMembers: number;
65
+ /** Exact physical API host identities participating in this seed epoch. */
66
+ seedSyncMembers: string[];
66
67
  seedSyncEpoch: string;
67
68
  concurrencyLimit: number;
68
69
  drainDeadlineMs: number;
@@ -120,6 +121,8 @@ export interface ApiRuntimeRenderOptions {
120
121
  credentials: SystemdCredentialSpec[];
121
122
  /** Root-owned calibration override, loaded after the ordinary shared environment. */
122
123
  capacityEnvironmentFile?: string;
124
+ /** Root-owned signed deployment topology, absent until post-genesis reconciliation. */
125
+ topologyEnvironmentFile?: string;
123
126
  }
124
127
  export declare function renderPlatformApiUnits(input: ApiRuntimeRenderOptions): {
125
128
  template: string;
@@ -811,7 +811,14 @@ function validatePlatformSharedEnvironment(input) {
811
811
  throw new Error("Bootstrap email provider must be smtp or jetemail.");
812
812
  }
813
813
  boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
814
- boundedInteger("seedSyncMembers", input.seedSyncMembers, 1, 64);
814
+ if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
815
+ throw new Error("seedSyncMembers must contain 3 to 64 unique physical host identities.");
816
+ }
817
+ for (const member of input.seedSyncMembers)
818
+ safeAtom("seedSyncMembers item", member);
819
+ if (!input.seedSyncMembers.includes(input.nodeHostname)) {
820
+ throw new Error("seedSyncMembers must include this nodeHostname.");
821
+ }
815
822
  boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
816
823
  boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
817
824
  boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
@@ -891,7 +898,7 @@ function renderPlatformSharedEnvironment(input) {
891
898
  FZ_DB_MASTER: value.databaseMaster ?? "",
892
899
  FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
893
900
  FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
894
- FZ_SEED_SYNC_MEMBERS: String(value.seedSyncMembers),
901
+ FZ_SEED_SYNC_MEMBERS: value.seedSyncMembers.join(","),
895
902
  FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
896
903
  FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
897
904
  FZ_SHARED_DIR: value.sharedDirectory,
@@ -960,7 +967,12 @@ function platformApiCredentialSpecs(options) {
960
967
  ];
961
968
  }
962
969
  function renderPlatformApiUnits(input) {
963
- for (const path2 of [input.sharedDirectory, input.sharedEnvironmentFile, input.slotsDirectory]) {
970
+ for (const path2 of [
971
+ input.sharedDirectory,
972
+ input.sharedEnvironmentFile,
973
+ input.slotsDirectory,
974
+ ...input.topologyEnvironmentFile ? [input.topologyEnvironmentFile] : []
975
+ ]) {
964
976
  if (!path2.startsWith("/") || /[\r\n]/.test(path2))
965
977
  throw new Error("Runtime paths must be absolute and single-line.");
966
978
  }
@@ -974,6 +986,8 @@ function renderPlatformApiUnits(input) {
974
986
  const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
975
987
  `);
976
988
  const capacityEnvironment = input.capacityEnvironmentFile ? `EnvironmentFile=-${input.capacityEnvironmentFile}
989
+ ` : "";
990
+ const topologyEnvironment = input.topologyEnvironmentFile ? `EnvironmentFile=-${input.topologyEnvironmentFile}
977
991
  ` : "";
978
992
  const template = `[Unit]
979
993
  Description=ForgeZero (%i slot)
@@ -987,7 +1001,7 @@ WorkingDirectory=${input.slotsDirectory}/%i
987
1001
  Environment=NODE_ENV=production
988
1002
  Environment=FZ_SLOT=%i
989
1003
  EnvironmentFile=${input.sharedEnvironmentFile}
990
- ${capacityEnvironment}${credentials}
1004
+ ${capacityEnvironment}${topologyEnvironment}${credentials}
991
1005
  ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
992
1006
  Restart=always
993
1007
  RestartSec=2
@@ -1649,6 +1649,7 @@ function phasePipeline(definition, phase, profile, executeRelease = false) {
1649
1649
  // src/deployment-connectivity.ts
1650
1650
  import { createHash as createHash3 } from "node:crypto";
1651
1651
  import { mkdirSync as mkdirSync4, renameSync as renameSync4, writeFileSync as writeFileSync4 } from "node:fs";
1652
+ import { createConnection } from "node:net";
1652
1653
  import { dirname as dirname4 } from "node:path";
1653
1654
 
1654
1655
  // src/process-input.ts
@@ -1701,6 +1702,21 @@ var defaultHost = {
1701
1702
  const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
1702
1703
  return { exitCode, output: `${stdout}${stderr}` };
1703
1704
  },
1705
+ probe: (address, port, timeoutMs) => new Promise((resolve3) => {
1706
+ const socket = createConnection({ host: address, port });
1707
+ let settled = false;
1708
+ const finish = (value) => {
1709
+ if (settled)
1710
+ return;
1711
+ settled = true;
1712
+ socket.destroy();
1713
+ resolve3(value);
1714
+ };
1715
+ socket.setTimeout(timeoutMs);
1716
+ socket.once("connect", () => finish(true));
1717
+ socket.once("timeout", () => finish(false));
1718
+ socket.once("error", () => finish(false));
1719
+ }),
1704
1720
  sleep: (ms) => Bun.sleep(ms)
1705
1721
  };
1706
1722
  function validate(request) {
@@ -1715,7 +1731,7 @@ function validate(request) {
1715
1731
  } else if (request.capabilities.public)
1716
1732
  throw new Error("unexpected public deployment capability");
1717
1733
  if (privateIntent?.mode === "cloudflare-warp") {
1718
- if (!NAME2.test(privateIntent.credential) || privateIntent.network.length < 1 || privateIntent.network.length > 128 || !request.capabilities.private || !UUID.test(request.capabilities.private.connectorId) || !TOKEN.test(request.capabilities.private.connectorToken)) {
1734
+ if (!NAME2.test(privateIntent.credential) || privateIntent.network.length < 1 || privateIntent.network.length > 128 || !request.capabilities.private || !UUID.test(request.capabilities.private.connectorId) || !UUID.test(request.capabilities.private.siteRouteId) || !TOKEN.test(request.capabilities.private.connectorToken)) {
1719
1735
  throw new Error("private deployment connectivity is malformed");
1720
1736
  }
1721
1737
  } else if (request.capabilities.private)
@@ -1725,6 +1741,22 @@ async function applyDeploymentConnectivity(request, host = defaultHost) {
1725
1741
  validate(request);
1726
1742
  const id = idFor(request.key);
1727
1743
  const evidence = { key: request.key };
1744
+ const topology = request.topology;
1745
+ if (topology) {
1746
+ const values = [topology.localRelayAddress, ...topology.localPeerAddresses, ...topology.remoteRelayAddresses];
1747
+ const identities = [
1748
+ topology.nodeIdentity,
1749
+ ...topology.localPeerIdentities,
1750
+ ...topology.remoteRelayIdentities,
1751
+ ...topology.memberIdentities
1752
+ ];
1753
+ 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") {
1754
+ throw new Error("deployment topology is malformed");
1755
+ }
1756
+ if (request.intent.private?.mode === "cloudflare-warp" !== (topology.transport === "cloudflare-warp")) {
1757
+ throw new Error("deployment topology transport differs from private connectivity intent");
1758
+ }
1759
+ }
1728
1760
  if (request.intent.public?.mode === "cloudflare-tunnel") {
1729
1761
  const capability = request.capabilities.public;
1730
1762
  const credentialName = `FZ_TUNNEL_${id.toUpperCase()}`;
@@ -1762,6 +1794,12 @@ WantedBy=multi-user.target
1762
1794
  const capability = request.capabilities.private;
1763
1795
  const credentialPath = "/etc/forgezero/creds/CF_WARP_CONNECTOR_TOKEN.cred";
1764
1796
  const unit = "forgezero-deployment-mesh.service";
1797
+ const forwarding = "/etc/sysctl.d/99-forgezero-mesh.conf";
1798
+ host.write(forwarding, `net.ipv4.ip_forward = 1
1799
+ net.ipv6.conf.all.forwarding = 1
1800
+ net.ipv6.conf.all.accept_ra = 2
1801
+ `, 420);
1802
+ await checked2(host, ["/usr/sbin/sysctl", "-p", forwarding], "Mesh subnet forwarding");
1765
1803
  await host.seal("CF_WARP_CONNECTOR_TOKEN", credentialPath, capability.connectorToken);
1766
1804
  host.write(`/etc/systemd/system/${unit}`, `[Unit]
1767
1805
  Description=ForgeZero deployment Mesh/WARP connector
@@ -1796,6 +1834,95 @@ WantedBy=multi-user.target
1796
1834
  if (!evidence.private)
1797
1835
  throw new Error("WARP connector readiness failed");
1798
1836
  }
1837
+ if (topology) {
1838
+ const syncAddresses = topology.role === "relay" ? [...topology.localPeerAddresses, ...topology.remoteRelayAddresses] : [topology.localRelayAddress];
1839
+ const topologyEpoch = `topology-${topology.generation.slice("sha256:".length, "sha256:".length + 48)}`;
1840
+ host.write("/etc/forgezero/deployment-topology.env", [
1841
+ `FZ_TOPOLOGY_NODE_IDENTITY=${topology.nodeIdentity}`,
1842
+ `FZ_TOPOLOGY_PEER_ADDRESSES=${[...new Set(syncAddresses)].join(",")}`,
1843
+ `FZ_TOPOLOGY_MEMBERS=${topology.memberIdentities.join(",")}`,
1844
+ `FZ_TOPOLOGY_EPOCH=${topologyEpoch}`,
1845
+ `FZ_TOPOLOGY_SITE=${topology.site}`,
1846
+ `FZ_TOPOLOGY_ROLE=${topology.role}`,
1847
+ `FZ_TOPOLOGY_TRANSPORT=${topology.transport}`,
1848
+ ""
1849
+ ].join(`
1850
+ `), 384);
1851
+ const routeUnit = "forgezero-deployment-topology.service";
1852
+ const routeUnitPath = `/etc/systemd/system/${routeUnit}`;
1853
+ const routePairs = topology.role === "member" ? topology.remoteSiteCidrs.map((network) => ({ network, via: topology.localRelayAddress })) : topology.transport === "private-lan" ? topology.remoteSiteCidrs.map((network, index) => ({ network, via: topology.remoteRelayAddresses[index] })) : [];
1854
+ for (const { via } of routePairs) {
1855
+ await checked2(host, ["/usr/sbin/ip", "route", "get", via], `private route gateway ${via}`);
1856
+ }
1857
+ await host.exec(["/usr/bin/systemctl", "disable", "--now", routeUnit]);
1858
+ const forwarding = topology.role !== "member" && topology.remoteSiteCidrs.length > 0 ? `ExecStart=/usr/sbin/sysctl -w net.ipv4.ip_forward=1
1859
+ ` : "";
1860
+ const starts = routePairs.map(({ network, via }) => `ExecStart=/usr/sbin/ip route replace ${network} via ${via}`).join(`
1861
+ `);
1862
+ const stops = routePairs.map(({ network, via }) => `ExecStop=-/usr/sbin/ip route del ${network} via ${via}`).join(`
1863
+ `);
1864
+ const noOp = !forwarding && !starts ? `ExecStart=/usr/bin/true
1865
+ ` : "";
1866
+ host.write(routeUnitPath, `[Unit]
1867
+ Description=ForgeZero fenced deployment site routing
1868
+ After=network-online.target${topology.transport === "cloudflare-warp" ? " forgezero-deployment-mesh.service" : ""}
1869
+ Wants=network-online.target
1870
+
1871
+ [Service]
1872
+ Type=oneshot
1873
+ RemainAfterExit=yes
1874
+ ${forwarding}${noOp}${starts}${starts ? `
1875
+ ` : ""}${stops}${stops ? `
1876
+ ` : ""}
1877
+ [Install]
1878
+ WantedBy=multi-user.target
1879
+ `, 420);
1880
+ for (const source of [...new Set([topology.siteCidr, ...topology.remoteSiteCidrs])]) {
1881
+ for (const port of topology.routedTcpPorts) {
1882
+ await checked2(host, ["/usr/sbin/ufw", "allow", "from", source, "to", "any", "port", String(port), "proto", "tcp"], `private service ${source}:${port}`);
1883
+ }
1884
+ }
1885
+ if (topology.role !== "member")
1886
+ for (const remoteCidr of topology.remoteSiteCidrs) {
1887
+ for (const port of topology.routedTcpPorts) {
1888
+ await checked2(host, [
1889
+ "/usr/sbin/ufw",
1890
+ "route",
1891
+ "allow",
1892
+ "proto",
1893
+ "tcp",
1894
+ "from",
1895
+ topology.siteCidr,
1896
+ "to",
1897
+ remoteCidr,
1898
+ "port",
1899
+ String(port)
1900
+ ], `private routed service ${topology.siteCidr}->${remoteCidr}:${port}`);
1901
+ }
1902
+ }
1903
+ await checked2(host, ["/usr/bin/systemctl", "daemon-reload"], "topology daemon reload");
1904
+ await checked2(host, ["/usr/bin/systemctl", "enable", "--now", routeUnit], "topology routes");
1905
+ const probes = topology.role === "member" ? [topology.localRelayAddress] : topology.remoteRelayAddresses;
1906
+ for (const address of [...new Set(probes)]) {
1907
+ await checked2(host, ["/usr/sbin/ip", "route", "get", address], `private route ${address}`);
1908
+ let ready = false;
1909
+ for (let attempt = 0;attempt < 10; attempt += 1) {
1910
+ if (await host.probe(address, topology.healthPort, 2000)) {
1911
+ ready = true;
1912
+ break;
1913
+ }
1914
+ await host.sleep(1000);
1915
+ }
1916
+ if (!ready)
1917
+ throw new Error(`private topology relay ${address}:${topology.healthPort} is unreachable`);
1918
+ }
1919
+ evidence.topology = {
1920
+ site: topology.site,
1921
+ siteCidr: topology.siteCidr,
1922
+ role: topology.role,
1923
+ probed: [...new Set(probes)]
1924
+ };
1925
+ }
1799
1926
  return evidence;
1800
1927
  }
1801
1928
 
@@ -2520,7 +2647,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
2520
2647
  }
2521
2648
 
2522
2649
  // src/version.ts
2523
- var VERSION3 = "0.1.79";
2650
+ var VERSION3 = "0.1.81";
2524
2651
 
2525
2652
  // src/egress-policy.ts
2526
2653
  import { realpathSync as realpathSync3 } from "node:fs";
@@ -3780,7 +3907,14 @@ function validatePlatformSharedEnvironment(input) {
3780
3907
  throw new Error("Bootstrap email provider must be smtp or jetemail.");
3781
3908
  }
3782
3909
  boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
3783
- boundedInteger("seedSyncMembers", input.seedSyncMembers, 1, 64);
3910
+ if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
3911
+ throw new Error("seedSyncMembers must contain 3 to 64 unique physical host identities.");
3912
+ }
3913
+ for (const member of input.seedSyncMembers)
3914
+ safeAtom("seedSyncMembers item", member);
3915
+ if (!input.seedSyncMembers.includes(input.nodeHostname)) {
3916
+ throw new Error("seedSyncMembers must include this nodeHostname.");
3917
+ }
3784
3918
  boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
3785
3919
  boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
3786
3920
  boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
@@ -3860,7 +3994,7 @@ function renderPlatformSharedEnvironment(input) {
3860
3994
  FZ_DB_MASTER: value.databaseMaster ?? "",
3861
3995
  FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
3862
3996
  FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
3863
- FZ_SEED_SYNC_MEMBERS: String(value.seedSyncMembers),
3997
+ FZ_SEED_SYNC_MEMBERS: value.seedSyncMembers.join(","),
3864
3998
  FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
3865
3999
  FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
3866
4000
  FZ_SHARED_DIR: value.sharedDirectory,
@@ -3929,7 +4063,12 @@ function platformApiCredentialSpecs(options) {
3929
4063
  ];
3930
4064
  }
3931
4065
  function renderPlatformApiUnits(input) {
3932
- for (const path2 of [input.sharedDirectory, input.sharedEnvironmentFile, input.slotsDirectory]) {
4066
+ for (const path2 of [
4067
+ input.sharedDirectory,
4068
+ input.sharedEnvironmentFile,
4069
+ input.slotsDirectory,
4070
+ ...input.topologyEnvironmentFile ? [input.topologyEnvironmentFile] : []
4071
+ ]) {
3933
4072
  if (!path2.startsWith("/") || /[\r\n]/.test(path2))
3934
4073
  throw new Error("Runtime paths must be absolute and single-line.");
3935
4074
  }
@@ -3943,6 +4082,8 @@ function renderPlatformApiUnits(input) {
3943
4082
  const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
3944
4083
  `);
3945
4084
  const capacityEnvironment = input.capacityEnvironmentFile ? `EnvironmentFile=-${input.capacityEnvironmentFile}
4085
+ ` : "";
4086
+ const topologyEnvironment = input.topologyEnvironmentFile ? `EnvironmentFile=-${input.topologyEnvironmentFile}
3946
4087
  ` : "";
3947
4088
  const template = `[Unit]
3948
4089
  Description=ForgeZero (%i slot)
@@ -3956,7 +4097,7 @@ WorkingDirectory=${input.slotsDirectory}/%i
3956
4097
  Environment=NODE_ENV=production
3957
4098
  Environment=FZ_SLOT=%i
3958
4099
  EnvironmentFile=${input.sharedEnvironmentFile}
3959
- ${capacityEnvironment}${credentials}
4100
+ ${capacityEnvironment}${topologyEnvironment}${credentials}
3960
4101
  ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
3961
4102
  Restart=always
3962
4103
  RestartSec=2