diff --git a/nix/modules/hive-network.nix b/nix/modules/hive-network.nix index e304230b..8e91e45b 100644 --- a/nix/modules/hive-network.nix +++ b/nix/modules/hive-network.nix @@ -87,52 +87,178 @@ in regardless of upstream choice. ''; }; - }; - config = lib.mkIf cfg.enable { - assertions = [ - { - assertion = config.services.hyperhive.domain != null; - message = '' - services.hyperhive.network.enable = true requires - services.hyperhive.domain to be set — the resolver needs a - domain to be authoritative for. Either pin a hostname - (`services.hyperhive.domain = "example.com";`) or leave - `network.enable` at its default of false. - ''; - } - { - assertion = config.services.hyperhive.gateway.enable; - message = '' - services.hyperhive.network.enable = true requires - services.hyperhive.gateway.enable = true — the dnsmasq - resolver runs inside the hive-gateway container (single - front-door for both DNS and HTTP). Enable the gateway or - leave `network.enable` at its default of false. - ''; - } - ]; + isolateContainers = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Flip agent containers from shared host netns to private netns + (#14). When true, each agent container gets a dedicated veth + pair attached to `bridgeName` and a deterministic IP from + the bridge subnet. The bridge (already up when `enable = true`) + becomes the sole routed path between the host and agent + containers. - # Bridge interface on the host. Empty interfaces list = purely - # virtual bridge (no slave NICs attached). Per-agent veth pairs - # will join this bridge once #14 lands; at v1 it stands alone. - networking.bridges.${cfg.bridgeName}.interfaces = [ ]; + The host-side nix effect (this option) is: + - Sets `HIVE_NETWORK_ISOLATION=1` in the c0re service env so + the Rust lifecycle knows to pass `--private-network` + + bridge settings when creating/updating containers. + - Enables IP forwarding + NAT so agents can reach the internet + through the host. + - Adds a firewall rule DROP'ing traffic from the bridge subnet + to the host's loopback addresses — defence-in-depth so a + compromised agent can't reach the c0re dashboard (already + bound to 127.0.0.1) or other host-loopback services even if + the routing table somehow leaks. + - Allows HTTP/HTTPS (80/443) traffic from the bridge subnet to + the host so agents can reach the gateway container (shared + host netns, proxies the operator's per-agent UI). - # Host-side IP assignment on the bridge. This is what dnsmasq - # (inside the gateway container, shared host netns) binds on. - networking.interfaces.${cfg.bridgeName}.ipv4.addresses = [ - { - address = cfg.bridgeIp; - prefixLength = cfg.bridgePrefixLength; - } - ]; + **Prerequisite**: all agents must have + `hyperhive.web.useUnixSocket = true` before enabling isolation. + Agents that still bind TCP on `0.0.0.0:` will be + reachable at their bridge IP from other agents on the same + subnet — defeating the isolation goal. The gateway routes via + unix sockets so gateway reach still works regardless. - # Open the resolver port in the host firewall for traffic from - # the bridge subnet only. Other interfaces stay closed — - # external DNS-amplification surface is not exposed. - networking.firewall.interfaces.${cfg.bridgeName} = { - allowedUDPPorts = [ 53 ]; - allowedTCPPorts = [ 53 ]; + **Migration**: containers are destroyed and re-created when + the network isolation flag flips. Operator state under + `/agents//state/` is bind-mounted and survives; the + container rootfs (nix store paths) is recreated cleanly. + + **Rust counterpart**: `hive-c0re` reads `HIVE_NETWORK_ISOLATION` + and `HIVE_NETWORK_BRIDGE` from its service env and uses them in + `lifecycle::set_nspawn_flags` to configure `PRIVATE_NETWORK`, + `LOCAL_ADDRESS`, and `HOST_BRIDGE` in each container's + `nixos-containers/.conf`. See `docs/network.md` for the + full design. + ''; }; }; + + config = lib.mkMerge [ + (lib.mkIf cfg.enable { + assertions = [ + { + assertion = config.services.hyperhive.domain != null; + message = '' + services.hyperhive.network.enable = true requires + services.hyperhive.domain to be set — the resolver needs a + domain to be authoritative for. Either pin a hostname + (`services.hyperhive.domain = "example.com";`) or leave + `network.enable` at its default of false. + ''; + } + { + assertion = config.services.hyperhive.gateway.enable; + message = '' + services.hyperhive.network.enable = true requires + services.hyperhive.gateway.enable = true — the dnsmasq + resolver runs inside the hive-gateway container (single + front-door for both DNS and HTTP). Enable the gateway or + leave `network.enable` at its default of false. + ''; + } + ]; + + # Bridge interface on the host. Empty interfaces list = purely + # virtual bridge (no slave NICs attached). Per-agent veth pairs + # will join this bridge once #14 lands; at v1 it stands alone. + networking.bridges.${cfg.bridgeName}.interfaces = [ ]; + + # Host-side IP assignment on the bridge. This is what dnsmasq + # (inside the gateway container, shared host netns) binds on. + networking.interfaces.${cfg.bridgeName}.ipv4.addresses = [ + { + address = cfg.bridgeIp; + prefixLength = cfg.bridgePrefixLength; + } + ]; + + # Open the resolver port in the host firewall for traffic from + # the bridge subnet only. Other interfaces stay closed — + # external DNS-amplification surface is not exposed. + networking.firewall.interfaces.${cfg.bridgeName} = { + allowedUDPPorts = [ 53 ]; + allowedTCPPorts = [ 53 ]; + }; + }) + + # Container network isolation (#14 v1). Ships as a separate overlay + # on top of the base bridge config (which stays unconditional) so + # operators can stand the bridge + resolver up first, validate + # everything, then flip isolation on independently. + (lib.mkIf (cfg.enable && cfg.isolateContainers) { + assertions = [ + { + # Isolation without the bridge is a no-op: agents would get + # private netns but no reachable gateway. The assertion on + # `cfg.enable` above already gates the bridge, but making the + # dependency explicit here avoids confusing "bridge up, no + # isolation" vs "isolation on, no bridge" states. + assertion = cfg.enable; + message = '' + services.hyperhive.network.isolateContainers = true requires + services.hyperhive.network.enable = true (the bridge and + resolver must be running before isolation is flipped on). + ''; + } + ]; + + # IP forwarding — agents need to route through the bridge to reach + # the internet. NixOS firewall's `nat.enable` sets this too, but + # making it explicit here keeps the intent visible alongside the + # NAT rule. + boot.kernel.sysctl."net.ipv4.ip_forward" = 1; + + # NAT/masquerade: translate agent bridge IPs → host's outbound + # IP for internet-bound traffic. Without masquerade, packets from + # 10.42.0.X arrive at external servers with an RFC-1918 source + # that can't be routed back. + networking.nat = { + enable = true; + # `internalInterfaces` causes `MASQUERADE` on packets from the + # bridge leaving via any external interface. Only traffic from + # agents crosses the bridge — hive-gateway / forge / matrix + # stay on host netns and don't need NAT. + internalInterfaces = [ cfg.bridgeName ]; + }; + + # DROP traffic from the bridge subnet to host loopback addresses. + # Defence-in-depth: the c0re dashboard already binds 127.0.0.1 + # (not 0.0.0.0), so bridge-sourced traffic can't reach it via + # the bridge IP. But a misconfigured service that slips to + # 0.0.0.0 would otherwise be reachable. The DROP rule closes that + # window. Use `extraInputRules` (nftables `input` chain, priority + # 0, same ruleset as `allowedTCPPorts`) so it's processed before + # the accept rules for bridge-side DNS we added above. + # + # The rule fires only when an agent container has a bridge IP + # (i.e. after the Rust side also ships `PRIVATE_NETWORK=1`); + # until then all containers share host netns and no traffic + # originates from 10.42.0.0/24 so this is a dead letter. + networking.firewall.extraInputRules = '' + ip saddr ${cfg.bridgeIp}/${toString cfg.bridgePrefixLength} ip daddr 127.0.0.0/8 drop + ''; + + # Signal to the c0re Rust side that container isolation is + # enabled. c0re reads `HIVE_NETWORK_ISOLATION` from its service + # environment and uses it in `lifecycle::set_nspawn_flags` to set + # `PRIVATE_NETWORK=1`, `LOCAL_ADDRESS=`, and + # `HOST_BRIDGE=` in each agent's container config. + # Also forwards the bridge name + subnet so c0re can wire the + # veth without hardcoding. + # + # `systemd.services.hive-c0re.environment` is an attrset; NixOS + # merges contributions from all modules that set it, so this + # cross-module injection is idiomatic and doesn't require a + # dedicated option in hive-c0re.nix. + systemd.services.hive-c0re.environment = { + HIVE_NETWORK_ISOLATION = "1"; + HIVE_NETWORK_BRIDGE = cfg.bridgeName; + HIVE_NETWORK_SUBNET = "${cfg.bridgeIp}/${toString cfg.bridgePrefixLength}"; + }; + }) + ]; }