@forgezero/agent 0.1.78 → 0.1.80

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.
@@ -843,16 +843,38 @@ var runSoftwareCommand = async (argv, env = {}) => {
843
843
  return { exitCode, output: `${stdout}${stderr}` };
844
844
  };
845
845
  var run = runSoftwareCommand;
846
+ var softwareDownloadCommand = (url, destination, maximumBytes) => {
847
+ const source = new URL(url);
848
+ if (source.protocol !== "https:" || source.username || source.password)
849
+ throw new Error("software download must use credential-free HTTPS");
850
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1)
851
+ throw new Error("software download size bound is invalid");
852
+ return [
853
+ "/usr/bin/curl",
854
+ "--fail",
855
+ "--location",
856
+ "--silent",
857
+ "--show-error",
858
+ "--proto",
859
+ "=https",
860
+ "--tlsv1.2",
861
+ "--max-time",
862
+ "1800",
863
+ "--max-filesize",
864
+ String(maximumBytes),
865
+ "--output",
866
+ destination,
867
+ source.href
868
+ ];
869
+ };
846
870
  var download = async (url, destination, sha256, maximumBytes = 512 * 1024 * 1024) => {
847
- const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(30 * 60000) });
848
- if (!response.ok)
849
- throw new Error(`download failed with HTTP ${response.status}`);
850
- const declared = Number(response.headers.get("content-length") ?? 0);
851
- if (declared > maximumBytes)
852
- throw new Error("download exceeds reviewed size bound");
853
871
  if (existsSync3(destination))
854
872
  throw new Error("download destination already exists");
855
- await Bun.write(destination, response);
873
+ const received = await run(softwareDownloadCommand(url, destination, maximumBytes));
874
+ if (received.exitCode !== 0) {
875
+ rmSync3(destination, { force: true });
876
+ throw new Error(`software download failed (${received.exitCode}): ${received.output.trim()}`);
877
+ }
856
878
  chmodSync3(destination, 384);
857
879
  if (statSync(destination).size > maximumBytes) {
858
880
  rmSync3(destination, { force: true });
@@ -1627,6 +1649,7 @@ function phasePipeline(definition, phase, profile, executeRelease = false) {
1627
1649
  // src/deployment-connectivity.ts
1628
1650
  import { createHash as createHash3 } from "node:crypto";
1629
1651
  import { mkdirSync as mkdirSync4, renameSync as renameSync4, writeFileSync as writeFileSync4 } from "node:fs";
1652
+ import { createConnection } from "node:net";
1630
1653
  import { dirname as dirname4 } from "node:path";
1631
1654
 
1632
1655
  // src/process-input.ts
@@ -1679,6 +1702,21 @@ var defaultHost = {
1679
1702
  const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
1680
1703
  return { exitCode, output: `${stdout}${stderr}` };
1681
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
+ }),
1682
1720
  sleep: (ms) => Bun.sleep(ms)
1683
1721
  };
1684
1722
  function validate(request) {
@@ -1693,7 +1731,7 @@ function validate(request) {
1693
1731
  } else if (request.capabilities.public)
1694
1732
  throw new Error("unexpected public deployment capability");
1695
1733
  if (privateIntent?.mode === "cloudflare-warp") {
1696
- 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)) {
1697
1735
  throw new Error("private deployment connectivity is malformed");
1698
1736
  }
1699
1737
  } else if (request.capabilities.private)
@@ -1703,6 +1741,22 @@ async function applyDeploymentConnectivity(request, host = defaultHost) {
1703
1741
  validate(request);
1704
1742
  const id = idFor(request.key);
1705
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
+ }
1706
1760
  if (request.intent.public?.mode === "cloudflare-tunnel") {
1707
1761
  const capability = request.capabilities.public;
1708
1762
  const credentialName = `FZ_TUNNEL_${id.toUpperCase()}`;
@@ -1740,6 +1794,12 @@ WantedBy=multi-user.target
1740
1794
  const capability = request.capabilities.private;
1741
1795
  const credentialPath = "/etc/forgezero/creds/CF_WARP_CONNECTOR_TOKEN.cred";
1742
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");
1743
1803
  await host.seal("CF_WARP_CONNECTOR_TOKEN", credentialPath, capability.connectorToken);
1744
1804
  host.write(`/etc/systemd/system/${unit}`, `[Unit]
1745
1805
  Description=ForgeZero deployment Mesh/WARP connector
@@ -1774,6 +1834,95 @@ WantedBy=multi-user.target
1774
1834
  if (!evidence.private)
1775
1835
  throw new Error("WARP connector readiness failed");
1776
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
+ }
1777
1926
  return evidence;
1778
1927
  }
1779
1928
 
@@ -2498,7 +2647,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
2498
2647
  }
2499
2648
 
2500
2649
  // src/version.ts
2501
- var VERSION3 = "0.1.78";
2650
+ var VERSION3 = "0.1.80";
2502
2651
 
2503
2652
  // src/egress-policy.ts
2504
2653
  import { realpathSync as realpathSync3 } from "node:fs";
@@ -3758,7 +3907,14 @@ function validatePlatformSharedEnvironment(input) {
3758
3907
  throw new Error("Bootstrap email provider must be smtp or jetemail.");
3759
3908
  }
3760
3909
  boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
3761
- 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
+ }
3762
3918
  boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
3763
3919
  boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
3764
3920
  boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
@@ -3838,7 +3994,7 @@ function renderPlatformSharedEnvironment(input) {
3838
3994
  FZ_DB_MASTER: value.databaseMaster ?? "",
3839
3995
  FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
3840
3996
  FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
3841
- FZ_SEED_SYNC_MEMBERS: String(value.seedSyncMembers),
3997
+ FZ_SEED_SYNC_MEMBERS: value.seedSyncMembers.join(","),
3842
3998
  FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
3843
3999
  FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
3844
4000
  FZ_SHARED_DIR: value.sharedDirectory,
@@ -3907,7 +4063,12 @@ function platformApiCredentialSpecs(options) {
3907
4063
  ];
3908
4064
  }
3909
4065
  function renderPlatformApiUnits(input) {
3910
- 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
+ ]) {
3911
4072
  if (!path2.startsWith("/") || /[\r\n]/.test(path2))
3912
4073
  throw new Error("Runtime paths must be absolute and single-line.");
3913
4074
  }
@@ -3921,6 +4082,8 @@ function renderPlatformApiUnits(input) {
3921
4082
  const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
3922
4083
  `);
3923
4084
  const capacityEnvironment = input.capacityEnvironmentFile ? `EnvironmentFile=-${input.capacityEnvironmentFile}
4085
+ ` : "";
4086
+ const topologyEnvironment = input.topologyEnvironmentFile ? `EnvironmentFile=-${input.topologyEnvironmentFile}
3924
4087
  ` : "";
3925
4088
  const template = `[Unit]
3926
4089
  Description=ForgeZero (%i slot)
@@ -3934,7 +4097,7 @@ WorkingDirectory=${input.slotsDirectory}/%i
3934
4097
  Environment=NODE_ENV=production
3935
4098
  Environment=FZ_SLOT=%i
3936
4099
  EnvironmentFile=${input.sharedEnvironmentFile}
3937
- ${capacityEnvironment}${credentials}
4100
+ ${capacityEnvironment}${topologyEnvironment}${credentials}
3938
4101
  ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
3939
4102
  Restart=always
3940
4103
  RestartSec=2
@@ -165,16 +165,38 @@ var runSoftwareCommand = async (argv, env = {}) => {
165
165
  return { exitCode, output: `${stdout}${stderr}` };
166
166
  };
167
167
  var run = runSoftwareCommand;
168
+ var softwareDownloadCommand = (url, destination, maximumBytes) => {
169
+ const source = new URL(url);
170
+ if (source.protocol !== "https:" || source.username || source.password)
171
+ throw new Error("software download must use credential-free HTTPS");
172
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1)
173
+ throw new Error("software download size bound is invalid");
174
+ return [
175
+ "/usr/bin/curl",
176
+ "--fail",
177
+ "--location",
178
+ "--silent",
179
+ "--show-error",
180
+ "--proto",
181
+ "=https",
182
+ "--tlsv1.2",
183
+ "--max-time",
184
+ "1800",
185
+ "--max-filesize",
186
+ String(maximumBytes),
187
+ "--output",
188
+ destination,
189
+ source.href
190
+ ];
191
+ };
168
192
  var download = async (url, destination, sha256, maximumBytes = 512 * 1024 * 1024) => {
169
- const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(30 * 60000) });
170
- if (!response.ok)
171
- throw new Error(`download failed with HTTP ${response.status}`);
172
- const declared = Number(response.headers.get("content-length") ?? 0);
173
- if (declared > maximumBytes)
174
- throw new Error("download exceeds reviewed size bound");
175
193
  if (existsSync(destination))
176
194
  throw new Error("download destination already exists");
177
- await Bun.write(destination, response);
195
+ const received = await run(softwareDownloadCommand(url, destination, maximumBytes));
196
+ if (received.exitCode !== 0) {
197
+ rmSync(destination, { force: true });
198
+ throw new Error(`software download failed (${received.exitCode}): ${received.output.trim()}`);
199
+ }
178
200
  chmodSync(destination, 384);
179
201
  if (statSync(destination).size > maximumBytes) {
180
202
  rmSync(destination, { force: true });
package/dist/provision.js CHANGED
@@ -843,16 +843,38 @@ var runSoftwareCommand = async (argv, env = {}) => {
843
843
  return { exitCode, output: `${stdout}${stderr}` };
844
844
  };
845
845
  var run = runSoftwareCommand;
846
+ var softwareDownloadCommand = (url, destination, maximumBytes) => {
847
+ const source = new URL(url);
848
+ if (source.protocol !== "https:" || source.username || source.password)
849
+ throw new Error("software download must use credential-free HTTPS");
850
+ if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1)
851
+ throw new Error("software download size bound is invalid");
852
+ return [
853
+ "/usr/bin/curl",
854
+ "--fail",
855
+ "--location",
856
+ "--silent",
857
+ "--show-error",
858
+ "--proto",
859
+ "=https",
860
+ "--tlsv1.2",
861
+ "--max-time",
862
+ "1800",
863
+ "--max-filesize",
864
+ String(maximumBytes),
865
+ "--output",
866
+ destination,
867
+ source.href
868
+ ];
869
+ };
846
870
  var download = async (url, destination, sha256, maximumBytes = 512 * 1024 * 1024) => {
847
- const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(30 * 60000) });
848
- if (!response.ok)
849
- throw new Error(`download failed with HTTP ${response.status}`);
850
- const declared = Number(response.headers.get("content-length") ?? 0);
851
- if (declared > maximumBytes)
852
- throw new Error("download exceeds reviewed size bound");
853
871
  if (existsSync3(destination))
854
872
  throw new Error("download destination already exists");
855
- await Bun.write(destination, response);
873
+ const received = await run(softwareDownloadCommand(url, destination, maximumBytes));
874
+ if (received.exitCode !== 0) {
875
+ rmSync3(destination, { force: true });
876
+ throw new Error(`software download failed (${received.exitCode}): ${received.output.trim()}`);
877
+ }
856
878
  chmodSync3(destination, 384);
857
879
  if (statSync(destination).size > maximumBytes) {
858
880
  rmSync3(destination, { force: true });
@@ -1627,6 +1649,7 @@ function phasePipeline(definition, phase, profile, executeRelease = false) {
1627
1649
  // src/deployment-connectivity.ts
1628
1650
  import { createHash as createHash3 } from "node:crypto";
1629
1651
  import { mkdirSync as mkdirSync4, renameSync as renameSync4, writeFileSync as writeFileSync4 } from "node:fs";
1652
+ import { createConnection } from "node:net";
1630
1653
  import { dirname as dirname4 } from "node:path";
1631
1654
 
1632
1655
  // src/process-input.ts
@@ -1679,6 +1702,21 @@ var defaultHost = {
1679
1702
  const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
1680
1703
  return { exitCode, output: `${stdout}${stderr}` };
1681
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
+ }),
1682
1720
  sleep: (ms) => Bun.sleep(ms)
1683
1721
  };
1684
1722
  function validate(request) {
@@ -1693,7 +1731,7 @@ function validate(request) {
1693
1731
  } else if (request.capabilities.public)
1694
1732
  throw new Error("unexpected public deployment capability");
1695
1733
  if (privateIntent?.mode === "cloudflare-warp") {
1696
- 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)) {
1697
1735
  throw new Error("private deployment connectivity is malformed");
1698
1736
  }
1699
1737
  } else if (request.capabilities.private)
@@ -1703,6 +1741,22 @@ async function applyDeploymentConnectivity(request, host = defaultHost) {
1703
1741
  validate(request);
1704
1742
  const id = idFor(request.key);
1705
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
+ }
1706
1760
  if (request.intent.public?.mode === "cloudflare-tunnel") {
1707
1761
  const capability = request.capabilities.public;
1708
1762
  const credentialName = `FZ_TUNNEL_${id.toUpperCase()}`;
@@ -1740,6 +1794,12 @@ WantedBy=multi-user.target
1740
1794
  const capability = request.capabilities.private;
1741
1795
  const credentialPath = "/etc/forgezero/creds/CF_WARP_CONNECTOR_TOKEN.cred";
1742
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");
1743
1803
  await host.seal("CF_WARP_CONNECTOR_TOKEN", credentialPath, capability.connectorToken);
1744
1804
  host.write(`/etc/systemd/system/${unit}`, `[Unit]
1745
1805
  Description=ForgeZero deployment Mesh/WARP connector
@@ -1774,6 +1834,95 @@ WantedBy=multi-user.target
1774
1834
  if (!evidence.private)
1775
1835
  throw new Error("WARP connector readiness failed");
1776
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
+ }
1777
1926
  return evidence;
1778
1927
  }
1779
1928
 
@@ -2498,7 +2647,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
2498
2647
  }
2499
2648
 
2500
2649
  // src/version.ts
2501
- var VERSION3 = "0.1.78";
2650
+ var VERSION3 = "0.1.80";
2502
2651
 
2503
2652
  // src/egress-policy.ts
2504
2653
  import { realpathSync as realpathSync3 } from "node:fs";