327 lines
14 KiB
Nix
327 lines
14 KiB
Nix
{
|
|
lib,
|
|
config,
|
|
...
|
|
}:
|
|
let
|
|
cfg = config.services.hyperhive.network;
|
|
in
|
|
{
|
|
# Hive-internal network — host-side bridge + per-agent DNS resolver.
|
|
# Containers stay on shared host netns at v1; this module stands the
|
|
# bridge + resolver up so the endpoint is in place before network
|
|
# isolation flips containers to private netns. Full design: docs/network.md.
|
|
|
|
options.services.hyperhive.network = {
|
|
enable = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = config.services.hyperhive.enable;
|
|
defaultText = lib.literalExpression "config.services.hyperhive.enable";
|
|
example = false;
|
|
description = ''
|
|
**DEPRECATED — ignored.** The hive network (bridge + dnsmasq
|
|
resolver + private-netns isolation) is now always on whenever
|
|
hyperhive is enabled; setting this to `false` warns and has no
|
|
effect. Retained as a no-op so existing configs eval; will be
|
|
removed in a future release.
|
|
|
|
The network requires `services.hyperhive.domain` to be set — the
|
|
dnsmasq resolver is authoritative for `<hive-domain>` and its
|
|
sub-domains. A bridge interface (`bridgeName`) appears on the host
|
|
with `bridgeIp` assigned, the hive-gateway container runs a dnsmasq
|
|
on that IP, and each agent container runs in a private netns with a
|
|
veth pair on the bridge.
|
|
'';
|
|
};
|
|
|
|
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. Becomes the DNS server address agents point at (and
|
|
the upstream the gateway proxies to once netns isolation
|
|
lands). 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.
|
|
'';
|
|
};
|
|
|
|
upstreamDns = lib.mkOption {
|
|
type = lib.types.listOf lib.types.str;
|
|
default = [
|
|
"1.1.1.1"
|
|
"9.9.9.9"
|
|
];
|
|
example = [
|
|
"192.168.1.1"
|
|
"8.8.8.8"
|
|
];
|
|
description = ''
|
|
Upstream DNS servers dnsmasq forwards non-hive queries to.
|
|
Defaults to Cloudflare + Quad9. Override for operators on
|
|
private networks who need a specific resolver (corporate
|
|
DNS, pi-hole, etc.). The hive resolver itself stays
|
|
authoritative for `<hive-domain>` and its sub-domains
|
|
regardless of upstream choice.
|
|
'';
|
|
};
|
|
|
|
exposeHostPorts = lib.mkOption {
|
|
type = lib.types.listOf lib.types.port;
|
|
default = [ ];
|
|
example = [ 4318 ];
|
|
description = ''
|
|
TCP ports on the host's loopback (`127.0.0.1`) to expose to agent
|
|
containers at the bridge IP (`bridgeIp`). For each port `P`, a
|
|
socket-activated `systemd-socket-proxyd` listens on
|
|
`''${bridgeIp}:P` and forwards to `127.0.0.1:P`, and `P` is opened
|
|
on the bridge-interface firewall.
|
|
|
|
Use this to let agents reach a host-local service that only binds
|
|
loopback — e.g. an OpenTelemetry collector for
|
|
`services.hyperhive.otel.endpoint`. The agent points at
|
|
`http://''${bridgeIp}:P` (default `http://10.42.0.1:P`).
|
|
|
|
This does NOT weaken the bridge→loopback DROP rule (defence-in-depth):
|
|
agents never reach `127.0.0.0/8`; they connect to the bridge IP and
|
|
the host's own proxy process dials loopback. The exposed port is
|
|
reachable by EVERY agent on the bridge subnet (same as DNS/gateway),
|
|
so only expose services that are safe for any agent to reach.
|
|
'';
|
|
};
|
|
|
|
isolateContainers = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
example = true;
|
|
description = ''
|
|
**DEPRECATED — ignored.** Network isolation is now the only mode and
|
|
is always on whenever hyperhive is enabled; the shared-netns path was
|
|
removed. This option is retained as a no-op so existing configs eval;
|
|
setting it to `false` warns and has no effect. It will be removed in
|
|
a future release.
|
|
|
|
Each agent container gets a dedicated veth
|
|
pair attached to `bridgeName` and a deterministic IP from
|
|
the bridge subnet. The bridge (already up when `enable = true`)
|
|
becomes the sole routed path between the host and agent
|
|
containers.
|
|
|
|
The host-side nix effect (this option) is:
|
|
- Sets `HIVE_NETWORK_ISOLATION=1` in the c0re service env so
|
|
the Rust lifecycle knows to pass `--private-network` +
|
|
bridge settings when creating/updating containers.
|
|
- Enables IP forwarding + NAT so agents can reach the internet
|
|
through the host.
|
|
- Adds a firewall rule DROP'ing traffic from the bridge subnet
|
|
to the host's loopback addresses — defence-in-depth so a
|
|
compromised agent can't reach the c0re dashboard (already
|
|
bound to 127.0.0.1) or other host-loopback services even if
|
|
the routing table somehow leaks.
|
|
- Allows HTTP/HTTPS (80/443) traffic from the bridge subnet to
|
|
the host so agents can reach the gateway container (shared
|
|
host netns, proxies the operator's per-agent UI).
|
|
|
|
**Prerequisite**: all agents must have
|
|
`hyperhive.web.useUnixSocket = true` before enabling isolation.
|
|
Agents that still bind TCP on `0.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 still works regardless.
|
|
|
|
**Migration**: containers are destroyed and re-created when
|
|
the network isolation flag flips. Operator state under
|
|
`/agents/<name>/state/` is bind-mounted and survives; the
|
|
container rootfs (nix store paths) is recreated cleanly.
|
|
|
|
**Rust counterpart**: `hive-c0re` reads `HIVE_NETWORK_ISOLATION`
|
|
and `HIVE_NETWORK_BRIDGE` from its service env and uses them in
|
|
`lifecycle::set_nspawn_flags` to configure `PRIVATE_NETWORK`,
|
|
`LOCAL_ADDRESS`, and `HOST_BRIDGE` in each container's
|
|
`nixos-containers/<name>.conf`. See `docs/network.md` for the
|
|
full design.
|
|
'';
|
|
};
|
|
};
|
|
|
|
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). `network.enable` and
|
|
# `isolateContainers` are kept as deprecated no-op options (see the
|
|
# warnings block below) so existing configs that set them still eval.
|
|
(lib.mkIf config.services.hyperhive.enable {
|
|
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. Pin a hostname
|
|
(`services.hyperhive.domain = "example.com";`).
|
|
'';
|
|
}
|
|
];
|
|
|
|
# Virtual bridge — each agent container attaches a veth pair (isolation
|
|
# is unconditional now).
|
|
networking.bridges.${cfg.bridgeName}.interfaces = [ ];
|
|
|
|
# Bridge IP — dnsmasq (in the gateway container) binds here.
|
|
networking.interfaces.${cfg.bridgeName}.ipv4.addresses = [
|
|
{
|
|
address = cfg.bridgeIp;
|
|
prefixLength = cfg.bridgePrefixLength;
|
|
}
|
|
];
|
|
|
|
# DNS only on the bridge interface — no external amplification surface.
|
|
networking.firewall.interfaces.${cfg.bridgeName} = {
|
|
allowedUDPPorts = [ 53 ];
|
|
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-loopback port exposure: one socket-activated systemd-socket-proxyd
|
|
# per `exposeHostPorts` entry, listening on the bridge IP and forwarding to
|
|
# the host's loopback. This is how agents reach a host-local service (e.g. a
|
|
# dev OTEL collector on 127.0.0.1) without weakening the bridge→loopback
|
|
# DROP rule above — agents connect to the bridge IP, and the host's own
|
|
# proxy process is what dials 127.0.0.1. See docs/network.md.
|
|
(lib.mkIf (config.services.hyperhive.enable && cfg.exposeHostPorts != [ ]) {
|
|
# Open the proxied ports on the bridge firewall HERE — same block + same
|
|
# gate as the proxies — so the firewall hole and the listener are always
|
|
# created together (never one without the other). Merges with the
|
|
# [ 80 443 ] gateway ports defined above. Independent of the deprecated
|
|
# isolateContainers toggle: isolation is unconditional, so this works
|
|
# under network isolation (the only mode).
|
|
networking.firewall.interfaces.${cfg.bridgeName}.allowedTCPPorts = cfg.exposeHostPorts;
|
|
|
|
systemd.sockets = lib.listToAttrs (
|
|
map (
|
|
p:
|
|
lib.nameValuePair "hive-hostport-${toString p}" {
|
|
description = "Host-loopback proxy socket for port ${toString p} (bridge→127.0.0.1)";
|
|
wantedBy = [ "sockets.target" ];
|
|
socketConfig.ListenStream = "${cfg.bridgeIp}:${toString p}";
|
|
}
|
|
) cfg.exposeHostPorts
|
|
);
|
|
|
|
systemd.services = lib.listToAttrs (
|
|
map (
|
|
p:
|
|
lib.nameValuePair "hive-hostport-${toString p}" {
|
|
description = "Host-loopback proxy for port ${toString p} (bridge→127.0.0.1)";
|
|
requires = [ "hive-hostport-${toString p}.socket" ];
|
|
after = [ "hive-hostport-${toString p}.socket" ];
|
|
serviceConfig = {
|
|
ExecStart = "${config.systemd.package}/lib/systemd/systemd-socket-proxyd 127.0.0.1:${toString p}";
|
|
SyslogIdentifier = "hive-hostport-${toString p}";
|
|
# Hardened userspace TCP relay. AF_INET/AF_INET6 for the upstream
|
|
# loopback dial; AF_UNIX only for systemd-socket-proxyd's sd_notify
|
|
# (the listen fd itself is inherited via LISTEN_FDS, so the relay
|
|
# never socket()s its own listener). DynamicUser keeps it unprivileged.
|
|
DynamicUser = true;
|
|
NoNewPrivileges = true;
|
|
ProtectSystem = "strict";
|
|
ProtectHome = true;
|
|
PrivateTmp = true;
|
|
RestrictAddressFamilies = [
|
|
"AF_INET"
|
|
"AF_INET6"
|
|
"AF_UNIX"
|
|
];
|
|
};
|
|
}
|
|
) cfg.exposeHostPorts
|
|
);
|
|
})
|
|
|
|
# Deprecation surface for the removed toggles. Both options are kept so
|
|
# existing configs that set them to `true` still eval cleanly; setting
|
|
# either to `false` no longer does anything (network + isolation are
|
|
# unconditional now), so warn rather than silently ignore.
|
|
{
|
|
# Only warn when hyperhive itself is enabled — otherwise `cfg.enable`
|
|
# defaults to `false` (tracking `hyperhive.enable`) and we'd fire a
|
|
# spurious deprecation warning on a host that doesn't run hyperhive.
|
|
warnings = lib.optionals config.services.hyperhive.enable (
|
|
lib.optional (!cfg.enable) ''
|
|
services.hyperhive.network.enable = false is deprecated and ignored
|
|
— the hive network is now always on (private-netns isolation is the
|
|
only mode). Remove the setting.
|
|
''
|
|
++ lib.optional (!cfg.isolateContainers) ''
|
|
services.hyperhive.network.isolateContainers = false is deprecated
|
|
and ignored — network isolation is now the only mode and is always
|
|
on. Remove the setting.
|
|
''
|
|
);
|
|
}
|
|
];
|
|
}
|