9.8 KiB
hive-network
Host-side bridge + per-agent DNS resolver — the foundation that
makes container netns isolation safe to land. Configured via
services.hyperhive.network.*; off by default during rollout.
Why ship before netns isolation
If netns isolation lands first, agent containers lose
/etc/resolv.conf propagation from the host and DNS breaks until a
separate resolver is up. Inverting the sequence — bridge + dnsmasq
first, netns flip second — makes the flag day boring: the resolver
endpoint is already live, agents just discover it via veth instead
of shared netns.
v1 vs v2
| feature | v1 (this PR) | v2 (after netns isolation) |
|---|---|---|
| bridge interface | created on host, no slave NICs | per-agent veth pairs attach |
| dnsmasq binding | bridge IP (reachable via host loopback in shared netns) | bridge IP (reachable via veth in private netns) |
| agent container netns | shared host | private |
agent /etc/resolv.conf |
unchanged (host DNS) | nameserver <bridge-ip> |
address rules target |
<bridge-ip> (works in both modes) |
unchanged from v1 |
The address rules ship pointing at the bridge IP from v1 so the
DNS contract is fixed before any container actually depends on it
— minimises the things that flip on netns day.
Container shape (where dnsmasq lives)
Co-located in the existing hive-gateway container — single
front-door for both DNS and HTTP, saves a sibling container, single
systemd-unit / state surface to monitor. The gateway shares host
netns (privateNetwork = false) so dnsmasq's bind-interfaces
listener on bridgeIp works without any veth gymnastics today; when
agent containers flip to private netns the binding doesn't change
(it's still on the host's bridge interface).
Configuration
{
services.hyperhive = {
enable = true;
domain = "darkest.space";
network.enable = true; # opt in to bridge + DNS
network.bridgeIp = "10.42.0.1"; # default
network.upstreamDns = [ # default Cloudflare + Quad9
"1.1.1.1"
"9.9.9.9"
];
};
}
Asserts services.hyperhive.domain != null (resolver needs a domain
to be authoritative for) + services.hyperhive.gateway.enable = true (resolver lives in the gateway container).
Bridge addressing
Default subnet is 10.42.0.0/24, host-side gateway at 10.42.0.1.
RFC 1918 space, unlikely to clash with operator's existing setup;
override bridgeIp + bridgePrefixLength if a different range is
already in use. /24 gives 254 usable per-agent addresses — enough
for any single-host hive; bigger swarms or tighter addressing
schemes pick their own.
Resolver behaviour
dnsmasq is authoritative for the hive's own zones — answers
<hive-domain>, forge.<hive-domain>, matrix.<hive-domain>
queries with the bridge IP (where nginx is reachable). Everything
else gets forwarded to upstreamDns. Containers don't need to know
the upstream — they query the bridge IP and dnsmasq does the right
thing per-name.
bind-interfaces + interface = [ bridgeName "lo" ] means the
listener only accepts queries from the bridge interface (plus lo for
container health-checks). External hosts can't reach it — no
DNS-amplification surface even when the operator opens port 80 for
gateway HTTP.
resolveLocalQueries = false keeps dnsmasq out of the host's own
resolution stack — the host's resolver (systemd-resolved, plain
glibc nss, dnscrypt-proxy, etc.) keeps doing whatever the operator
configured. The hive resolver is purely for inbound queries from
agent containers.
Firewall posture
networking.firewall.interfaces.<bridge>.allowedUDPPorts = [ 53 ]
allowedTCPPorts = [ 53 ]opens the resolver on the bridge interface only. Other interfaces stay closed. The hive resolver isn't an external-facing service.
When isolateContainers = true, allowedTCPPorts is extended with
[ 80 443 ] so isolated agents can reach nginx (gateway container,
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-<P>) listens on<bridgeIp>:Pand forwards to127.0.0.1:P; Pis added to the bridge-interfaceallowedTCPPorts.
The agent then points at http://<bridgeIp>: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
agent containers from shared host netns to private netns. Set only after
enable = true is stable in production — an assertion blocks the reverse.
What the nix side does when isolateContainers = true
| effect | mechanism |
|---|---|
| IP forwarding | boot.kernel.sysctl."net.ipv4.ip_forward" = 1 |
| Internet NAT | networking.nat { enable = true; internalInterfaces = [ bridgeName ]; } — MASQUERADE on packets leaving via any external NIC |
| Loopback DROP | networking.firewall.extraInputRules — drops bridge-subnet → 127.0.0.0/8 traffic; defence-in-depth against routing table leaks |
| Gateway access | networking.firewall.interfaces.<bridge>.allowedTCPPorts = [ 80 443 ] — lets isolated agents reach nginx on the host (shared netns) |
| Forge URL | HIVE_FORGE_URL flips from http://127.0.0.1:3000 to http://forge.<domain> — agents resolve via dnsmasq, nginx proxies to forgejo |
| c0re signal | HIVE_NETWORK_ISOLATION=1, HIVE_NETWORK_BRIDGE, HIVE_NETWORK_SUBNET in systemd.services.hive-c0re.environment |
HIVE_NETWORK_SUBNET is the host-side bridge IP + prefix (e.g.
10.42.0.1/24), not the canonical network address. The Rust side
must normalise (bitwise-AND with mask) before subnet membership checks or
address arithmetic.
What the Rust side does
hive-c0re reads HIVE_NETWORK_ISOLATION and, when set, passes
PRIVATE_NETWORK=1, LOCAL_ADDRESS=<deterministic-ip>,
HOST_ADDRESS=<bridge-ip>, and HOST_BRIDGE=<bridgeName> via
lifecycle::set_nspawn_flags when creating or updating containers. Each
agent gets a deterministic IP derived from its name so the address is
reproducible across destroy/recreate. This applies uniformly to all
containers — no special case.
HOST_ADDRESS is the bridge gateway IP (the address part of
HIVE_NETWORK_SUBNET, via lifecycle::bridge_gateway_ip — taken verbatim
so a non-.1 operator override still resolves to wherever the bridge
actually lives). It is load-bearing: nixos-container's container-side
network setup only installs a default route (ip route add default via $HOST_ADDRESS) when HOST_ADDRESS is non-empty. In bridge mode the
host-side address/route setup is skipped, so writing it only affects the
container's default route — without it the container comes up with an IP
but no path off the bridge subnet (no internet, no api.anthropic.com).
How the isolated container gets its resolver
nixos-container copies the host's /etc/resolv.conf into the container
at every start. The host resolver (e.g. 127.0.0.53 from systemd-resolved,
or a LAN router) is unreachable from a private netns and isn't
authoritative for the hive's own zones, so it must be replaced with the
bridge dnsmasq (the gateway IP). Because the copy happens on every start, a
declarative environment.etc."resolv.conf" would be clobbered — so the
wiring is runtime:
hive-privdrops a marker file (/etc/hyperhive-bridge-dns, carrying the gateway IP) into the container's/etconly when isolated, removing it otherwise — so one shared container toplevel behaves correctly in both netns modes.- the
hyperhive-isolated-dnsoneshot (harness-base.nix), gated on that marker, rewrites/etc/resolv.conftonameserver <gateway-ip>at boot. It is orderedbeforethe harness (hive-ag3nt), the matrix daemon, andtea-loginso the resolver is correct before the first DNS lookup; it's an instant no-op in shared-netns mode (the marker is absent, soConditionPathExistsskips it).
Why isolation is safe: all hive-c0re communication goes
through unix domain sockets (/run/hive/mcp.sock for agent requests,
/run/hive/priv.sock for privileged ops).
These are bind-mounted into containers via the nspawn conf. UDS paths
traverse the VFS, not the network stack, so PRIVATE_NETWORK=1 does not
affect them.
The nix side also enables IP forwarding + NAT (agents reach the internet
through the host) and drops bridge-subnet → loopback traffic (defence-in-depth
against a compromised agent reaching the c0re dashboard HTTP at
127.0.0.1). Agents have no legitimate reason to reach the dashboard over
loopback — the hive-c0re admin socket is a UDS, not TCP.
Prerequisites before flipping on
- All agents must have
hyperhive.web.useUnixSocket = true. Agents that still bind TCP on0.0.0.0:<port>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 is unaffected.
Migration behaviour
Containers are destroyed and re-created when the flag flips. Agent state
under /agents/<name>/state/ is bind-mounted and survives; the container
rootfs is recreated cleanly from the nix store.
Cross-references
docs/gateway.md— vhost map + the gateway container's other duties