Compare commits

..
9 changed files with 126 additions and 88 deletions

View file

@ -33,8 +33,9 @@ in
# gateway_nginx.rs uses this to generate split location # gateway_nginx.rs uses this to generate split location
# blocks in agents.conf — static HTML/CSS/JS served from the # blocks in agents.conf — static HTML/CSS/JS served from the
# nix store directly; dynamic API paths still proxied to the # nix store directly; dynamic API paths still proxied to the
# agent daemon. nginx runs on the host, which is where this # agent daemon. The nix store is shared across nspawn
# store path already is, so it is reachable as written. # containers, so this path is reachable from inside the
# gateway container's nginx.
HIVE_AGENT_FRONTEND_DIR = "${cfg.servedFrontend}/agent"; HIVE_AGENT_FRONTEND_DIR = "${cfg.servedFrontend}/agent";
# Path to the static runtime asset tree (branding + claude # Path to the static runtime asset tree (branding + claude
# prompts). `hive_sh4re::assets::*` reads paths underneath. # prompts). `hive_sh4re::assets::*` reads paths underneath.

View file

@ -26,7 +26,9 @@ let
forgeCfg = config.services.hyperhive.swarm.forge; forgeCfg = config.services.hyperhive.swarm.forge;
networkCfg = config.services.hyperhive.network; networkCfg = config.services.hyperhive.network;
# Dashboard SPA dist, static-served by nginx. # Dashboard SPA dist, static-served by nginx. Read in OUTER scope so
# `config` is the host's (inside the container block it'd be the
# container's).
dashboardDist = "${config.services.hyperhive.c0re.servedFrontend}/dashboard"; dashboardDist = "${config.services.hyperhive.c0re.servedFrontend}/dashboard";
# Full hyperhive-themed Swagger UI dist — nginx serves this whole # Full hyperhive-themed Swagger UI dist — nginx serves this whole
@ -155,6 +157,32 @@ in
"f /var/lib/hive-gateway/conf/gateway.htpasswd 0644 hive-core hive-core - -" "f /var/lib/hive-gateway/conf/gateway.htpasswd 0644 hive-core hive-core - -"
]; ];
# ⚠️ REMOVED WITH THE CONTAINER, and each one was a workaround for the
# boundary rather than a thing nginx or dnsmasq needed:
#
# - `privateNetwork = false` — the container already shared the host
# netns, which is why nginx bound host ports and `localhost`
# upstreams reached hive-c0re. On the host that is simply true.
# - `additionalCapabilities = [ "CAP_NET_ADMIN" ]` — dnsmasq refuses
# to start with a `dhcp-range` unless it holds NET_ADMIN, and
# nspawn's bounding set dropped it for a host-netns container.
# Host root has it.
# - `networking.firewall.enable = false` — a container sharing the
# host netns would run *its* firewall.service against the HOST
# ruleset, flushing nixos-fw and deleting the nixos-nat-* chains on
# every boot. With one machine there is one firewall (below).
# - `networking.resolvconf.enable = false` + the `hive-gateway-resolv`
# path/service pair — the container's /etc/resolv.conf was a
# one-shot copy frozen at start, so a host network change left
# dnsmasq forwarding to a resolver that was gone. The whole
# watch-copy-reload machine existed to bridge two files. There is
# now one.
# - three bind mounts — /run/hive-agent, /run/hive-state, and either
# /run/hive-tls (operator cert) or /run/hive-ca (self-signed);
# those last two are mutually exclusive mkIfs, so it was never
# four. All plain host paths now.
#
# See `docs/network.md::Resolver behaviour` for the resolver history.
# ACME (Let's Encrypt) integration. nginx vhosts set # ACME (Let's Encrypt) integration. nginx vhosts set
# `enableACME = true` via the vhost builder; this provides the # `enableACME = true` via the vhost builder; this provides the
# shared ACME config (acceptTerms + email). # shared ACME config (acceptTerms + email).
@ -165,14 +193,15 @@ in
# Import the hive-CA leaf into nginx's state dir before nginx starts. # Import the hive-CA leaf into nginx's state dir before nginx starts.
# #
# 🚨 DO NOT "simplify" this into pointing nginx at the CA dir. It # 🚨 THIS LOOKS LIKE A LEFTOVER OF THE CONTAINER AND IS NOT — do not
# does TWO jobs: # "simplify" it into pointing nginx at the CA dir. It does TWO jobs:
# #
# (1) It re-modes the leaf. `hive-tls-ca` writes the key 0600 # (1) It re-modes the leaf. `hive-tls-ca` writes the key 0600
# root:root; nginx's pre-start `nginx -t` runs as the nginx # root:root; nginx's pre-start `nginx -t` runs as the nginx
# *user*, so a 0600 key fails the config test with # *user*, so a 0600 key fails the config test with
# `BIO_new_file() … Permission denied` and blocks the unit — # `BIO_new_file() … Permission denied` and blocks the unit —
# hence the 0640 root:nginx copy below. # hence the 0640 root:nginx copy below. That is a file-mode fact,
# not a namespace one, and it did not go away with the container.
# (2) It guarantees that **every cert path the nginx config names # (2) It guarantees that **every cert path the nginx config names
# exists** — which is what the swarm-services fallback at the # exists** — which is what the swarm-services fallback at the
# bottom of the script is for. nginx refuses to load a config # bottom of the script is for. nginx refuses to load a config
@ -214,10 +243,10 @@ in
# mode behind. # mode behind.
chmod 0755 ${builtins.dirOf tlsDir} chmod 0755 ${builtins.dirOf tlsDir}
chmod 0755 ${tlsDir} chmod 0755 ${tlsDir}
# Copy the leaf in. `install` writes atomically with the # Copy the host leaf in. `install` writes atomically with the
# target mode; runs as root so the 0600 root:root key written # target mode; run as root (container root == host root,
# by hive-tls-ca is readable. Key ends up root:nginx 0640 so # privateUsers=false) so the 0600 root:root host key is
# nginx-pre-start # readable. Key ends up root:nginx 0640 so nginx-pre-start
# (which runs `nginx -t` as the nginx user, not root) can # (which runs `nginx -t` as the nginx user, not root) can
# read it — a 0600 root:root key passes the master load but # read it — a 0600 root:root key passes the master load but
# fails the pre-start config test with `BIO_new_file() … # fails the pre-start config test with `BIO_new_file() …

View file

@ -1,10 +1,10 @@
# Hive-internal DNS resolver + DHCP, running on the host alongside the # Hive-internal DNS resolver + DHCP, co-located in the gateway
# gateway's nginx — single front-door for both DNS and HTTP, and no # container — single front-door for both DNS and HTTP, saves a
# container of its own. Listens on the bridge interface from # sibling container. Listens on the bridge interface from
# `services.hyperhive.network`; authoritative for the hive domain + # `services.hyperhive.network`; authoritative for the hive domain +
# sub-domains, forwards everything else upstream. Returns the # sub-domains, forwards everything else upstream. Returns the
# `services.dnsmasq` value (see ./default.nix); the DHCP pool bounds # `services.dnsmasq` value for the container config (see
# are computed by hive-network. # ./default.nix); the DHCP pool bounds are computed by hive-network.
{ {
lib, lib,
networkCfg, networkCfg,
@ -68,10 +68,14 @@
dhcp-range = "${networkCfg.dhcpRangeStart},${networkCfg.dhcpRangeEnd},1h"; dhcp-range = "${networkCfg.dhcpRangeStart},${networkCfg.dhcpRangeEnd},1h";
dhcp-leasefile = "/var/lib/dnsmasq/dnsmasq.leases"; dhcp-leasefile = "/var/lib/dnsmasq/dnsmasq.leases";
# No explicit upstream: non-hive queries follow dnsmasq's # No explicit upstream: non-hive queries follow dnsmasq's
# resolv.conf default — the host's own `/etc/resolv.conf`, so the # resolv.conf default — the gateway container's `/etc/resolv.conf`,
# hive always uses the host's resolvers and follows them live with # which nixos-container copies from the host at every start, so the
# no copy to go stale. Deliberately no fallback # hive always uses the host's resolvers. resolvconf is disabled in
# `server=`: dnsmasq queries # the container (see ./default.nix) so nothing regenerates that
# copy; the host-side `hive-gateway-resolv` path unit (also in
# ./default.nix) pushes in a fresh copy and reloads dnsmasq whenever
# the host's resolvers change, so the copy can't go stale under a
# network switch. Deliberately no fallback `server=`: dnsmasq queries
# all known upstreams in parallel, so a hardcoded public resolver # all known upstreams in parallel, so a hardcoded public resolver
# would take a share of *normal* traffic, not just fill in when the # would take a share of *normal* traffic, not just fill in when the
# host file is empty. # host file is empty.

View file

@ -28,10 +28,10 @@ in
example = 8080; example = 8080;
description = '' description = ''
TCP port the gateway listens on. Default 80 (canonical web TCP port the gateway listens on. Default 80 (canonical web
port). nginx runs on the host as root, so it can bind <1024; port). nginx inside the container binds <1024 because the
if 80 is already taken on the host (existing nginx, traefik, container's init runs as root; if 80 is already taken on the
etc.) override to an unused port like 8080 or move the host (existing nginx, traefik, etc.) override to an unused
conflicting service. port like 8080 or move the conflicting service.
''; '';
}; };
@ -40,8 +40,8 @@ in
default = "127.0.0.1"; default = "127.0.0.1";
description = '' description = ''
Host the gateway proxies non-static requests to. Defaults to Host the gateway proxies non-static requests to. Defaults to
`127.0.0.1` because nginx runs on the host itself, so loopback `127.0.0.1` because the gateway container shares the host
resolves directly to hive-c0re. netns, so loopback resolves directly to hive-c0re.
''; '';
}; };
@ -180,11 +180,11 @@ in
default = false; default = false;
example = true; example = true;
description = '' description = ''
Let the gateway's nginx obtain and renew TLS certificates Let nginx inside the gateway container obtain and renew TLS
automatically via ACME (Let's Encrypt). When enabled, each certificates automatically via ACME (Let's Encrypt). When
vhost calls out to Let's Encrypt using the HTTP-01 enabled, each vhost calls out to Let's Encrypt using the
challenge on `port` (default 80) and stores certs in the HTTP-01 challenge on `port` (default 80) and stores certs
gateway's persistent state dir on the host. inside the gateway container's persistent state dir.
Requirements: Requirements:
- `services.hyperhive.domain` must be set and publicly - `services.hyperhive.domain` must be set and publicly

View file

@ -1,9 +1,9 @@
# nginx virtual-host tree for the gateway: the `_` default server # nginx virtual-host tree for the gateway container: the `_` default
# (dashboard, per-agent routing, matrix discovery), the forge, matrix # server (dashboard, per-agent routing, matrix discovery), the forge
# and authelia sub-domain vhosts, and the Accept-header SPA map for the # and matrix sub-domain vhosts, and the Accept-header SPA map for the
# matrix GUI. Pure function — called from ./default.nix with the # matrix GUI. Pure function — called from ./default.nix inside the
# outer-scope config values as arguments; returns # container config with the outer-scope config values as arguments;
# `{ virtualHosts, appendHttpConfig }`. # returns `{ virtualHosts, appendHttpConfig }`.
{ {
lib, lib,
cfg, # services.hyperhive.gateway cfg, # services.hyperhive.gateway

View file

@ -581,22 +581,25 @@ in
]; ];
}; };
# The matrix container's resolver is the hive's dnsmasq (bound at # The matrix container's resolver is the dnsmasq that runs in the
# `bridgeIp`). Order the matrix container start after it so the # gateway container (bound at `bridgeIp`). Order the matrix
# resolver is up before tuwunel's first federation lookups. tuwunel # container start after the gateway container so the resolver is up
# boots fine without this — it configures the resolver from # before tuwunel's first federation lookups. tuwunel boots fine
# `/etc/resolv.conf` at startup and only queries on-demand (the boot # without this — it configures the resolver from `/etc/resolv.conf`
# failure this module guards against is an *empty* resolv.conf, a # at startup and only queries on-demand (the boot failure this
# parse error, not a connectivity one) — so this is robustness, not a # module guards against is an *empty* resolv.conf, a parse error,
# boot requirement. Soft `after` ordering (not `requires`) keeps the # not a connectivity one) — so this is robustness, not a boot
# matrix container's lifecycle decoupled from the resolver's. # requirement. Soft `after` ordering (not `requires`) keeps the
# # matrix container's lifecycle decoupled from the gateway's. The
# gateway always runs alongside hyperhive, so the gateway container
# unit always exists here. (Declarative `containers.<n>` →
# `container@<n>.service` — the nspawn template NixOS generates.)
# `mkMerge`, not a bare assignment: `caTrust.containerOrdering` also # `mkMerge`, not a bare assignment: `caTrust.containerOrdering` also
# sets `after`/`requires` (so the bound trust bundle exists before # sets `after`/`requires` (so the bound trust bundle exists before
# nspawn wires the mount up), and two plain assignments to the same # nspawn wires the mount up), and two plain assignments to the same
# unit would conflict rather than combine. # unit would conflict rather than combine.
systemd.services."container@hive-matrix" = lib.mkMerge [ systemd.services."container@hive-matrix" = lib.mkMerge [
{ after = [ "dnsmasq.service" ]; } { after = [ "container@hive-gateway.service" ]; }
caTrust.containerOrdering caTrust.containerOrdering
]; ];
}; };

View file

@ -46,8 +46,9 @@ in
'') '')
(lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "upstreamDns" ] '' (lib.mkRemovedOptionModule [ "services" "hyperhive" "network" "upstreamDns" ] ''
The hive resolver always follows the host's resolvers now The hive resolver always follows the host's resolvers now
(dnsmasq runs on the host and reads its /etc/resolv.conf (dnsmasq reads the gateway container's /etc/resolv.conf, the
directly). Configure upstream DNS on the host itself instead. host copy made at container start). Configure upstream DNS on
the host itself instead.
'') '')
]; ];
@ -70,8 +71,8 @@ in
example = "172.30.0.1"; example = "172.30.0.1";
description = '' description = ''
IPv4 address assigned to the bridge interface on the host IPv4 address assigned to the bridge interface on the host
side. Agents use this address as their DNS server (the hive's side. Agents use this address as their DNS server (dnsmasq
dnsmasq binds here). Default `10.42.0.1` in the gateway container binds here). Default `10.42.0.1`
is in RFC 1918 space and unlikely to clash with operator's is in RFC 1918 space and unlikely to clash with operator's
existing setup; override if a different range is already in existing setup; override if a different range is already in
use. use.
@ -122,7 +123,7 @@ in
# before broadcast). All containers — agents and service # before broadcast). All containers — agents and service
# containers alike — receive their IPs dynamically from this pool; # containers alike — receive their IPs dynamically from this pool;
# there are no hash-derived static assignments. Consumed by the # there are no hash-derived static assignments. Consumed by the
# hive's dnsmasq (hive-gateway module, host-side). # dnsmasq that runs in the gateway container (hive-gateway module).
dhcpRangeStart = lib.mkOption { dhcpRangeStart = lib.mkOption {
type = lib.types.str; type = lib.types.str;
internal = true; internal = true;
@ -211,7 +212,7 @@ in
# is unconditional now). # is unconditional now).
networking.bridges.${cfg.bridgeName}.interfaces = [ ]; networking.bridges.${cfg.bridgeName}.interfaces = [ ];
# Bridge IP — the hive's dnsmasq binds here. # Bridge IP — dnsmasq (in the gateway container) binds here.
networking.interfaces.${cfg.bridgeName}.ipv4.addresses = [ networking.interfaces.${cfg.bridgeName}.ipv4.addresses = [
{ {
address = cfg.bridgeIp; address = cfg.bridgeIp;

View file

@ -307,19 +307,17 @@ in
matrix.gatewayHost, authelia.domain}) or install the sub-CA. matrix.gatewayHost, authelia.domain}) or install the sub-CA.
''; '';
# Generate (and rotate) the hive CA + gateway leaf before anything # Generate (and rotate) the hive CA + gateway leaf before the gateway
# serves it. Idempotent: the CA is created once and reused; the leaf # container starts. Idempotent: the CA is created once and reused; the
# is re-signed on expiry under the same CA so the anchor is stable. # leaf is re-signed on expiry under the same CA so the anchor is stable.
systemd.services.hive-tls-ca = { systemd.services.hive-tls-ca = {
description = "Generate hive CA + gateway leaf TLS cert (self-signed mode)"; description = "Generate hive CA + gateway leaf TLS cert (self-signed mode)";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
# The consumer is `hive-gateway-self-signed-cert`, which copies the # Gateway nginx reads the leaf from the bind-mount, so the cert must
# leaf into the gateway's state dir at the mode nginx can read, and # exist before the container starts. Declarative nixos-containers are
# which nginx in turn `Requires=`. So this must run first or that # instances of the `container@.service` template.
# copy fails under `set -eu` and takes nginx down with it — order before = [ "container@hive-gateway.service" ];
# against the unit that reads the file. requiredBy = [ "container@hive-gateway.service" ];
before = [ "hive-gateway-self-signed-cert.service" ];
requiredBy = [ "hive-gateway-self-signed-cert.service" ];
# The issuance below needs the swarm root key on disk, and (for the # The issuance below needs the swarm root key on disk, and (for the
# services leaf) the services sub-CA it signs under. When this host # services leaf) the services sub-CA it signs under. When this host
# generates them (single-host swarm) both units must have run first; # generates them (single-host swarm) both units must have run first;
@ -468,19 +466,21 @@ in
# `hive-tls-ca` only re-signs at service activation (boot/rebuild); a # `hive-tls-ca` only re-signs at service activation (boot/rebuild); a
# long-uptime host would otherwise let a 30-day leaf lapse silently. # long-uptime host would otherwise let a 30-day leaf lapse silently.
# This service re-signs the leaf directly (not by bouncing hive-tls-ca) # This service re-signs the leaf directly (not by bouncing hive-tls-ca)
# and propagates the new leaf to nginx when the file actually changed. # and propagates the new leaf into the running gateway container when
# the file actually changed.
# #
# Propagation mechanism: nginx serves a *copy* of the leaf, written by # Propagation mechanism: nginx in the gateway container serves a *copy*
# `hive-gateway-self-signed-cert` at the mode nginx can read. Re-signing # of the leaf written by `hive-gateway-self-signed-cert` (which runs at
# the source therefore changes nothing on its own — the copy has to be # container start). A host-side `systemctl -M hive-gateway` call
# remade and nginx reloaded, which is what the two calls below do. # triggers the re-import + reload, mirroring how hive-c0re reloads the
# gateway after each agents.conf write. A path unit *inside* the
# container cannot do this: IN_MOVED_TO from an atomic rename on the
# host does not propagate across the nspawn mount-namespace boundary.
# #
# ⚠️ Neither call is `|| true`: a swallowed propagation failure means # `|| true` on propagation so a stopped gateway never fails the unit —
# the leaf rotates on disk while nginx keeps serving the old copy # its next boot will import the already-rotated leaf anyway.
# until it expires, with this unit reporting success the whole time.
# A failure here must fail the timer.
systemd.services.hive-tls-resign = { systemd.services.hive-tls-resign = {
description = "Re-sign the gateway TLS leaf and reload nginx"; description = "Re-sign the gateway TLS leaf and propagate it into the gateway container";
# hive-tls-ca must have run first so the CA key exists before we try # hive-tls-ca must have run first so the CA key exists before we try
# to re-sign under it. On first boot `Persistent=true` on the weekly # to re-sign under it. On first boot `Persistent=true` on the weekly
# timer fires immediately; without this ordering the resign could race # timer fires immediately; without this ordering the resign could race
@ -534,9 +534,9 @@ in
after="$(sha256sum "$leaf" "$svcleaf" 2>/dev/null || true)" after="$(sha256sum "$leaf" "$svcleaf" 2>/dev/null || true)"
if [ "$before" != "$after" ]; then if [ "$before" != "$after" ]; then
echo "gateway leaf rotated re-importing and reloading nginx" echo "gateway leaf rotated propagating into hive-gateway"
systemctl restart hive-gateway-self-signed-cert.service systemctl -M hive-gateway restart hive-gateway-self-signed-cert.service || true
systemctl reload nginx.service systemctl -M hive-gateway reload nginx.service || true
else else
echo "gateway leaf unchanged (already up to date)" echo "gateway leaf unchanged (already up to date)"
fi fi

View file

@ -4,9 +4,9 @@
# the agents on one host, this owns what is true across hives. # the agents on one host, this owns what is true across hives.
# #
# Serves HTTP over a unix socket rather than a TCP port: the gateway's # Serves HTTP over a unix socket rather than a TCP port: the gateway's
# nginx is the only intended client, it runs on this same host and so # nginx is the only intended client, it reaches the socket through a
# reaches the socket by path, and a socket that is never bound to an # bind-mount, and a socket that is never bound to an address cannot be
# address cannot be reached from off-host by mistake. # reached from off-host by mistake.
{ {
pkgs, pkgs,
lib, lib,
@ -103,13 +103,13 @@ in
the socket itself is `0666` (nginx runs as another user, and the socket itself is `0666` (nginx runs as another user, and
`connect(2)` needs write), exactly as hive-c0re publishes the `connect(2)` needs write), exactly as hive-c0re publishes the
per-agent sockets. What keeps that safe is that the directory holds per-agent sockets. What keeps that safe is that the directory holds
exactly one socket and is traverse-only (`0751`) for everyone else. one socket and is bind-mounted into one container. Moving this path
Moving this path under a directory that carries anything else under a directory that carries anything else `/run/hyperhive`,
`/run/hyperhive`, which holds the host admin socket, above all which holds the host admin socket, above all hands whatever else
exposes whatever else lives there to the same reachability. lives there to every consumer that mounts it.
Changing this therefore means re-checking what else lives in the Changing this therefore means re-checking the gateway bind-mount,
new directory, not just the daemon. not just the daemon.
''; '';
}; };
}; };
@ -147,9 +147,9 @@ in
# the socket path without being able to list the directory. Same # the socket path without being able to list the directory. Same
# shape (and same reason) as hive-c0re's runtime dir. # shape (and same reason) as hive-c0re's runtime dir.
RuntimeDirectoryMode = "0751"; RuntimeDirectoryMode = "0751";
# Preserved across restarts so the path never vanishes from under a # Preserved across restarts so the bind-mount source never vanishes
# running nginx. The daemon unlinks a stale socket on start, which # from under a running gateway container. The daemon unlinks a stale
# is what makes preservation safe. # socket on start, which is what makes preservation safe.
RuntimeDirectoryPreserve = "yes"; RuntimeDirectoryPreserve = "yes";
StateDirectory = "swarm-controller"; StateDirectory = "swarm-controller";