@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.
- package/README.md +57 -6
- package/dist/agent-heartbeat.js +1 -1
- package/dist/bootstrap.js +21 -6
- package/dist/community-rehearsal-host.js +29 -7
- package/dist/definition.js +29 -7
- package/dist/deploy-compiler.js +50 -2
- package/dist/deploy-file.js +29 -7
- package/dist/deploy-plan-runner.js +50 -2
- package/dist/deploy-plan.js +50 -2
- package/dist/deploy.d.ts +37 -1
- package/dist/deploy.js +1 -0
- package/dist/deployment-connectivity.d.ts +26 -0
- package/dist/deployment-connectivity.js +128 -1
- package/dist/deployment-targets.js +15 -1
- package/dist/deployment-topology.d.ts +46 -0
- package/dist/deployment-topology.js +113 -0
- package/dist/deployment.d.ts +40 -0
- package/dist/fz-agent.js +397 -21
- package/dist/fz.js +75 -12
- package/dist/index.d.ts +1 -0
- package/dist/metal-bootstrap.js +1 -1
- package/dist/metal-helper-socket.js +29 -7
- package/dist/metal-provision.js +29 -7
- package/dist/operator-bootstrap.js +21 -6
- package/dist/platform-bootstrap-runtime.d.ts +4 -1
- package/dist/platform-bootstrap-runtime.js +47 -11
- package/dist/platform-fleet-verification.js +176 -13
- package/dist/platform-genesis.js +29 -7
- package/dist/provision.js +158 -9
- package/dist/software-helper.js +157 -8
- package/dist/software.d.ts +1 -0
- package/dist/software.js +30 -7
- package/dist/ubuntu.js +29 -7
- package/dist/version.d.ts +1 -1
- package/package.json +5 -1
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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 [
|
|
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
|
package/dist/platform-genesis.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
2650
|
+
var VERSION3 = "0.1.80";
|
|
2502
2651
|
|
|
2503
2652
|
// src/egress-policy.ts
|
|
2504
2653
|
import { realpathSync as realpathSync3 } from "node:fs";
|