From 609438a8897004537685f1fe2ea243a29359a8ad Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 24 Jun 2026 19:48:50 +0200 Subject: [PATCH] fix(#1977): exposeHostPorts is firewall-only (drop conflicting loopback proxy) --- docs/network.md | 34 +++++++------- nix/modules/hive-network.nix | 89 ++++++++++-------------------------- 2 files changed, 41 insertions(+), 82 deletions(-) diff --git a/docs/network.md b/docs/network.md index aea7defc..164a3ea0 100644 --- a/docs/network.md +++ b/docs/network.md @@ -100,26 +100,28 @@ When `isolateContainers = true`, `allowedTCPPorts` is extended with shared host netns) for the forge sub-domain, per-agent UI proxies, and any other HTTP services. -### Reaching host-loopback services (`exposeHostPorts`) +### Reaching host services (`exposeHostPorts`) -Agents are deliberately cut off from the host loopback (the bridge→ -`127.0.0.0/8` DROP rule below), so a service that only binds -`127.0.0.1` on the host — e.g. a dev OTEL collector for -`services.hyperhive.otel.endpoint` — is unreachable by default. +By default agents can only reach the host on 80/443 (+53 DNS), so a +host-side service on another port — e.g. a dev OTEL collector for +`services.hyperhive.otel.endpoint` — is unreachable. -`services.hyperhive.network.exposeHostPorts = [ 4318 ];` opens a -controlled path for each listed TCP port `P`: +`services.hyperhive.network.exposeHostPorts = [ 4318 ];` opens each +listed TCP port `P` on the bridge-interface `allowedTCPPorts`, so an +agent can connect to `:P` (point the collector endpoint at +`http://:4318`, default `http://10.42.0.1:4318`). -- a socket-activated `systemd-socket-proxyd` (`hive-hostport-

`) - listens on `:P` and forwards to `127.0.0.1:P`; -- `P` is added to the bridge-interface `allowedTCPPorts`. +This is **firewall-only**: the host service must bind an address +reachable from the bridge — `0.0.0.0` or the bridge IP — not loopback +only. The bridge→`127.0.0.0/8` DROP rule (below) is unchanged, so a +service bound to `127.0.0.1` only stays unreachable; rebind it to +`0.0.0.0`. (An earlier revision shipped a per-port +`systemd-socket-proxyd` bridge→loopback forwarder, but that collides +EADDRINUSE with any collector already bound to `0.0.0.0` — which is the +common case — so the proxy was dropped in favour of opening the port.) -The agent then points at `http://:P` (default -`http://10.42.0.1:4318`). This keeps the loopback DROP rule intact: -agents only ever connect to the bridge IP, and the host's own proxy -process is what dials `127.0.0.1`. The port is reachable by **every** -agent on the bridge subnet (like DNS/gateway), so only expose services -safe for any agent to reach. +The port is reachable by **every** agent on the bridge subnet (like +DNS/gateway), so only expose services safe for any agent to reach. ## Container isolation diff --git a/nix/modules/hive-network.nix b/nix/modules/hive-network.nix index 4737b7d5..8c66f8e8 100644 --- a/nix/modules/hive-network.nix +++ b/nix/modules/hive-network.nix @@ -97,22 +97,24 @@ in default = [ ]; example = [ 4318 ]; description = '' - TCP ports on the host's loopback (`127.0.0.1`) to expose to agent - containers at the bridge IP (`bridgeIp`). For each port `P`, a - socket-activated `systemd-socket-proxyd` listens on - `''${bridgeIp}:P` and forwards to `127.0.0.1:P`, and `P` is opened - on the bridge-interface firewall. + TCP ports on the host that agent containers may reach at the bridge + IP (`bridgeIp`). Each listed port `P` is opened on the bridge-interface + firewall, so an agent can connect to `''${bridgeIp}:P` (default + `10.42.0.1:P`). - Use this to let agents reach a host-local service that only binds - loopback — e.g. an OpenTelemetry collector for - `services.hyperhive.otel.endpoint`. The agent points at - `http://''${bridgeIp}:P` (default `http://10.42.0.1:P`). + Use this to let agents reach a host-local service — e.g. an + OpenTelemetry collector for `services.hyperhive.otel.endpoint` (set + `endpoint = "http://''${bridgeIp}:P"`). - This does NOT weaken the bridge→loopback DROP rule (defence-in-depth): - agents never reach `127.0.0.0/8`; they connect to the bridge IP and - the host's own proxy process dials loopback. The exposed port is - reachable by EVERY agent on the bridge subnet (same as DNS/gateway), - so only expose services that are safe for any agent to reach. + **The host service must bind an address reachable from the bridge** — + `0.0.0.0` or the bridge IP (`bridgeIp`) — not loopback-only. The + bridge→`127.0.0.0/8` DROP rule (defence-in-depth) is unchanged: this + only opens the firewall, it does not bridge loopback. A service that + binds `127.0.0.1` only is still unreachable; rebind it to `0.0.0.0`. + + The exposed port is reachable by EVERY agent on the bridge subnet + (same as DNS/gateway), so only expose services safe for any agent to + reach. ''; }; @@ -246,60 +248,15 @@ in }; }) - # Host-loopback port exposure: one socket-activated systemd-socket-proxyd - # per `exposeHostPorts` entry, listening on the bridge IP and forwarding to - # the host's loopback. This is how agents reach a host-local service (e.g. a - # dev OTEL collector on 127.0.0.1) without weakening the bridge→loopback - # DROP rule above — agents connect to the bridge IP, and the host's own - # proxy process is what dials 127.0.0.1. See docs/network.md. + # Host port exposure: open each `exposeHostPorts` entry on the bridge + # firewall so agents can reach a host service at `:P`. The host + # service must bind `0.0.0.0` or the bridge IP (a loopback-only bind stays + # unreachable — the bridge→127.0.0.0/8 DROP rule above is unchanged). This + # is firewall-only by design: a host service that binds `0.0.0.0` already + # serves the bridge IP, so an extra bridge-IP proxy would only collide + # (EADDRINUSE) with it. Merges with the [ 80 443 ] gateway ports above. (lib.mkIf (config.services.hyperhive.enable && cfg.exposeHostPorts != [ ]) { - # Open the proxied ports on the bridge firewall HERE — same block + same - # gate as the proxies — so the firewall hole and the listener are always - # created together (never one without the other). Merges with the - # [ 80 443 ] gateway ports defined above. Independent of the deprecated - # isolateContainers toggle: isolation is unconditional, so this works - # under network isolation (the only mode). networking.firewall.interfaces.${cfg.bridgeName}.allowedTCPPorts = cfg.exposeHostPorts; - - systemd.sockets = lib.listToAttrs ( - map ( - p: - lib.nameValuePair "hive-hostport-${toString p}" { - description = "Host-loopback proxy socket for port ${toString p} (bridge→127.0.0.1)"; - wantedBy = [ "sockets.target" ]; - socketConfig.ListenStream = "${cfg.bridgeIp}:${toString p}"; - } - ) cfg.exposeHostPorts - ); - - systemd.services = lib.listToAttrs ( - map ( - p: - lib.nameValuePair "hive-hostport-${toString p}" { - description = "Host-loopback proxy for port ${toString p} (bridge→127.0.0.1)"; - requires = [ "hive-hostport-${toString p}.socket" ]; - after = [ "hive-hostport-${toString p}.socket" ]; - serviceConfig = { - ExecStart = "${config.systemd.package}/lib/systemd/systemd-socket-proxyd 127.0.0.1:${toString p}"; - SyslogIdentifier = "hive-hostport-${toString p}"; - # Hardened userspace TCP relay. AF_INET/AF_INET6 for the upstream - # loopback dial; AF_UNIX only for systemd-socket-proxyd's sd_notify - # (the listen fd itself is inherited via LISTEN_FDS, so the relay - # never socket()s its own listener). DynamicUser keeps it unprivileged. - DynamicUser = true; - NoNewPrivileges = true; - ProtectSystem = "strict"; - ProtectHome = true; - PrivateTmp = true; - RestrictAddressFamilies = [ - "AF_INET" - "AF_INET6" - "AF_UNIX" - ]; - }; - } - ) cfg.exposeHostPorts - ); }) # Deprecation surface for the removed toggles. Both options are kept so