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