{ lib, config, ... }: let cfg = config.services.hyperhive.network; # IPv4 helpers for the DHCP-pool computation below — nix integers # are 64-bit so all /0-/32 values are safe. ipToInt = ip: builtins.foldl' (acc: x: acc * 256 + x) 0 ( map lib.strings.toIntBase10 (lib.strings.splitString "." ip) ); intToIp = n: let a = n / 16777216; b = (n - a * 16777216) / 65536; c = (n - a * 16777216 - b * 65536) / 256; d = n - a * 16777216 - b * 65536 - c * 256; in "${toString a}.${toString b}.${toString c}.${toString d}"; # 2^n via recursion (nix has no pow builtin). pow2 = n: if n == 0 then 1 else 2 * (pow2 (n - 1)); hostCount = pow2 (32 - cfg.bridgePrefixLength); # Mask off host bits to get the network base address. networkBase = builtins.bitAnd (ipToInt cfg.bridgeIp) (4294967295 - hostCount + 1); in { # Hive-internal network — host-side bridge + per-agent DNS resolver. # Always active when hyperhive is enabled: agent containers run in # private netns behind the bridge. Full design: docs/network.md. imports = [ (lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "enable" ] '' The hive network (bridge + dnsmasq resolver + private-netns isolation) is always on whenever hyperhive is enabled. Remove the setting. '') (lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "isolateContainers" ] '' Network isolation is the only mode and is always on whenever hyperhive is enabled; the shared-netns path was removed. Remove the setting. '') (lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "upstreamDns" ] '' The hive resolver always follows the host's resolvers now (dnsmasq runs on the host and reads its /etc/resolv.conf directly). Configure upstream DNS on the host itself instead. '') ]; options.services.hyperhive.network = { bridgeName = lib.mkOption { type = lib.types.str; default = "hive-br0"; example = "h0"; description = '' Name of the host-side bridge interface the hive uses for inter-container traffic. Kept short so it survives the IFNAMSIZ (15-char) cap, and prefixed so it's obviously hive-managed in `ip link` output. ''; }; bridgeIp = lib.mkOption { type = lib.types.str; default = "10.42.0.1"; example = "172.30.0.1"; description = '' IPv4 address assigned to the bridge interface on the host side. Agents use this address as their DNS server (the hive's dnsmasq binds here). Default `10.42.0.1` is in RFC 1918 space and unlikely to clash with operator's existing setup; override if a different range is already in use. ''; }; bridgePrefixLength = lib.mkOption { type = lib.types.int; default = 24; example = 16; description = '' Netmask prefix length for the bridge subnet. Default `/24` gives 254 usable per-agent addresses, enough for any single-host hive. Operator with a larger swarm or a tighter addressing scheme overrides. ''; }; exposeHostPorts = lib.mkOption { type = lib.types.listOf lib.types.port; default = [ ]; example = [ 5432 ]; description = '' 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 you run yourself — a database, a scratch HTTP endpoint, anything listening on `''${bridgeIp}:P`. **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. ''; }; # DHCP pool covering all usable host addresses on the bridge # subnet, computed from bridgeIp/bridgePrefixLength: .2 (first # usable after the .1 gateway) to .(hostCount-2) (last usable # before broadcast). All containers — agents and service # containers alike — receive their IPs dynamically from this pool; # there are no hash-derived static assignments. Consumed by the # hive's dnsmasq (hive-gateway module, host-side). dhcpRangeStart = lib.mkOption { type = lib.types.str; internal = true; readOnly = true; default = intToIp (networkBase + 2); defaultText = lib.literalMD "first usable bridge address after the gateway"; description = '' Read-only computed first address of the bridge DHCP pool. ''; }; dhcpRangeEnd = lib.mkOption { type = lib.types.str; internal = true; readOnly = true; default = intToIp (networkBase + hostCount - 2); defaultText = lib.literalMD "last usable bridge address before broadcast"; description = '' Read-only computed last address of the bridge DHCP pool. ''; }; }; config = lib.mkMerge [ # The hive network + container isolation are unconditional whenever # hyperhive is enabled: the shared-netns mode was removed, so there # is one mode (private netns behind the bridge). (lib.mkIf config.services.hyperhive.enable { # This message is only useful if an operator can actually reach # it, and an assertion competes with every eager default that # reads the value it guards: option defaults that interpolate the # domain (`forge.`, `matrix.`) throw while the # assertion list is being evaluated, so the operator sees # `cannot coerce null to a string` naming an unrelated option # instead of the sentence below. Those defaults therefore stay # total, falling back to a name under the reserved `.invalid` TLD # (RFC 2606) — a value this assertion then refuses to let out the # door, and one that fails loudly at resolution rather than # quietly working if it somehow did. assertions = [ { assertion = config.services.hyperhive.domain != null; message = '' hyperhive requires services.hyperhive.domain to be set — the hive resolver is authoritative for `` and its sub-domains, and agents reach the forge/matrix through the gateway by that domain. It is read from this hive's entry in the swarm directory, so what is actually missing is that entry: services.hyperhive.swarm.hives."" = { }; whose `domain` defaults to `.`. The assertion in ./swarm.nix names it precisely; this one is the backstop. ''; } { assertion = config.services.hyperhive.swarm.domain != null; message = '' hyperhive requires services.hyperhive.swarm.domain to be set — the DNS domain of the swarm this hive belongs to, of which this hive occupies one sub-domain. There is no fallback: a guessed value would be a wrong hostname that evaluates cleanly and deploys. Set it (`services.hyperhive.swarm.domain = "example.com";`) — with `hiveName` it also derives `services.hyperhive.domain` for you. ''; } { assertion = config.services.hyperhive.hiveName != null; message = '' hyperhive requires services.hyperhive.hiveName to be set — it is this hive's label within the swarm, and the leftmost part of the domain it is addressed by (`.`), not only a display name. Set it (`services.hyperhive.hiveName = "pr1ma";`). ''; } ]; # Virtual bridge — each agent container attaches a veth pair (isolation # is unconditional now). networking.bridges.${cfg.bridgeName}.interfaces = [ ]; # Bridge IP — the hive's dnsmasq binds here. networking.interfaces.${cfg.bridgeName}.ipv4.addresses = [ { address = cfg.bridgeIp; prefixLength = cfg.bridgePrefixLength; } ]; # DNS + DHCP on the bridge interface only — no external amplification # surface. UDP 67 is required for the dnsmasq DHCP pool: dnsmasq # receives DHCPDISCOVER via a regular UDP socket (no netfilter-bypassing # raw socket like ISC dhcpd), so without this hole the host INPUT chain # drops the broadcasts and every container falls back to IPv4LL. networking.firewall.interfaces.${cfg.bridgeName} = { allowedUDPPorts = [ 53 67 ]; allowedTCPPorts = [ 53 ]; }; }) # Container isolation overlay — now unconditional (the shared-netns # mode was removed). See docs/network.md#container-isolation. (lib.mkIf config.services.hyperhive.enable { # Agents route internet traffic via the bridge; NAT masquerades their RFC-1918 IPs. boot.kernel.sysctl."net.ipv4.ip_forward" = 1; networking.nat = { enable = true; internalInterfaces = [ cfg.bridgeName ]; }; # Defence-in-depth: DROP bridge→loopback so compromised agents can't # reach host-loopback services even via routing table leaks. networking.firewall.extraInputRules = '' ip saddr ${cfg.bridgeIp}/${toString cfg.bridgePrefixLength} ip daddr 127.0.0.0/8 drop ''; # 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` opens its own ports in its dedicated block below, # co-located with the proxies so the firewall hole + listener can't drift.) networking.firewall.interfaces.${cfg.bridgeName}.allowedTCPPorts = [ 80 443 ]; # Tells hive-c0re to pass PRIVATE_NETWORK + bridge settings to each # container. HIVE_NETWORK_SUBNET is host-bridge IP/prefix, not canonical # network address — the Rust side normalises before subnet arithmetic. systemd.services.hive-c0re.environment = { HIVE_NETWORK_ISOLATION = "1"; HIVE_NETWORK_BRIDGE = cfg.bridgeName; HIVE_NETWORK_SUBNET = "${cfg.bridgeIp}/${toString cfg.bridgePrefixLength}"; }; }) # 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 != [ ]) { networking.firewall.interfaces.${cfg.bridgeName}.allowedTCPPorts = cfg.exposeHostPorts; }) ]; }