@forgezero/agent 0.1.103 → 0.1.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1869,7 +1869,7 @@ function phasePipeline(definition, phase, profile, executeRelease = false) {
1869
1869
  // src/deployment-connectivity.ts
1870
1870
  import { createHash as createHash3 } from "node:crypto";
1871
1871
  import { mkdirSync as mkdirSync4, renameSync as renameSync5, writeFileSync as writeFileSync4 } from "node:fs";
1872
- import { createConnection } from "node:net";
1872
+ import { createConnection, isIP } from "node:net";
1873
1873
  import { dirname as dirname5 } from "node:path";
1874
1874
 
1875
1875
  // src/process-input.ts
@@ -1956,21 +1956,99 @@ function validate(request) {
1956
1956
  }
1957
1957
  } else if (request.capabilities.private)
1958
1958
  throw new Error("unexpected private deployment capability");
1959
+ if (request.firewallPolicy) {
1960
+ const policy = request.firewallPolicy;
1961
+ if (!/^sha256:[a-f0-9]{64}$/.test(policy.generation) || !Array.isArray(policy.rules) || policy.rules.length > 256) {
1962
+ throw new Error("deployment firewall policy is malformed");
1963
+ }
1964
+ let expanded = 0;
1965
+ for (const rule of policy.rules) {
1966
+ expanded += rule.sourceAddresses.length;
1967
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(rule.ruleKey) || !["allow", "deny"].includes(rule.action) || !["tcp", "udp"].includes(rule.protocol) || rule.sourceAddresses.length < 1 || rule.sourceAddresses.length > 1024 || new Set(rule.sourceAddresses).size !== rule.sourceAddresses.length || rule.sourceAddresses.some((address) => isIP(address) !== 4) || !Number.isSafeInteger(rule.portFrom) || rule.portFrom < 1 || rule.portFrom > 65535 || !Number.isSafeInteger(rule.portTo) || rule.portTo < rule.portFrom || rule.portTo > 65535 || !Number.isSafeInteger(rule.priority) || rule.priority < 0 || rule.priority > 1e6) {
1968
+ throw new Error("deployment firewall policy is malformed");
1969
+ }
1970
+ }
1971
+ if (expanded > 2048)
1972
+ throw new Error("deployment firewall policy expands beyond its rule limit");
1973
+ }
1974
+ }
1975
+ async function replaceTaggedUfwRules(host, comment) {
1976
+ const status = await checked2(host, ["/usr/sbin/ufw", "status", "numbered"], "firewall inventory");
1977
+ const numbers = status.split(`
1978
+ `).flatMap((line) => {
1979
+ if (!line.includes(comment))
1980
+ return [];
1981
+ const match = line.match(/^\s*\[\s*(\d{1,6})\]/);
1982
+ return match ? [Number(match[1])] : [];
1983
+ }).filter((value) => Number.isSafeInteger(value) && value > 0).sort((left, right) => right - left);
1984
+ for (const number of numbers)
1985
+ await checked2(host, ["/usr/sbin/ufw", "--force", "delete", String(number)], `stale firewall rule ${number}`);
1959
1986
  }
1960
1987
  async function applyDeploymentConnectivity(request, host = defaultHost) {
1961
1988
  validate(request);
1962
1989
  const id = idFor(request.key);
1963
1990
  const evidence = { key: request.key };
1991
+ if (request.firewallPolicy) {
1992
+ const policy = request.firewallPolicy;
1993
+ const comment = `fz-policy-${id}`;
1994
+ await replaceTaggedUfwRules(host, comment);
1995
+ const ordered = [...policy.rules].sort((left, right) => left.priority - right.priority || (left.action === right.action ? left.ruleKey.localeCompare(right.ruleKey) : left.action === "deny" ? -1 : 1));
1996
+ const commands = [];
1997
+ for (const rule of ordered)
1998
+ for (const source of rule.sourceAddresses)
1999
+ commands.push([
2000
+ "/usr/sbin/ufw",
2001
+ "insert",
2002
+ "1",
2003
+ rule.action,
2004
+ "from",
2005
+ source,
2006
+ "to",
2007
+ "any",
2008
+ "port",
2009
+ rule.portFrom === rule.portTo ? String(rule.portFrom) : `${rule.portFrom}:${rule.portTo}`,
2010
+ "proto",
2011
+ rule.protocol,
2012
+ "comment",
2013
+ comment
2014
+ ]);
2015
+ const ranges = new Map;
2016
+ for (const rule of ordered)
2017
+ ranges.set(`${rule.protocol}:${rule.portFrom}:${rule.portTo}`, rule);
2018
+ for (const range of ranges.values())
2019
+ commands.push([
2020
+ "/usr/sbin/ufw",
2021
+ "insert",
2022
+ "1",
2023
+ "deny",
2024
+ "to",
2025
+ "any",
2026
+ "port",
2027
+ range.portFrom === range.portTo ? String(range.portFrom) : `${range.portFrom}:${range.portTo}`,
2028
+ "proto",
2029
+ range.protocol,
2030
+ "comment",
2031
+ comment
2032
+ ]);
2033
+ for (const command2 of commands.toReversed())
2034
+ await checked2(host, command2, "deployment firewall rule");
2035
+ evidence.firewall = { generation: policy.generation, rules: commands.length, active: true };
2036
+ }
1964
2037
  const topology = request.topology;
1965
2038
  if (topology) {
1966
- const values = [topology.localRelayAddress, ...topology.localPeerAddresses, ...topology.remoteRelayAddresses];
2039
+ const values = [
2040
+ topology.localRelayAddress,
2041
+ ...topology.localPeerAddresses,
2042
+ ...topology.remoteRelayAddresses,
2043
+ ...topology.remoteMemberAddresses
2044
+ ];
1967
2045
  const identities = [
1968
2046
  topology.nodeIdentity,
1969
2047
  ...topology.localPeerIdentities,
1970
2048
  ...topology.remoteRelayIdentities,
1971
2049
  ...topology.memberIdentities
1972
2050
  ];
1973
- 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") {
2051
+ if (!/^[A-Za-z0-9.-]{1,253}$/.test(topology.site) || !/^(?:\d{1,3}\.){3}0\/24$/.test(topology.siteCidr) || values.some((value) => isIP(value) !== 4) || identities.some((value) => !/^[A-Za-z0-9.-]{1,253}$/.test(value)) || topology.localPeerIdentities.length !== topology.localPeerAddresses.length || topology.remoteRelayIdentities.length !== topology.remoteRelayAddresses.length || topology.remoteMemberAddresses.length > 1024 || new Set(topology.remoteMemberAddresses).size !== topology.remoteMemberAddresses.length || topology.memberIdentities.length < 1 || topology.memberIdentities.length > 1024 || new Set(topology.memberIdentities).size !== topology.memberIdentities.length || !topology.memberIdentities.includes(topology.nodeIdentity) || !/^sha256:[a-f0-9]{64}$/.test(topology.generation) || (topology.role === "member" ? topology.remoteRelayAddresses.length !== 0 : topology.remoteSiteCidrs.length !== topology.remoteRelayAddresses.length) || topology.remoteSiteCidrs.some((value) => !/^(?:\d{1,3}\.){3}0\/24$/.test(value)) || new Set(topology.remoteSiteCidrs).size !== topology.remoteSiteCidrs.length || !Number.isSafeInteger(topology.healthPort) || topology.healthPort < 1 || topology.healthPort > 65535 || topology.routedTcpPorts.length < 1 || topology.routedTcpPorts.length > 64 || new Set(topology.routedTcpPorts).size !== topology.routedTcpPorts.length || !topology.routedTcpPorts.includes(topology.healthPort) || topology.routedTcpPorts.some((port) => !Number.isSafeInteger(port) || port < 1 || port > 65535) || topology.role === "member" && topology.transport !== "private-lan") {
1974
2052
  throw new Error("deployment topology is malformed");
1975
2053
  }
1976
2054
  if (request.intent.private?.mode === "cloudflare-warp" !== (topology.transport === "cloudflare-warp")) {
@@ -2097,28 +2175,46 @@ ${forwarding}${noOp}${starts}${starts ? `
2097
2175
  [Install]
2098
2176
  WantedBy=multi-user.target
2099
2177
  `, 420);
2100
- for (const source of [...new Set([topology.siteCidr, ...topology.remoteSiteCidrs])]) {
2178
+ const firewallComment = `fz-topology-${id}`;
2179
+ await replaceTaggedUfwRules(host, firewallComment);
2180
+ for (const source of [...new Set([...topology.localPeerAddresses, ...topology.remoteMemberAddresses])]) {
2101
2181
  for (const port of topology.routedTcpPorts) {
2102
- await checked2(host, ["/usr/sbin/ufw", "allow", "from", source, "to", "any", "port", String(port), "proto", "tcp"], `private service ${source}:${port}`);
2182
+ await checked2(host, [
2183
+ "/usr/sbin/ufw",
2184
+ "allow",
2185
+ "from",
2186
+ source,
2187
+ "to",
2188
+ "any",
2189
+ "port",
2190
+ String(port),
2191
+ "proto",
2192
+ "tcp",
2193
+ "comment",
2194
+ firewallComment
2195
+ ], `private service ${source}:${port}`);
2103
2196
  }
2104
2197
  }
2105
2198
  if (topology.role !== "member")
2106
- for (const remoteCidr of topology.remoteSiteCidrs) {
2107
- for (const port of topology.routedTcpPorts) {
2108
- await checked2(host, [
2109
- "/usr/sbin/ufw",
2110
- "route",
2111
- "allow",
2112
- "proto",
2113
- "tcp",
2114
- "from",
2115
- topology.siteCidr,
2116
- "to",
2117
- remoteCidr,
2118
- "port",
2119
- String(port)
2120
- ], `private routed service ${topology.siteCidr}->${remoteCidr}:${port}`);
2121
- }
2199
+ for (const remoteAddress of topology.remoteMemberAddresses) {
2200
+ for (const source of topology.localPeerAddresses)
2201
+ for (const port of topology.routedTcpPorts) {
2202
+ await checked2(host, [
2203
+ "/usr/sbin/ufw",
2204
+ "route",
2205
+ "allow",
2206
+ "proto",
2207
+ "tcp",
2208
+ "from",
2209
+ source,
2210
+ "to",
2211
+ remoteAddress,
2212
+ "port",
2213
+ String(port),
2214
+ "comment",
2215
+ firewallComment
2216
+ ], `private routed service ${source}->${remoteAddress}:${port}`);
2217
+ }
2122
2218
  }
2123
2219
  await checked2(host, ["/usr/bin/systemctl", "daemon-reload"], "topology daemon reload");
2124
2220
  await checked2(host, ["/usr/bin/systemctl", "enable", "--now", routeUnit], "topology routes");
@@ -2908,7 +3004,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
2908
3004
  }
2909
3005
 
2910
3006
  // src/version.ts
2911
- var VERSION3 = "0.1.103";
3007
+ var VERSION3 = "0.1.107";
2912
3008
 
2913
3009
  // src/egress-policy.ts
2914
3010
  import { realpathSync as realpathSync3 } from "node:fs";
@@ -2971,7 +3067,7 @@ function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
2971
3067
  }
2972
3068
 
2973
3069
  // src/provision.ts
2974
- import { isIP } from "node:net";
3070
+ import { isIP as isIP2 } from "node:net";
2975
3071
  function atLeast(version, floor) {
2976
3072
  const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
2977
3073
  const got = parse(version);
@@ -3451,7 +3547,7 @@ function agentUnit(options) {
3451
3547
  throw new Error("compute telemetry endpoint must be an absolute collector URL");
3452
3548
  }
3453
3549
  const localCollector = endpoint.protocol === "http:" && endpoint.hostname === "127.0.0.1" && endpoint.port === "4318" && endpoint.pathname === "/";
3454
- const publicCollector = endpoint.protocol === "https:" && !endpoint.username && !endpoint.password && !endpoint.search && !endpoint.hash && isIP(endpoint.hostname) === 0 && endpoint.hostname.includes(".") && endpoint.hostname !== "localhost" && !endpoint.hostname.endsWith(".local");
3550
+ const publicCollector = endpoint.protocol === "https:" && !endpoint.username && !endpoint.password && !endpoint.search && !endpoint.hash && isIP2(endpoint.hostname) === 0 && endpoint.hostname.includes(".") && endpoint.hostname !== "localhost" && !endpoint.hostname.endsWith(".local");
3455
3551
  if (!localCollector && !publicCollector || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
3456
3552
  throw new Error("compute telemetry endpoint must be the supervised loopback collector or credential-free public HTTPS");
3457
3553
  }
@@ -4399,6 +4495,7 @@ function platformApiCredentialSpecs(options) {
4399
4495
  ];
4400
4496
  return [
4401
4497
  { name: "arangodb-jwt", encryptedPath: "/etc/forgezero/creds/arangodb-jwt.cred", required: true },
4498
+ { name: "arangodb-root-password", encryptedPath: "/etc/forgezero/creds/arangodb-root-password.cred", required: true },
4402
4499
  { name: "seed-sync-root", encryptedPath: "/etc/forgezero/creds/seed-sync-root.cred", required: true },
4403
4500
  ...optional.filter(([, present]) => present).map(([name]) => ({
4404
4501
  name,
@@ -5078,10 +5175,10 @@ import { constants } from "node:fs";
5078
5175
  import { createHmac, randomUUID as randomUUID4 } from "node:crypto";
5079
5176
  import { chmod, lstat, mkdir, open, readdir, rename, rmdir, stat, unlink } from "node:fs/promises";
5080
5177
  import { dirname as dirname10, join as join6, resolve as resolve5 } from "node:path";
5081
- import { isIP as isIP3 } from "node:net";
5178
+ import { isIP as isIP4 } from "node:net";
5082
5179
 
5083
5180
  // src/cloudflare-edge.ts
5084
- import { isIP as isIP2 } from "node:net";
5181
+ import { isIP as isIP3 } from "node:net";
5085
5182
 
5086
5183
  // src/otel-collector.ts
5087
5184
  var FORGEZERO_OTEL_COLLECTOR_UNIT = "forgezero-otel-collector.service";
@@ -5146,6 +5243,7 @@ var BOOTSTRAP_STATE_PATH = "/var/lib/forgezero/bootstrap.json";
5146
5243
  var STATE_PATH = BOOTSTRAP_STATE_PATH;
5147
5244
  var CREDS = "/etc/forgezero/creds";
5148
5245
  var JWT_CREDENTIAL = `${CREDS}/arangodb-jwt.cred`;
5246
+ var ARANGO_ROOT_CREDENTIAL = `${CREDS}/arangodb-root-password.cred`;
5149
5247
  var ENROL_CREDENTIAL = `${CREDS}/enrol-token.cred`;
5150
5248
  var TUNNEL_CREDENTIAL = `${CREDS}/CF_TUNNEL_CONNECTOR_TOKEN.cred`;
5151
5249
  var CF_API_CREDENTIAL = `${CREDS}/CF_API_TOKEN.cred`;
@@ -5307,6 +5405,11 @@ async function bootstrapStatus(host = localBootstrapHost()) {
5307
5405
  if (nginx3.exitCode !== 0)
5308
5406
  problems.push("nginx configuration is invalid");
5309
5407
  if (state.databaseRole !== "none") {
5408
+ for (const credential of [JWT_CREDENTIAL, ARANGO_ROOT_CREDENTIAL]) {
5409
+ services[credential] = host.exists(credential);
5410
+ if (!services[credential])
5411
+ problems.push(`${credential} is missing`);
5412
+ }
5310
5413
  const unitPath = "/etc/systemd/system/forgezero-db.service";
5311
5414
  const expectsNoAgency = state.databaseAgency === "none";
5312
5415
  const unitHasNoAgency = host.exists(unitPath) && host.read(unitPath).includes("--cluster.start-agent=false");
@@ -0,0 +1,19 @@
1
+ import type { ForgeZeroLaunchOwnerInput } from './platform-launch-profile';
2
+ export interface PlatformLaunchEnvironmentInput {
3
+ owner: ForgeZeroLaunchOwnerInput;
4
+ emailSecret: string;
5
+ cloudflareTunnelToken: string;
6
+ cloudflareApiToken: string;
7
+ githubClientSecret?: string;
8
+ githubPrivateKeyBase64?: string;
9
+ githubWebhookSecret?: string;
10
+ }
11
+ /**
12
+ * Read the development-only attended-launch adapter.
13
+ *
14
+ * The file may contain the API's ordinary non-secret environment as well; this
15
+ * reader selects only the fixed bootstrap coordinates below. Secret values are
16
+ * returned to the caller for immediate systemd-creds sealing and are never
17
+ * copied into the API environment rendered on a target.
18
+ */
19
+ export declare function readPlatformLaunchEnvironment(path: string): PlatformLaunchEnvironmentInput;
package/dist/provision.js CHANGED
@@ -1869,7 +1869,7 @@ function phasePipeline(definition, phase, profile, executeRelease = false) {
1869
1869
  // src/deployment-connectivity.ts
1870
1870
  import { createHash as createHash3 } from "node:crypto";
1871
1871
  import { mkdirSync as mkdirSync4, renameSync as renameSync5, writeFileSync as writeFileSync4 } from "node:fs";
1872
- import { createConnection } from "node:net";
1872
+ import { createConnection, isIP } from "node:net";
1873
1873
  import { dirname as dirname5 } from "node:path";
1874
1874
 
1875
1875
  // src/process-input.ts
@@ -1956,21 +1956,99 @@ function validate(request) {
1956
1956
  }
1957
1957
  } else if (request.capabilities.private)
1958
1958
  throw new Error("unexpected private deployment capability");
1959
+ if (request.firewallPolicy) {
1960
+ const policy = request.firewallPolicy;
1961
+ if (!/^sha256:[a-f0-9]{64}$/.test(policy.generation) || !Array.isArray(policy.rules) || policy.rules.length > 256) {
1962
+ throw new Error("deployment firewall policy is malformed");
1963
+ }
1964
+ let expanded = 0;
1965
+ for (const rule of policy.rules) {
1966
+ expanded += rule.sourceAddresses.length;
1967
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(rule.ruleKey) || !["allow", "deny"].includes(rule.action) || !["tcp", "udp"].includes(rule.protocol) || rule.sourceAddresses.length < 1 || rule.sourceAddresses.length > 1024 || new Set(rule.sourceAddresses).size !== rule.sourceAddresses.length || rule.sourceAddresses.some((address) => isIP(address) !== 4) || !Number.isSafeInteger(rule.portFrom) || rule.portFrom < 1 || rule.portFrom > 65535 || !Number.isSafeInteger(rule.portTo) || rule.portTo < rule.portFrom || rule.portTo > 65535 || !Number.isSafeInteger(rule.priority) || rule.priority < 0 || rule.priority > 1e6) {
1968
+ throw new Error("deployment firewall policy is malformed");
1969
+ }
1970
+ }
1971
+ if (expanded > 2048)
1972
+ throw new Error("deployment firewall policy expands beyond its rule limit");
1973
+ }
1974
+ }
1975
+ async function replaceTaggedUfwRules(host, comment) {
1976
+ const status = await checked2(host, ["/usr/sbin/ufw", "status", "numbered"], "firewall inventory");
1977
+ const numbers = status.split(`
1978
+ `).flatMap((line) => {
1979
+ if (!line.includes(comment))
1980
+ return [];
1981
+ const match = line.match(/^\s*\[\s*(\d{1,6})\]/);
1982
+ return match ? [Number(match[1])] : [];
1983
+ }).filter((value) => Number.isSafeInteger(value) && value > 0).sort((left, right) => right - left);
1984
+ for (const number of numbers)
1985
+ await checked2(host, ["/usr/sbin/ufw", "--force", "delete", String(number)], `stale firewall rule ${number}`);
1959
1986
  }
1960
1987
  async function applyDeploymentConnectivity(request, host = defaultHost) {
1961
1988
  validate(request);
1962
1989
  const id = idFor(request.key);
1963
1990
  const evidence = { key: request.key };
1991
+ if (request.firewallPolicy) {
1992
+ const policy = request.firewallPolicy;
1993
+ const comment = `fz-policy-${id}`;
1994
+ await replaceTaggedUfwRules(host, comment);
1995
+ const ordered = [...policy.rules].sort((left, right) => left.priority - right.priority || (left.action === right.action ? left.ruleKey.localeCompare(right.ruleKey) : left.action === "deny" ? -1 : 1));
1996
+ const commands = [];
1997
+ for (const rule of ordered)
1998
+ for (const source of rule.sourceAddresses)
1999
+ commands.push([
2000
+ "/usr/sbin/ufw",
2001
+ "insert",
2002
+ "1",
2003
+ rule.action,
2004
+ "from",
2005
+ source,
2006
+ "to",
2007
+ "any",
2008
+ "port",
2009
+ rule.portFrom === rule.portTo ? String(rule.portFrom) : `${rule.portFrom}:${rule.portTo}`,
2010
+ "proto",
2011
+ rule.protocol,
2012
+ "comment",
2013
+ comment
2014
+ ]);
2015
+ const ranges = new Map;
2016
+ for (const rule of ordered)
2017
+ ranges.set(`${rule.protocol}:${rule.portFrom}:${rule.portTo}`, rule);
2018
+ for (const range of ranges.values())
2019
+ commands.push([
2020
+ "/usr/sbin/ufw",
2021
+ "insert",
2022
+ "1",
2023
+ "deny",
2024
+ "to",
2025
+ "any",
2026
+ "port",
2027
+ range.portFrom === range.portTo ? String(range.portFrom) : `${range.portFrom}:${range.portTo}`,
2028
+ "proto",
2029
+ range.protocol,
2030
+ "comment",
2031
+ comment
2032
+ ]);
2033
+ for (const command2 of commands.toReversed())
2034
+ await checked2(host, command2, "deployment firewall rule");
2035
+ evidence.firewall = { generation: policy.generation, rules: commands.length, active: true };
2036
+ }
1964
2037
  const topology = request.topology;
1965
2038
  if (topology) {
1966
- const values = [topology.localRelayAddress, ...topology.localPeerAddresses, ...topology.remoteRelayAddresses];
2039
+ const values = [
2040
+ topology.localRelayAddress,
2041
+ ...topology.localPeerAddresses,
2042
+ ...topology.remoteRelayAddresses,
2043
+ ...topology.remoteMemberAddresses
2044
+ ];
1967
2045
  const identities = [
1968
2046
  topology.nodeIdentity,
1969
2047
  ...topology.localPeerIdentities,
1970
2048
  ...topology.remoteRelayIdentities,
1971
2049
  ...topology.memberIdentities
1972
2050
  ];
1973
- 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") {
2051
+ if (!/^[A-Za-z0-9.-]{1,253}$/.test(topology.site) || !/^(?:\d{1,3}\.){3}0\/24$/.test(topology.siteCidr) || values.some((value) => isIP(value) !== 4) || identities.some((value) => !/^[A-Za-z0-9.-]{1,253}$/.test(value)) || topology.localPeerIdentities.length !== topology.localPeerAddresses.length || topology.remoteRelayIdentities.length !== topology.remoteRelayAddresses.length || topology.remoteMemberAddresses.length > 1024 || new Set(topology.remoteMemberAddresses).size !== topology.remoteMemberAddresses.length || topology.memberIdentities.length < 1 || topology.memberIdentities.length > 1024 || new Set(topology.memberIdentities).size !== topology.memberIdentities.length || !topology.memberIdentities.includes(topology.nodeIdentity) || !/^sha256:[a-f0-9]{64}$/.test(topology.generation) || (topology.role === "member" ? topology.remoteRelayAddresses.length !== 0 : topology.remoteSiteCidrs.length !== topology.remoteRelayAddresses.length) || topology.remoteSiteCidrs.some((value) => !/^(?:\d{1,3}\.){3}0\/24$/.test(value)) || new Set(topology.remoteSiteCidrs).size !== topology.remoteSiteCidrs.length || !Number.isSafeInteger(topology.healthPort) || topology.healthPort < 1 || topology.healthPort > 65535 || topology.routedTcpPorts.length < 1 || topology.routedTcpPorts.length > 64 || new Set(topology.routedTcpPorts).size !== topology.routedTcpPorts.length || !topology.routedTcpPorts.includes(topology.healthPort) || topology.routedTcpPorts.some((port) => !Number.isSafeInteger(port) || port < 1 || port > 65535) || topology.role === "member" && topology.transport !== "private-lan") {
1974
2052
  throw new Error("deployment topology is malformed");
1975
2053
  }
1976
2054
  if (request.intent.private?.mode === "cloudflare-warp" !== (topology.transport === "cloudflare-warp")) {
@@ -2097,28 +2175,46 @@ ${forwarding}${noOp}${starts}${starts ? `
2097
2175
  [Install]
2098
2176
  WantedBy=multi-user.target
2099
2177
  `, 420);
2100
- for (const source of [...new Set([topology.siteCidr, ...topology.remoteSiteCidrs])]) {
2178
+ const firewallComment = `fz-topology-${id}`;
2179
+ await replaceTaggedUfwRules(host, firewallComment);
2180
+ for (const source of [...new Set([...topology.localPeerAddresses, ...topology.remoteMemberAddresses])]) {
2101
2181
  for (const port of topology.routedTcpPorts) {
2102
- await checked2(host, ["/usr/sbin/ufw", "allow", "from", source, "to", "any", "port", String(port), "proto", "tcp"], `private service ${source}:${port}`);
2182
+ await checked2(host, [
2183
+ "/usr/sbin/ufw",
2184
+ "allow",
2185
+ "from",
2186
+ source,
2187
+ "to",
2188
+ "any",
2189
+ "port",
2190
+ String(port),
2191
+ "proto",
2192
+ "tcp",
2193
+ "comment",
2194
+ firewallComment
2195
+ ], `private service ${source}:${port}`);
2103
2196
  }
2104
2197
  }
2105
2198
  if (topology.role !== "member")
2106
- for (const remoteCidr of topology.remoteSiteCidrs) {
2107
- for (const port of topology.routedTcpPorts) {
2108
- await checked2(host, [
2109
- "/usr/sbin/ufw",
2110
- "route",
2111
- "allow",
2112
- "proto",
2113
- "tcp",
2114
- "from",
2115
- topology.siteCidr,
2116
- "to",
2117
- remoteCidr,
2118
- "port",
2119
- String(port)
2120
- ], `private routed service ${topology.siteCidr}->${remoteCidr}:${port}`);
2121
- }
2199
+ for (const remoteAddress of topology.remoteMemberAddresses) {
2200
+ for (const source of topology.localPeerAddresses)
2201
+ for (const port of topology.routedTcpPorts) {
2202
+ await checked2(host, [
2203
+ "/usr/sbin/ufw",
2204
+ "route",
2205
+ "allow",
2206
+ "proto",
2207
+ "tcp",
2208
+ "from",
2209
+ source,
2210
+ "to",
2211
+ remoteAddress,
2212
+ "port",
2213
+ String(port),
2214
+ "comment",
2215
+ firewallComment
2216
+ ], `private routed service ${source}->${remoteAddress}:${port}`);
2217
+ }
2122
2218
  }
2123
2219
  await checked2(host, ["/usr/bin/systemctl", "daemon-reload"], "topology daemon reload");
2124
2220
  await checked2(host, ["/usr/bin/systemctl", "enable", "--now", routeUnit], "topology routes");
@@ -2908,7 +3004,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
2908
3004
  }
2909
3005
 
2910
3006
  // src/version.ts
2911
- var VERSION3 = "0.1.103";
3007
+ var VERSION3 = "0.1.107";
2912
3008
 
2913
3009
  // src/egress-policy.ts
2914
3010
  import { realpathSync as realpathSync3 } from "node:fs";
@@ -2971,7 +3067,7 @@ function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
2971
3067
  }
2972
3068
 
2973
3069
  // src/provision.ts
2974
- import { isIP } from "node:net";
3070
+ import { isIP as isIP2 } from "node:net";
2975
3071
  function atLeast(version, floor) {
2976
3072
  const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
2977
3073
  const got = parse(version);
@@ -3451,7 +3547,7 @@ function agentUnit(options) {
3451
3547
  throw new Error("compute telemetry endpoint must be an absolute collector URL");
3452
3548
  }
3453
3549
  const localCollector = endpoint.protocol === "http:" && endpoint.hostname === "127.0.0.1" && endpoint.port === "4318" && endpoint.pathname === "/";
3454
- const publicCollector = endpoint.protocol === "https:" && !endpoint.username && !endpoint.password && !endpoint.search && !endpoint.hash && isIP(endpoint.hostname) === 0 && endpoint.hostname.includes(".") && endpoint.hostname !== "localhost" && !endpoint.hostname.endsWith(".local");
3550
+ const publicCollector = endpoint.protocol === "https:" && !endpoint.username && !endpoint.password && !endpoint.search && !endpoint.hash && isIP2(endpoint.hostname) === 0 && endpoint.hostname.includes(".") && endpoint.hostname !== "localhost" && !endpoint.hostname.endsWith(".local");
3455
3551
  if (!localCollector && !publicCollector || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
3456
3552
  throw new Error("compute telemetry endpoint must be the supervised loopback collector or credential-free public HTTPS");
3457
3553
  }
@@ -1026,7 +1026,7 @@ function phasePipeline(definition, phase, profile, executeRelease = false) {
1026
1026
  // src/deployment-connectivity.ts
1027
1027
  import { createHash as createHash2 } from "node:crypto";
1028
1028
  import { mkdirSync as mkdirSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
1029
- import { createConnection } from "node:net";
1029
+ import { createConnection, isIP } from "node:net";
1030
1030
  import { dirname as dirname2 } from "node:path";
1031
1031
 
1032
1032
  // src/process-input.ts
@@ -1113,21 +1113,99 @@ function validate(request) {
1113
1113
  }
1114
1114
  } else if (request.capabilities.private)
1115
1115
  throw new Error("unexpected private deployment capability");
1116
+ if (request.firewallPolicy) {
1117
+ const policy = request.firewallPolicy;
1118
+ if (!/^sha256:[a-f0-9]{64}$/.test(policy.generation) || !Array.isArray(policy.rules) || policy.rules.length > 256) {
1119
+ throw new Error("deployment firewall policy is malformed");
1120
+ }
1121
+ let expanded = 0;
1122
+ for (const rule of policy.rules) {
1123
+ expanded += rule.sourceAddresses.length;
1124
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(rule.ruleKey) || !["allow", "deny"].includes(rule.action) || !["tcp", "udp"].includes(rule.protocol) || rule.sourceAddresses.length < 1 || rule.sourceAddresses.length > 1024 || new Set(rule.sourceAddresses).size !== rule.sourceAddresses.length || rule.sourceAddresses.some((address) => isIP(address) !== 4) || !Number.isSafeInteger(rule.portFrom) || rule.portFrom < 1 || rule.portFrom > 65535 || !Number.isSafeInteger(rule.portTo) || rule.portTo < rule.portFrom || rule.portTo > 65535 || !Number.isSafeInteger(rule.priority) || rule.priority < 0 || rule.priority > 1e6) {
1125
+ throw new Error("deployment firewall policy is malformed");
1126
+ }
1127
+ }
1128
+ if (expanded > 2048)
1129
+ throw new Error("deployment firewall policy expands beyond its rule limit");
1130
+ }
1131
+ }
1132
+ async function replaceTaggedUfwRules(host, comment) {
1133
+ const status = await checked(host, ["/usr/sbin/ufw", "status", "numbered"], "firewall inventory");
1134
+ const numbers = status.split(`
1135
+ `).flatMap((line) => {
1136
+ if (!line.includes(comment))
1137
+ return [];
1138
+ const match = line.match(/^\s*\[\s*(\d{1,6})\]/);
1139
+ return match ? [Number(match[1])] : [];
1140
+ }).filter((value) => Number.isSafeInteger(value) && value > 0).sort((left, right) => right - left);
1141
+ for (const number of numbers)
1142
+ await checked(host, ["/usr/sbin/ufw", "--force", "delete", String(number)], `stale firewall rule ${number}`);
1116
1143
  }
1117
1144
  async function applyDeploymentConnectivity(request, host = defaultHost) {
1118
1145
  validate(request);
1119
1146
  const id = idFor(request.key);
1120
1147
  const evidence = { key: request.key };
1148
+ if (request.firewallPolicy) {
1149
+ const policy = request.firewallPolicy;
1150
+ const comment = `fz-policy-${id}`;
1151
+ await replaceTaggedUfwRules(host, comment);
1152
+ const ordered = [...policy.rules].sort((left, right) => left.priority - right.priority || (left.action === right.action ? left.ruleKey.localeCompare(right.ruleKey) : left.action === "deny" ? -1 : 1));
1153
+ const commands = [];
1154
+ for (const rule of ordered)
1155
+ for (const source of rule.sourceAddresses)
1156
+ commands.push([
1157
+ "/usr/sbin/ufw",
1158
+ "insert",
1159
+ "1",
1160
+ rule.action,
1161
+ "from",
1162
+ source,
1163
+ "to",
1164
+ "any",
1165
+ "port",
1166
+ rule.portFrom === rule.portTo ? String(rule.portFrom) : `${rule.portFrom}:${rule.portTo}`,
1167
+ "proto",
1168
+ rule.protocol,
1169
+ "comment",
1170
+ comment
1171
+ ]);
1172
+ const ranges = new Map;
1173
+ for (const rule of ordered)
1174
+ ranges.set(`${rule.protocol}:${rule.portFrom}:${rule.portTo}`, rule);
1175
+ for (const range of ranges.values())
1176
+ commands.push([
1177
+ "/usr/sbin/ufw",
1178
+ "insert",
1179
+ "1",
1180
+ "deny",
1181
+ "to",
1182
+ "any",
1183
+ "port",
1184
+ range.portFrom === range.portTo ? String(range.portFrom) : `${range.portFrom}:${range.portTo}`,
1185
+ "proto",
1186
+ range.protocol,
1187
+ "comment",
1188
+ comment
1189
+ ]);
1190
+ for (const command of commands.toReversed())
1191
+ await checked(host, command, "deployment firewall rule");
1192
+ evidence.firewall = { generation: policy.generation, rules: commands.length, active: true };
1193
+ }
1121
1194
  const topology = request.topology;
1122
1195
  if (topology) {
1123
- const values = [topology.localRelayAddress, ...topology.localPeerAddresses, ...topology.remoteRelayAddresses];
1196
+ const values = [
1197
+ topology.localRelayAddress,
1198
+ ...topology.localPeerAddresses,
1199
+ ...topology.remoteRelayAddresses,
1200
+ ...topology.remoteMemberAddresses
1201
+ ];
1124
1202
  const identities = [
1125
1203
  topology.nodeIdentity,
1126
1204
  ...topology.localPeerIdentities,
1127
1205
  ...topology.remoteRelayIdentities,
1128
1206
  ...topology.memberIdentities
1129
1207
  ];
1130
- 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") {
1208
+ if (!/^[A-Za-z0-9.-]{1,253}$/.test(topology.site) || !/^(?:\d{1,3}\.){3}0\/24$/.test(topology.siteCidr) || values.some((value) => isIP(value) !== 4) || identities.some((value) => !/^[A-Za-z0-9.-]{1,253}$/.test(value)) || topology.localPeerIdentities.length !== topology.localPeerAddresses.length || topology.remoteRelayIdentities.length !== topology.remoteRelayAddresses.length || topology.remoteMemberAddresses.length > 1024 || new Set(topology.remoteMemberAddresses).size !== topology.remoteMemberAddresses.length || topology.memberIdentities.length < 1 || topology.memberIdentities.length > 1024 || new Set(topology.memberIdentities).size !== topology.memberIdentities.length || !topology.memberIdentities.includes(topology.nodeIdentity) || !/^sha256:[a-f0-9]{64}$/.test(topology.generation) || (topology.role === "member" ? topology.remoteRelayAddresses.length !== 0 : topology.remoteSiteCidrs.length !== topology.remoteRelayAddresses.length) || topology.remoteSiteCidrs.some((value) => !/^(?:\d{1,3}\.){3}0\/24$/.test(value)) || new Set(topology.remoteSiteCidrs).size !== topology.remoteSiteCidrs.length || !Number.isSafeInteger(topology.healthPort) || topology.healthPort < 1 || topology.healthPort > 65535 || topology.routedTcpPorts.length < 1 || topology.routedTcpPorts.length > 64 || new Set(topology.routedTcpPorts).size !== topology.routedTcpPorts.length || !topology.routedTcpPorts.includes(topology.healthPort) || topology.routedTcpPorts.some((port) => !Number.isSafeInteger(port) || port < 1 || port > 65535) || topology.role === "member" && topology.transport !== "private-lan") {
1131
1209
  throw new Error("deployment topology is malformed");
1132
1210
  }
1133
1211
  if (request.intent.private?.mode === "cloudflare-warp" !== (topology.transport === "cloudflare-warp")) {
@@ -1254,28 +1332,46 @@ ${forwarding}${noOp}${starts}${starts ? `
1254
1332
  [Install]
1255
1333
  WantedBy=multi-user.target
1256
1334
  `, 420);
1257
- for (const source of [...new Set([topology.siteCidr, ...topology.remoteSiteCidrs])]) {
1335
+ const firewallComment = `fz-topology-${id}`;
1336
+ await replaceTaggedUfwRules(host, firewallComment);
1337
+ for (const source of [...new Set([...topology.localPeerAddresses, ...topology.remoteMemberAddresses])]) {
1258
1338
  for (const port of topology.routedTcpPorts) {
1259
- await checked(host, ["/usr/sbin/ufw", "allow", "from", source, "to", "any", "port", String(port), "proto", "tcp"], `private service ${source}:${port}`);
1339
+ await checked(host, [
1340
+ "/usr/sbin/ufw",
1341
+ "allow",
1342
+ "from",
1343
+ source,
1344
+ "to",
1345
+ "any",
1346
+ "port",
1347
+ String(port),
1348
+ "proto",
1349
+ "tcp",
1350
+ "comment",
1351
+ firewallComment
1352
+ ], `private service ${source}:${port}`);
1260
1353
  }
1261
1354
  }
1262
1355
  if (topology.role !== "member")
1263
- for (const remoteCidr of topology.remoteSiteCidrs) {
1264
- for (const port of topology.routedTcpPorts) {
1265
- await checked(host, [
1266
- "/usr/sbin/ufw",
1267
- "route",
1268
- "allow",
1269
- "proto",
1270
- "tcp",
1271
- "from",
1272
- topology.siteCidr,
1273
- "to",
1274
- remoteCidr,
1275
- "port",
1276
- String(port)
1277
- ], `private routed service ${topology.siteCidr}->${remoteCidr}:${port}`);
1278
- }
1356
+ for (const remoteAddress of topology.remoteMemberAddresses) {
1357
+ for (const source of topology.localPeerAddresses)
1358
+ for (const port of topology.routedTcpPorts) {
1359
+ await checked(host, [
1360
+ "/usr/sbin/ufw",
1361
+ "route",
1362
+ "allow",
1363
+ "proto",
1364
+ "tcp",
1365
+ "from",
1366
+ source,
1367
+ "to",
1368
+ remoteAddress,
1369
+ "port",
1370
+ String(port),
1371
+ "comment",
1372
+ firewallComment
1373
+ ], `private routed service ${source}->${remoteAddress}:${port}`);
1374
+ }
1279
1375
  }
1280
1376
  await checked(host, ["/usr/bin/systemctl", "daemon-reload"], "topology daemon reload");
1281
1377
  await checked(host, ["/usr/bin/systemctl", "enable", "--now", routeUnit], "topology routes");