From 0172106559fd0957894041f57a58d8bc0e4aea97 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 24 Jun 2026 19:17:37 +0200 Subject: [PATCH] feat(#1971): network.exposeHostPorts to reach host-loopback services from agents --- docs/network.md | 21 ++++++++++ nix/modules/hive-network.nix | 74 +++++++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/docs/network.md b/docs/network.md index a2f0566a..aea7defc 100644 --- a/docs/network.md +++ b/docs/network.md @@ -100,6 +100,27 @@ 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`) + +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. + +`services.hyperhive.network.exposeHostPorts = [ 4318 ];` opens a +controlled path for each listed TCP port `P`: + +- 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`. + +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. + ## Container isolation `services.hyperhive.network.isolateContainers` (default `false`) flips diff --git a/nix/modules/hive-network.nix b/nix/modules/hive-network.nix index f4a6f9e9..0d02d5e1 100644 --- a/nix/modules/hive-network.nix +++ b/nix/modules/hive-network.nix @@ -92,6 +92,30 @@ in ''; }; + exposeHostPorts = lib.mkOption { + type = lib.types.listOf lib.types.port; + 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. + + 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`). + + 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. + ''; + }; + isolateContainers = lib.mkOption { type = lib.types.bool; default = true; @@ -205,10 +229,12 @@ in # Allow isolated agents to reach the gateway (nginx on the host, shared # netns). Port 80 covers `http://forge.`, per-agent UI proxies, # and any other HTTP services the gateway fronts. Port 443 for HTTPS. + # `exposeHostPorts` adds any operator-declared host-loopback proxies. networking.firewall.interfaces.${cfg.bridgeName}.allowedTCPPorts = [ 80 443 - ]; + ] + ++ cfg.exposeHostPorts; # Tells hive-c0re to pass PRIVATE_NETWORK + bridge settings to each # container. HIVE_NETWORK_SUBNET is host-bridge IP/prefix, not canonical @@ -220,6 +246,52 @@ 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. + (lib.mkIf (config.services.hyperhive.enable && 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}"; + # Pure userspace TCP proxy: no filesystem, no privileges, no extra + # address families beyond inet/unix. 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 # existing configs that set them to `true` still eval cleanly; setting # either to `false` no longer does anything (network + isolation are