@forgezero/agent 0.1.32 → 0.1.34

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/dist/provision.js CHANGED
@@ -229,6 +229,7 @@ var AGENT_UPDATE_JOURNAL = "/var/lib/forgezero/agent-update.json";
229
229
  var AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update-receipt.json";
230
230
  var MAX_REQUEST_BYTES = 8 * 1024;
231
231
  var COMPUTE_HELPER_UNITS = [
232
+ "forgezero-agent-egress.service",
232
233
  "forgezero-deploy-runner.service",
233
234
  "forgezero-lifecycle-helper.service",
234
235
  "forgezero-software-helper.service"
@@ -883,9 +884,71 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
883
884
  }
884
885
 
885
886
  // src/version.ts
886
- var VERSION3 = "0.1.32";
887
+ var VERSION3 = "0.1.34";
888
+
889
+ // src/egress-policy.ts
890
+ import { realpathSync } from "node:fs";
891
+ var AGENT_EGRESS_TABLE = "forgezero_agent_egress";
892
+ var SYSTEMD_RESOLVED_ADDRESS = "127.0.0.53";
893
+ var BLOCKED_IPV4 = [
894
+ "0.0.0.0/8",
895
+ "10.0.0.0/8",
896
+ "100.64.0.0/10",
897
+ "127.0.0.0/8",
898
+ "168.63.129.16/32",
899
+ "169.254.0.0/16",
900
+ "172.16.0.0/12",
901
+ "192.0.0.0/24",
902
+ "192.0.2.0/24",
903
+ "192.88.99.0/24",
904
+ "192.168.0.0/16",
905
+ "198.18.0.0/15",
906
+ "198.51.100.0/24",
907
+ "203.0.113.0/24",
908
+ "224.0.0.0/4",
909
+ "240.0.0.0/4"
910
+ ];
911
+ var BLOCKED_IPV6 = [
912
+ "::/128",
913
+ "::1/128",
914
+ "::ffff:0:0/96",
915
+ "64:ff9b::/96",
916
+ "64:ff9b:1::/48",
917
+ "100::/64",
918
+ "fc00::/7",
919
+ "fec0::/10",
920
+ "fe80::/10",
921
+ "ff00::/8",
922
+ "2001::/32",
923
+ "2001:2::/48",
924
+ "2001:10::/28",
925
+ "2001:20::/28",
926
+ "2001:db8::/32",
927
+ "2002::/16",
928
+ "3fff::/20"
929
+ ];
930
+ var normalizeEgressTcpPorts = (ports) => {
931
+ for (const port of ports) {
932
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
933
+ throw new Error("Agent egress policy refuses an invalid loopback TCP port.");
934
+ }
935
+ }
936
+ return [...new Set(ports)].sort((left, right) => left - right);
937
+ };
938
+ function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
939
+ const ports = normalizeEgressTcpPorts(loopbackTcpPorts);
940
+ return [
941
+ "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6",
942
+ `IPAddressAllow=${SYSTEMD_RESOLVED_ADDRESS}/32`,
943
+ ...ports.length > 0 ? ["IPAddressAllow=127.0.0.1/32", "IPAddressAllow=::1/128"] : [],
944
+ ...BLOCKED_IPV4.map((network) => `IPAddressDeny=${network}`),
945
+ ...BLOCKED_IPV6.map((network) => `IPAddressDeny=${network}`)
946
+ ].join(`
947
+ `);
948
+ }
887
949
 
888
950
  // src/provision.ts
951
+ import { isIP } from "node:net";
889
952
  function atLeast(version, floor) {
890
953
  const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
891
954
  const got = parse(version);
@@ -939,6 +1002,63 @@ var LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper
939
1002
  var LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
940
1003
  var WARP_CONFIG_UNIT_PATH = "/etc/systemd/system/forgezero-warp-config.service";
941
1004
  var WARP_SERVICE_DROP_IN_PATH = "/etc/systemd/system/warp-svc.service.d/forgezero.conf";
1005
+ var AGENT_EGRESS_UNIT_PATH = "/etc/systemd/system/forgezero-agent-egress.service";
1006
+ var DEFAULT_RUNNER_PUBLIC_TCP_PORTS = [443];
1007
+ function agentEgressUnit(options) {
1008
+ const bin = options.binPath ?? "fz-agent";
1009
+ const user = options.user ?? "forgezero";
1010
+ if (!/^[a-z_][a-z0-9_-]{0,30}$/.test(user))
1011
+ throw new Error("invalid Agent service user");
1012
+ const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
1013
+ const runnerLoopbackPorts = normalizeEgressTcpPorts(options.runnerLoopbackPorts ?? []);
1014
+ const runnerPublicTcpPorts = normalizeEgressTcpPorts(options.runnerPublicTcpPorts ?? DEFAULT_RUNNER_PUBLIC_TCP_PORTS);
1015
+ if (deploymentEnabled && runnerPublicTcpPorts.length < 1) {
1016
+ throw new Error("deployed project runner needs at least one vetted public TCP port");
1017
+ }
1018
+ if (deploymentEnabled)
1019
+ systemdAgentEgressDirectives(runnerLoopbackPorts);
1020
+ const users = [user, ...deploymentEnabled ? [DEPLOYMENT_RUNNER_USER] : []];
1021
+ const runnerGrant = deploymentEnabled ? ` --loopback-user=${DEPLOYMENT_RUNNER_USER}` + runnerLoopbackPorts.map((port) => ` --loopback-tcp-port=${port}`).join("") + runnerPublicTcpPorts.map((port) => ` --public-tcp-port=${port}`).join("") : "";
1022
+ const policyProofs = deploymentEnabled ? [
1023
+ ...runnerLoopbackPorts.length > 0 ? [`loopback=.*:${runnerLoopbackPorts.join(",")}`] : [],
1024
+ `public-tcp=${runnerPublicTcpPorts.join(",")}`
1025
+ ].map((pattern) => `ExecStartPost=/bin/sh -c '/usr/sbin/nft --numeric list table inet ${AGENT_EGRESS_TABLE} | /usr/bin/grep -q "${pattern}"'`).join(`
1026
+ `) : "";
1027
+ return `[Unit]
1028
+ Description=ForgeZero Agent host egress policy
1029
+ Documentation=https://www.forgezero.net/docs/agent
1030
+ After=systemd-resolved.service nftables.service
1031
+ Requires=systemd-resolved.service
1032
+ Before=forgezero-agent-enrol.service forgezero-agent.service
1033
+
1034
+ [Service]
1035
+ Type=notify
1036
+ NotifyAccess=all
1037
+ User=root
1038
+ Group=root
1039
+ ExecStart=${bin} egress-policy ${users.map((name) => `--user=${name}`).join(" ")}${runnerGrant}
1040
+ ${policyProofs}
1041
+ Restart=on-failure
1042
+ RestartSec=2
1043
+ LimitCORE=0
1044
+ NoNewPrivileges=true
1045
+ PrivateTmp=true
1046
+ ProtectSystem=strict
1047
+ ProtectHome=true
1048
+ ProtectKernelTunables=true
1049
+ ProtectKernelModules=true
1050
+ ProtectControlGroups=true
1051
+ RestrictSUIDSGID=true
1052
+ RestrictRealtime=true
1053
+ MemoryDenyWriteExecute=true
1054
+ LockPersonality=true
1055
+ CapabilityBoundingSet=CAP_NET_ADMIN
1056
+ RestrictAddressFamilies=AF_UNIX AF_NETLINK
1057
+
1058
+ [Install]
1059
+ WantedBy=multi-user.target
1060
+ `;
1061
+ }
942
1062
  function softwareHelperUnit(options) {
943
1063
  const bin = options.binPath ?? "fz-agent";
944
1064
  return `[Unit]
@@ -1183,11 +1303,16 @@ function agentEnrolmentUnit(options) {
1183
1303
  ` : ""
1184
1304
  ].join("");
1185
1305
  const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
1306
+ const egressDependency = options.enforceEgress ? `After=forgezero-agent-egress.service
1307
+ Requires=forgezero-agent-egress.service
1308
+ BindsTo=forgezero-agent-egress.service
1309
+ ` : "";
1310
+ const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives() : "";
1186
1311
  return `[Unit]
1187
1312
  Description=Bind this machine to its ForgeZero compute
1188
1313
  After=network-online.target
1189
1314
  Wants=network-online.target
1190
- Before=forgezero-agent.service
1315
+ ${egressDependency}Before=forgezero-agent.service
1191
1316
  ConditionPathExists=!${options.enrolStatePath}
1192
1317
 
1193
1318
  [Service]
@@ -1210,6 +1335,7 @@ ProtectSystem=strict
1210
1335
  ProtectHome=true
1211
1336
  ReadWritePaths=${stateDir}
1212
1337
  LimitCORE=0
1338
+ ${egressDirectives}
1213
1339
 
1214
1340
  [Install]
1215
1341
  WantedBy=multi-user.target
@@ -1219,9 +1345,15 @@ function deploymentRunnerUnit(options) {
1219
1345
  const bin = options.binPath ?? "fz-agent";
1220
1346
  const root = options.deployRoot ?? "/opt/forgezero";
1221
1347
  const agentUser = options.user ?? "forgezero-agent";
1348
+ const egressDependency = options.enforceEgress ? `After=forgezero-agent-egress.service
1349
+ Requires=forgezero-agent-egress.service
1350
+ BindsTo=forgezero-agent-egress.service
1351
+ ` : "";
1352
+ const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives(options.runnerLoopbackPorts ?? []) : "";
1222
1353
  return `[Unit]
1223
1354
  Description=ForgeZero credential-free project command runner
1224
1355
  Documentation=https://www.forgezero.net/docs/agent
1356
+ ${egressDependency}
1225
1357
 
1226
1358
  [Service]
1227
1359
  Type=notify
@@ -1252,6 +1384,7 @@ ProtectControlGroups=true
1252
1384
  RestrictRealtime=true
1253
1385
  MemoryDenyWriteExecute=true
1254
1386
  LockPersonality=true
1387
+ ${egressDirectives}
1255
1388
  ReadWritePaths=${root}/releases ${root}/runner-home
1256
1389
 
1257
1390
  [Install]
@@ -1261,6 +1394,21 @@ WantedBy=multi-user.target
1261
1394
  function agentUnit(options) {
1262
1395
  if (!validNodeHostname(options.nodeHostname))
1263
1396
  throw new Error("node hostname is invalid");
1397
+ if (!options.telemetryEndpoint) {
1398
+ throw new Error("compute Agent provisioning requires OTEL_EXPORTER_OTLP_ENDPOINT as an explicit public HTTPS collector coordinate");
1399
+ }
1400
+ let telemetryEndpoint;
1401
+ {
1402
+ let endpoint;
1403
+ try {
1404
+ endpoint = new URL(options.telemetryEndpoint);
1405
+ } catch {
1406
+ throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL");
1407
+ }
1408
+ if (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"))
1409
+ throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL without credentials, query or fragment");
1410
+ telemetryEndpoint = endpoint.toString().replace(/\/$/, "");
1411
+ }
1264
1412
  const bin = options.binPath ?? "fz-agent";
1265
1413
  const user = options.user ?? "forgezero";
1266
1414
  const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
@@ -1309,6 +1457,7 @@ function agentUnit(options) {
1309
1457
  }
1310
1458
  }
1311
1459
  const environment = [
1460
+ "NODE_ENV=production",
1312
1461
  `FZ_SOCKET_PATH=${agentBackendSocketPath(options.socketPath)}`,
1313
1462
  `FZ_CONTROL_SOCKET=${controlSocketPath}`,
1314
1463
  `FZ_SEED_CREDENTIAL=agent-seed`,
@@ -1319,12 +1468,15 @@ function agentUnit(options) {
1319
1468
  options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
1320
1469
  options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
1321
1470
  options.nodeHostname ? `FZ_NODE_HOSTNAME=${options.nodeHostname}` : null,
1471
+ `OTEL_EXPORTER_OTLP_ENDPOINT=${telemetryEndpoint}`,
1472
+ "OTEL_SERVICE_NAME=forgezero-agent",
1322
1473
  options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
1323
1474
  options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
1324
1475
  options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
1325
1476
  options.profile ? `FZ_DEPLOY_PROFILE=${options.profile}` : null,
1326
1477
  options.repository && options.branch ? `FZ_DEPLOY_KEY=${options.project ?? "platform"}:${options.environment ?? "production"}` : null,
1327
1478
  deploymentEnabled ? `FZ_DEPLOY_ROOT=${deployRoot}` : null,
1479
+ deploymentEnabled ? `FZ_CAPACITY_EVIDENCE_DIR=${deployRoot}/capacity` : null,
1328
1480
  deploymentEnabled ? `FZ_DEPLOY_RUNNER_SOCKET=${DEPLOYMENT_RUNNER_SOCKET}` : null,
1329
1481
  deploymentEnabled ? `FZ_SOFTWARE_HELPER_SOCKET=${DEFAULT_SOFTWARE_HELPER_SOCKET}` : null,
1330
1482
  Object.keys(deploymentCredentials).length > 0 ? `FZ_DEPLOY_SYSTEMD_SECRETS=${Object.keys(deploymentCredentials).join(",")}` : null,
@@ -1343,7 +1495,7 @@ function agentUnit(options) {
1343
1495
  ` : "";
1344
1496
  const projectCredentials = Object.entries(deploymentCredentials).map(([name, path]) => `LoadCredentialEncrypted=${name}:${path}`).join(`
1345
1497
  `);
1346
- const deploymentWrites = deploymentEnabled ? `ReadWritePaths=${deployRoot}/releases ${deployRoot}/agent-home ${deployRoot}/cache` : "";
1498
+ const deploymentWrites = deploymentEnabled ? `ReadWritePaths=${deployRoot}/releases ${deployRoot}/agent-home ${deployRoot}/cache ${deployRoot}/capacity` : "";
1347
1499
  const supplementaryGroups = [
1348
1500
  AGENT_UPDATE_GROUP,
1349
1501
  deploymentEnabled ? DEPLOYMENT_GROUP : null,
@@ -1354,6 +1506,7 @@ function agentUnit(options) {
1354
1506
  const after = [
1355
1507
  "network-online.target",
1356
1508
  "forgezero-agent-update-helper.service",
1509
+ options.enforceEgress ? "forgezero-agent-egress.service" : null,
1357
1510
  deploymentEnabled ? "forgezero-deploy-runner.service" : null,
1358
1511
  deploymentEnabled ? "forgezero-software-helper.service" : null,
1359
1512
  lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
@@ -1362,6 +1515,7 @@ function agentUnit(options) {
1362
1515
  ].filter((value) => value !== null);
1363
1516
  const requires = [
1364
1517
  "forgezero-agent-update-helper.service",
1518
+ options.enforceEgress ? "forgezero-agent-egress.service" : null,
1365
1519
  deploymentEnabled ? "forgezero-deploy-runner.service" : null,
1366
1520
  deploymentEnabled ? "forgezero-software-helper.service" : null,
1367
1521
  lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
@@ -1371,7 +1525,8 @@ function agentUnit(options) {
1371
1525
  const deploymentDependency = [
1372
1526
  `After=${after.join(" ")}`,
1373
1527
  "Wants=network-online.target",
1374
- requires.length > 0 ? `Requires=${requires.join(" ")}` : null
1528
+ requires.length > 0 ? `Requires=${requires.join(" ")}` : null,
1529
+ options.enforceEgress ? "BindsTo=forgezero-agent-egress.service" : null
1375
1530
  ].filter((value) => value !== null).join(`
1376
1531
  `);
1377
1532
  const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
@@ -1379,6 +1534,7 @@ DeviceAllow=/dev/sev-guest rw` : "";
1379
1534
  const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
1380
1535
  ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
1381
1536
  ` : "";
1537
+ const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives() : "";
1382
1538
  return `[Unit]
1383
1539
  Description=ForgeZero node agent (${options.mode})
1384
1540
  Documentation=https://www.forgezero.net/docs/agent
@@ -1423,6 +1579,7 @@ RestrictSUIDSGID=true
1423
1579
  RestrictRealtime=true
1424
1580
  MemoryDenyWriteExecute=true
1425
1581
  LockPersonality=true
1582
+ ${egressDirectives}
1426
1583
  ${snpDevice}
1427
1584
  ${deploymentWrites}
1428
1585
 
@@ -1438,6 +1595,8 @@ function planProvision(options) {
1438
1595
  const credentialDir = seedCredentialPath.replace(/\/[^/]+$/, "");
1439
1596
  const deployRoot = options.deployRoot ?? "/opt/forgezero";
1440
1597
  const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
1598
+ const runnerLoopbackPorts = normalizeEgressTcpPorts(options.runnerLoopbackPorts ?? []);
1599
+ const runnerPublicTcpPorts = normalizeEgressTcpPorts(options.runnerPublicTcpPorts ?? DEFAULT_RUNNER_PUBLIC_TCP_PORTS);
1441
1600
  const lifecycleEnabled = Boolean(options.pullMigrations && options.lifecycleProfilePath);
1442
1601
  if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
1443
1602
  throw new Error("migration pull and lifecycle profile must be supplied together");
@@ -1478,6 +1637,9 @@ function planProvision(options) {
1478
1637
  { path: AGENT_SOCKET_UNIT_PATH, unit: agentSocketUnit(options) },
1479
1638
  { path: AGENT_SOCKET_PROXY_UNIT_PATH, unit: agentSocketProxyUnit(options) },
1480
1639
  { path: AGENT_UPDATE_HELPER_UNIT_PATH, unit: agentUpdateHelperUnit(options) },
1640
+ ...options.enforceEgress ? [
1641
+ { path: AGENT_EGRESS_UNIT_PATH, unit: agentEgressUnit(options) }
1642
+ ] : [],
1481
1643
  ...deploymentEnabled ? [
1482
1644
  { path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) },
1483
1645
  { path: SOFTWARE_HELPER_UNIT_PATH, unit: softwareHelperUnit(options) }
@@ -1504,6 +1666,10 @@ function planProvision(options) {
1504
1666
  socketPath: options.socketPath,
1505
1667
  user,
1506
1668
  steps: [
1669
+ ...options.enforceEgress ? [{
1670
+ label: "Ubuntu Agent egress prerequisites",
1671
+ command: `. /etc/os-release; test "$ID" = ubuntu; ` + `DEBIAN_FRONTEND=noninteractive apt-get update -qq; ` + `DEBIAN_FRONTEND=noninteractive apt-get install -y nftables; ` + `systemctl enable --now systemd-resolved.service; ` + `test "$(readlink -f /etc/resolv.conf)" = /run/systemd/resolve/stub-resolv.conf; ` + `test -s /run/systemd/resolve/stub-resolv.conf`
1672
+ }] : [],
1507
1673
  {
1508
1674
  label: "vault socket access group",
1509
1675
  command: `groupadd --system ${VAULT_GROUP} || true`
@@ -1582,7 +1748,7 @@ function planProvision(options) {
1582
1748
  ] : [],
1583
1749
  ...deploymentEnabled ? [{
1584
1750
  label: "deployment directories",
1585
- command: `install -d -o root -g root -m 0755 ${deployRoot} && ` + `install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 ${deployRoot}/releases && ` + `install -d -o ${user} -g ${user} -m 0750 ${deployRoot}/cache && ` + `install -d -o ${user} -g ${user} -m 0700 ${deployRoot}/agent-home && ` + `install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 ${deployRoot}/runner-home ${deployRoot}/runner-home/cache`
1751
+ command: `install -d -o root -g root -m 0755 ${deployRoot} && ` + `install -d -o root -g ${DEPLOYMENT_GROUP} -m 3770 ${deployRoot}/releases && ` + `install -d -o ${user} -g ${user} -m 0750 ${deployRoot}/cache && ` + `install -d -o ${user} -g ${user} -m 0700 ${deployRoot}/capacity && ` + `install -d -o ${user} -g ${user} -m 0700 ${deployRoot}/agent-home && ` + `install -d -o ${DEPLOYMENT_RUNNER_USER} -g ${DEPLOYMENT_GROUP} -m 0700 ${deployRoot}/runner-home ${deployRoot}/runner-home/cache`
1586
1752
  }] : [],
1587
1753
  { label: "reload units", command: "systemctl daemon-reload" },
1588
1754
  ...deploymentEnabled ? [{
@@ -1594,6 +1760,7 @@ function planProvision(options) {
1594
1760
  command: `systemctl enable ${[
1595
1761
  "forgezero-agent.socket",
1596
1762
  "forgezero-agent-update-helper.service",
1763
+ ...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
1597
1764
  ...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
1598
1765
  ...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
1599
1766
  ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
@@ -1601,6 +1768,7 @@ function planProvision(options) {
1601
1768
  "forgezero-agent.service"
1602
1769
  ].join(" ")}; systemctl reset-failed forgezero-agent.service || true; systemctl restart ${[
1603
1770
  "forgezero-agent-update-helper.service",
1771
+ ...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
1604
1772
  ...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
1605
1773
  ...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
1606
1774
  ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
@@ -1611,6 +1779,10 @@ function planProvision(options) {
1611
1779
  label: "prove the compute binding is durable",
1612
1780
  command: `test -s ${enrolStatePath}`
1613
1781
  }] : [],
1782
+ ...options.enforceEgress ? [{
1783
+ label: "prove the Agent egress policy is active",
1784
+ command: "systemctl is-active forgezero-agent-egress.service && " + "nft --numeric list table inet forgezero_agent_egress | grep -q forgezero-agent-egress-v1" + (deploymentEnabled ? ` && nft --numeric list table inet forgezero_agent_egress | grep -q 'public-tcp=${runnerPublicTcpPorts.join(",")}'` + (runnerLoopbackPorts.length > 0 ? ` && nft --numeric list table inet forgezero_agent_egress | grep -Eq 'loopback=[0-9]+:${runnerLoopbackPorts.join(",")}( |")'` : "") : "")
1785
+ }] : [],
1614
1786
  { label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
1615
1787
  { label: "prove the public Vault socket exists", command: awaitSocketCommand(options.socketPath) },
1616
1788
  { label: "prove the Agent Vault backend exists", command: awaitSocketCommand(agentBackendSocketPath(options.socketPath)) },
@@ -1652,6 +1824,7 @@ export {
1652
1824
  agentSocketUnit,
1653
1825
  agentSocketProxyUnit,
1654
1826
  agentEnrolmentUnit,
1827
+ agentEgressUnit,
1655
1828
  agentBackendSocketPath,
1656
1829
  WARP_SERVICE_DROP_IN_PATH,
1657
1830
  WARP_CONFIG_UNIT_PATH,
@@ -1665,7 +1838,9 @@ export {
1665
1838
  DEPLOYMENT_RUNNER_UNIT_PATH,
1666
1839
  DEPLOYMENT_RUNNER_SOCKET,
1667
1840
  DEPLOYMENT_GROUP,
1841
+ DEFAULT_RUNNER_PUBLIC_TCP_PORTS,
1668
1842
  CAPABILITY_CHECKS,
1669
1843
  AGENT_SOCKET_UNIT_PATH,
1670
- AGENT_SOCKET_PROXY_UNIT_PATH
1844
+ AGENT_SOCKET_PROXY_UNIT_PATH,
1845
+ AGENT_EGRESS_UNIT_PATH
1671
1846
  };
@@ -1,5 +1,6 @@
1
1
  import type { NodeKeyPair } from '@forgezero/runtime/identity';
2
2
  import { type SignedNodeHttpOptions } from './signed-node-http';
3
+ import type { AgentOperationTelemetry } from './telemetry-runtime';
3
4
  interface RemoteProvisionClaimBase {
4
5
  computeKey: string;
5
6
  claimToken: string;
@@ -73,6 +74,7 @@ export interface ProvisioningPullOptions extends SignedNodeHttpOptions {
73
74
  kvm: boolean;
74
75
  helper: boolean;
75
76
  };
77
+ telemetry?: AgentOperationTelemetry;
76
78
  }
77
79
  export type ProvisionPullResult = {
78
80
  status: 'idle';
@@ -70,6 +70,7 @@ async function postSignedNode(options, path, body) {
70
70
  // src/provisioning-pull.ts
71
71
  class ProvisionClaimLostError extends Error {
72
72
  }
73
+ var remoteOutcome = (cause) => cause instanceof SignedNodeHttpError && cause.status < 500 ? "refused" : "retryable";
73
74
  var post = (options, operation, body) => postSignedNode(options, `v1/metal/computes/${operation}`, body);
74
75
  async function runClaim(options, claim) {
75
76
  const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
@@ -88,10 +89,11 @@ async function runClaim(options, claim) {
88
89
  if (stopped || renewal)
89
90
  return;
90
91
  let retry;
91
- renewal = post(options, "renew", {
92
+ const renew = () => post(options, "renew", {
92
93
  computeKey: claim.computeKey,
93
94
  claimToken: claim.claimToken
94
- }).then((response) => {
95
+ });
96
+ renewal = (options.telemetry ? options.telemetry.observe("provisioning.renew", renew, () => "success", remoteOutcome) : renew()).then((response) => {
95
97
  expires = response.claimExpiresAtTs;
96
98
  options.onEvent?.("lease-renewed", { computeKey: claim.computeKey, claimExpiresAtTs: expires });
97
99
  }).catch((cause) => {
@@ -112,7 +114,8 @@ async function runClaim(options, claim) {
112
114
  schedule();
113
115
  let result;
114
116
  try {
115
- result = await options.run(claim);
117
+ const run = () => options.run(claim);
118
+ result = options.telemetry ? await options.telemetry.observe("provisioning.apply", run) : await run();
116
119
  } finally {
117
120
  stopped = true;
118
121
  clearTimer(timer);
@@ -143,7 +146,8 @@ async function complete(options, body) {
143
146
  async function pullProvisioningOnce(options) {
144
147
  if (options.metalPreflight) {
145
148
  const report = options.metalPreflight();
146
- const accepted = await postSignedNode(options, "v1/metal/preflight", report);
149
+ const preflight = () => postSignedNode(options, "v1/metal/preflight", report);
150
+ const accepted = options.telemetry ? await options.telemetry.observe("provisioning.preflight", preflight, (value) => value.ready ? "success" : "refused", remoteOutcome) : await preflight();
147
151
  if (options.metalHostname && accepted.hostname !== options.metalHostname) {
148
152
  throw new Error(`metal agent: configured inventory hostname ${options.metalHostname} is bound as ${accepted.hostname}; refusing work.`);
149
153
  }
@@ -156,7 +160,8 @@ async function pullProvisioningOnce(options) {
156
160
  if (!accepted.ready)
157
161
  return { status: "idle" };
158
162
  }
159
- const response = await post(options, "claim", {});
163
+ const claimWork = () => post(options, "claim", {});
164
+ const response = options.telemetry ? await options.telemetry.observe("provisioning.claim", claimWork, (value) => value.claim ? "success" : "idle", remoteOutcome) : await claimWork();
160
165
  if (!response.claim)
161
166
  return { status: "idle" };
162
167
  const claim = response.claim;
@@ -167,20 +172,28 @@ async function pullProvisioningOnce(options) {
167
172
  if (cause instanceof ProvisionClaimLostError)
168
173
  throw cause;
169
174
  const reason = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2000);
170
- await complete(options, {
175
+ const acknowledge2 = () => complete(options, {
171
176
  computeKey: claim.computeKey,
172
177
  claimToken: claim.claimToken,
173
178
  ok: false,
174
179
  detail: reason
175
180
  });
181
+ if (options.telemetry) {
182
+ await options.telemetry.observe("provisioning.complete", acknowledge2, () => "success", remoteOutcome);
183
+ } else
184
+ await acknowledge2();
176
185
  return { status: "failed", claim, reason };
177
186
  }
178
- await complete(options, {
187
+ const acknowledge = () => complete(options, {
179
188
  computeKey: claim.computeKey,
180
189
  claimToken: claim.claimToken,
181
190
  ok: true,
182
191
  ...result.guestAddress ? { guestAddress: result.guestAddress } : {}
183
192
  });
193
+ if (options.telemetry) {
194
+ await options.telemetry.observe("provisioning.complete", acknowledge, () => "success", remoteOutcome);
195
+ } else
196
+ await acknowledge();
184
197
  return { status: claim.action === "delete" ? "terminated" : "running", claim, result };
185
198
  }
186
199
  function startProvisioningPull(options) {
@@ -0,0 +1,20 @@
1
+ import type { AgentTelemetry, AgentTelemetryEvent, AgentTelemetryOperation, AgentTelemetryOutcome } from './telemetry';
2
+ /**
3
+ * Process-wide operation/state accounting layered over the deliberately small
4
+ * OTLP emitter. Callers still cannot attach arbitrary attributes or payloads.
5
+ */
6
+ export declare class AgentTelemetryRuntime {
7
+ private readonly telemetry;
8
+ private readonly now;
9
+ private active;
10
+ private completed;
11
+ private failed;
12
+ private draining;
13
+ constructor(telemetry: AgentTelemetry, now?: () => number);
14
+ event(event: AgentTelemetryEvent): void;
15
+ setDraining(draining: boolean): void;
16
+ observe<T>(operation: AgentTelemetryOperation, work: () => Promise<T>, outcome?: (value: T) => AgentTelemetryOutcome, errorOutcome?: (cause: unknown) => AgentTelemetryOutcome): Promise<T>;
17
+ close(): Promise<void>;
18
+ private recordState;
19
+ }
20
+ export type AgentOperationTelemetry = Pick<AgentTelemetryRuntime, 'observe'>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Dependency-free OTLP/HTTP telemetry for the machine Agent.
3
+ *
4
+ * Callers can name only reviewed operations, outcomes and lifecycle events.
5
+ * There is deliberately no field for a URL, request/response body, header,
6
+ * credential, tenant coordinate, command, error detail or arbitrary label.
7
+ */
8
+ export declare const AGENT_TELEMETRY_OPERATIONS: readonly ["agent.heartbeat", "agent.update", "attestation.refresh", "deployment.claim", "deployment.run", "deployment.renew", "deployment.complete", "migration.claim", "migration.run", "migration.renew", "migration.complete", "provisioning.preflight", "provisioning.claim", "provisioning.apply", "provisioning.renew", "provisioning.complete", "vault.sync", "agent.other"];
9
+ export type AgentTelemetryOperation = (typeof AGENT_TELEMETRY_OPERATIONS)[number];
10
+ export declare const AGENT_TELEMETRY_OUTCOMES: readonly ["success", "idle", "refused", "retryable", "failed"];
11
+ export type AgentTelemetryOutcome = (typeof AGENT_TELEMETRY_OUTCOMES)[number];
12
+ export declare const AGENT_TELEMETRY_EVENTS: readonly ["agent.started", "agent.draining", "agent.stopped", "agent.update_prepared", "agent.update_recovered", "telemetry.queue_overflow"];
13
+ export type AgentTelemetryEvent = (typeof AGENT_TELEMETRY_EVENTS)[number];
14
+ export interface AgentTelemetryConfig {
15
+ endpoint: string;
16
+ serviceName: string;
17
+ instanceId: string;
18
+ environment: 'production' | 'development';
19
+ flushIntervalMs: number;
20
+ traceSampleRatio: number;
21
+ }
22
+ export interface AgentOperationObservation {
23
+ operation: AgentTelemetryOperation;
24
+ outcome: AgentTelemetryOutcome;
25
+ startedAtMs: number;
26
+ durationMs: number;
27
+ }
28
+ export interface AgentTelemetryState {
29
+ active: number;
30
+ completed: number;
31
+ failed: number;
32
+ draining: boolean;
33
+ }
34
+ export interface AgentTelemetry {
35
+ recordOperation(observation: AgentOperationObservation): void;
36
+ recordEvent(event: AgentTelemetryEvent): void;
37
+ recordState(state: AgentTelemetryState): void;
38
+ flush(): Promise<void>;
39
+ close(): Promise<void>;
40
+ }
41
+ type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
42
+ export declare const AGENT_OTLP_EXPORT_TIMEOUT_MS = 5000;
43
+ export declare const AGENT_OTLP_MAX_ATTEMPTS = 2;
44
+ export declare const AGENT_OTLP_MAX_RETRY_DELAY_MS = 1000;
45
+ export declare const AGENT_TELEMETRY_MAX_SPANS = 256;
46
+ export declare const AGENT_TELEMETRY_MAX_LOGS = 64;
47
+ /** Resolve once at process startup; production refuses to run without an exporter. */
48
+ export declare function resolveAgentTelemetryConfig(env?: Record<string, string | undefined>): AgentTelemetryConfig | null;
49
+ export declare function createAgentTelemetry(input: AgentTelemetryConfig | null, options?: {
50
+ fetch?: FetchLike;
51
+ warn?: (message: string) => void;
52
+ now?: () => number;
53
+ random?: () => number;
54
+ wait?: (milliseconds: number) => Promise<void>;
55
+ }): AgentTelemetry;
56
+ export {};
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** One package version shared by both public binaries. Pinned to package.json by tests. */
2
- export declare const VERSION = "0.1.32";
2
+ export declare const VERSION = "0.1.34";
package/package.json CHANGED
@@ -1,12 +1,11 @@
1
1
  {
2
- "//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
3
2
  "name": "@forgezero/agent",
4
- "version": "0.1.32",
3
+ "version": "0.1.34",
5
4
  "type": "module",
6
5
  "scripts": {
7
6
  "check": "tsc --noEmit",
8
7
  "prebuild": "rm -rf dist",
9
- "build": "bun build src/index.ts --outfile dist/fz-agent.js --target bun --format esm && bun build src/cli/index.ts --outfile dist/fz.js --target bun --format esm && bun build src/compute.ts src/provision.ts src/subscribe.ts src/pipeline.ts src/definition.ts src/deploy-file.ts src/ssh-server.ts src/ssh-listen.ts src/provisioning-pull.ts src/migration-pull.ts src/guest-enrolment.ts src/node-vault.ts src/metal-provision.ts src/metal-helper-socket.ts src/lifecycle-helper.ts src/deployment-runner.ts src/agent-update.ts src/agent-update-helper.ts src/agent-heartbeat.ts src/software.ts src/software-helper.ts src/ubuntu.ts --root src --outdir dist --target browser --format esm --packages external && bun build src/project-context.ts --root src --outdir dist --target bun --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
8
+ "build": "bun build src/index.ts --outfile dist/fz-agent.js --target bun --format esm && bun build src/cli/index.ts --outfile dist/fz.js --target bun --format esm && bun build src/compute.ts src/provision.ts src/subscribe.ts src/pipeline.ts src/definition.ts src/deploy-file.ts src/ssh-server.ts src/ssh-listen.ts src/provisioning-pull.ts src/migration-pull.ts src/guest-enrolment.ts src/node-vault.ts src/metal-provision.ts src/metal-helper-socket.ts src/lifecycle-helper.ts src/deployment-runner.ts src/agent-update.ts src/agent-update-helper.ts src/agent-heartbeat.ts src/software.ts src/software-helper.ts src/ubuntu.ts src/capacity-calibration.ts --root src --outdir dist --target browser --format esm --packages external && bun build src/project-context.ts --root src --outdir dist --target bun --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
10
9
  "prepublishOnly": "bun run check && bun run build"
11
10
  },
12
11
  "devDependencies": {
@@ -18,19 +17,20 @@
18
17
  "@noble/post-quantum": "^0.6.1"
19
18
  },
20
19
  "dependencies": {
21
- "@forgezero/runtime": "^0.1.4",
22
- "@forgezero/vault": "^0.1.7",
23
- "@noble/curves": "^2.2.0",
24
- "@noble/hashes": "^2.2.0",
25
- "@noble/post-quantum": "^0.6.1",
26
- "@scure/bip39": "^2.2.0"
20
+ "@forgezero/runtime": "0.1.5",
21
+ "@forgezero/vault": "0.1.8",
22
+ "@noble/curves": "2.2.0",
23
+ "@noble/hashes": "2.2.0",
24
+ "@noble/post-quantum": "0.6.1",
25
+ "@scure/bip39": "2.2.0"
27
26
  },
28
27
  "bin": {
29
28
  "fz-agent": "dist/fz-agent.js",
30
29
  "fz": "dist/fz.js"
31
30
  },
32
31
  "publishConfig": {
33
- "access": "public"
32
+ "access": "public",
33
+ "provenance": true
34
34
  },
35
35
  "files": [
36
36
  "dist",
@@ -53,7 +53,7 @@
53
53
  "repository": {
54
54
  "type": "git",
55
55
  "url": "git+https://github.com/forgezero-net/packages.git",
56
- "directory": "packages/agent"
56
+ "directory": "agent"
57
57
  },
58
58
  "bugs": "https://github.com/forgezero-net/packages/issues",
59
59
  "exports": {
@@ -133,6 +133,10 @@
133
133
  "./project-context": {
134
134
  "types": "./dist/project-context.d.ts",
135
135
  "default": "./dist/project-context.js"
136
+ },
137
+ "./capacity-calibration": {
138
+ "types": "./dist/capacity-calibration.d.ts",
139
+ "default": "./dist/capacity-calibration.js"
136
140
  }
137
141
  }
138
142
  }
@@ -32,6 +32,20 @@
32
32
  "version": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9.-]{0,31}$" }
33
33
  }
34
34
  }
35
+ },
36
+ "capacityCalibration": {
37
+ "type": "object",
38
+ "additionalProperties": false,
39
+ "required": ["endpoint"],
40
+ "properties": {
41
+ "endpoint": { "type": "string", "pattern": "^http://(?:localhost|127\\.0\\.0\\.1|\\[::1\\]):[1-9][0-9]{0,4}/" },
42
+ "maxConcurrency": { "type": "integer", "minimum": 1, "maximum": 4096 },
43
+ "requestsPerWorker": { "type": "integer", "minimum": 2, "maximum": 100 },
44
+ "maxP95Ms": { "type": "number", "exclusiveMinimum": 0 },
45
+ "maxErrorRate": { "type": "number", "minimum": 0, "maximum": 0.2 },
46
+ "headroomRatio": { "type": "number", "minimum": 0.25, "maximum": 0.95 },
47
+ "requestTimeoutMs": { "type": "integer", "minimum": 100, "maximum": 30000 }
48
+ }
35
49
  }
36
50
  }
37
51
  }