hyperhive/nix/host-modules/hive-network.nix
atlas 83c0e4b4bf require network isolation, deleting the residual non-isolated branch
Per mara on #3725: the on/off toggle is removed, and required env vars
unset lead to a crash. HIVE_NETWORK_ISOLATION is gone from
hive-network.nix -- it was the toggle.

Validation happens once at daemon startup rather than per container.
The variables are process-global, so a bad value breaks every container
rather than one: failing at boot gives a single diagnostic naming the
bad value, and cannot reach a state where some containers were
configured before it was noticed.

Option<NetworkIsolation> collapses to NetworkIsolation through the wire
type, client and helper, which deletes the branch instead of leaving it
unreachable. serde(default) is dropped on that field deliberately: a
request omitting isolation is now rejected rather than defaulting to a
container sharing the host's network namespace.

What this replaces was a silent security downgrade. Of the four ways
into the old fallback, two logged nothing at all -- a container came up
without isolation and the journal agreed it was fine.

Doc comments that still described the removed branch are updated
(argus's note on #3723 scoped that to this issue). The hive-priv one is
a minimal edit inside the block #3723 rewrites; de-splicing is that
PR's job.
2026-08-30 03:32:08 +02:00

283 lines
11 KiB
Nix

{
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.<domain>`, `matrix.<domain>`) 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 `<hive-domain>` 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."<hiveName>" = { };
whose `domain` defaults to `<hiveName>.<swarm.domain>`. 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
(`<hiveName>.<swarm.domain>`), 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.<domain>`, 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_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 `<bridgeIp>: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;
})
];
}