hyperhive/nix/host-modules/hive-network.nix
iris 07b62612b0 docs: restructure into topic subdirectories, collapse duplicated index
Per mara's go-ahead on hyperhive#3902 ("getting started is good, but
terminal rendering does not go in there i think"):

Moved 21 top-level docs/*.md files into 7 new topic subdirectories
(existing web-ui/, turn-loop/, swarm/, tools/, crates/ untouched):
  getting-started/  setup.md
  agent-lifecycle/  agent-hierarchy.md, approvals.md, persistence.md
  trust-boundary/   boundary.md, security.md
  integrations/     forge.md, matrix.md, github.md, knowledge.md
  networking/       gateway.md, network.md, snapshot-store.md
  scheduler/        jobq.md, coordinator.md, ci.md, observability.md
  process/          conventions.md, gotchas.md, pr-review-gate.md
  web-ui/           terminal-rendering.md (moved into the EXISTING dir,
                    per mara's correction to the original getting-started
                    guess -- it's UI implementation detail, not onboarding)

The physical layout now matches docs/README.md's own topical headers,
which already amounted to this taxonomy -- see the scoping comment on
the issue for the two findings that motivated this (a genuine
duplication between CLAUDE.md's old "Reading paths" list and
docs/README.md's grouped one, since drifted out of sync with each
other; and the flat layout not matching the grouping we already had).

Fixed every cross-reference this moved across the whole repo (~120
files: docs/ internal links at every depth, Rust doc comments, nix
module option docs, crate READMEs) -- verified two ways: a grep sweep
confirming zero remaining references to any old path, and a script
that resolves every markdown link in docs/**/*.md + CLAUDE.md +
README.md against the filesystem and reports anything that doesn't
exist (zero broken links).

Collapsed CLAUDE.md's "Reading paths" section (the duplicate) down to
a pointer at docs/README.md, now the single index. Rewrote
docs/README.md itself to use the new subdirectory paths and added the
one doc it was missing that CLAUDE.md's old copy had (pr-review-gate.md).

Classified all 22 docs/*.md files first via a haiku subagent (mara's
suggestion) on two axes -- proposed grouping and operator-vs-
implementation focus -- before finalizing the taxonomy; spot-checked
the report and found internal inconsistencies (its classification
table disagreed with its own summary section for a few files), so this
taxonomy is my original proposal + the one correction mara gave
directly, not a blind application of the subagent's table. The
operator-focus data it gathered is still useful for a follow-up
content pass (docs skewing 'mixed' rather than pure operator-facing),
not addressed in this PR -- structure only.

nix fmt clean, both pre-push lints clean.
2026-09-02 01:55:37 +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/networking/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/networking/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;
})
];
}