hyperhive/nix/host-modules/hive-network.nix
atlas fc3bd2a26c docs(network): drop the otel reasoning instead of restating it
Review feedback from mara ("does the otel info even belong in there?") and
argus: `docs/observability.md` already states twice that the collector's
bridge port is contributed automatically and that neither line is needed for
hyperhive's own telemetry. A second copy of that reasoning in the option
description is a third place to drift from — and drift is what produced the
misleading example this PR started out fixing.

So remove the explanation rather than relocate it. With no otel content in
the option at all there is nothing to keep in sync, and observability.md
stays the one place that owns the otel-specific reasoning.

`example = [ 4318 ]` goes too: 4318 is the OTLP port, so the example still
pointed at otel without naming it.

Also fixes the same confusion one doc over, since it is this PR's subject:
observability.md's own "Network access" section used `otel.endpoint` as the
example for pointing something *else* at a host-local service, immediately
above a warning that you do not need it for otel. It now uses a generic port
and names the reason loopback is wrong from inside a container. While there,
the bridge is `hive-br0`, not `hvbr0` — the only occurrence in the tree.

Refs #3402
2026-08-19 02:04:57 +02:00

284 lines
12 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_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 `<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;
})
];
}