hyperhive/nix/host-modules/hive-gateway/default.nix
atlas fcdff04b23 refactor(nix): move the matrix host options under services.hyperhive.swarm
Second slice of the swarm-global service consolidation, same shape as
the forge move: the operator-facing host options become
services.hyperhive.swarm.matrix.*, and one mkRenamedOptionModule on the
namespace carries the whole subtree (nested gui.* included), so there is
no leaf list to forget an entry from.

The rename lives in hive-matrix.nix, the module that declares the
options, so each service's migration stays independent of its siblings.

The per-agent hyperhive.matrix.{enable,url} and hyperhive.matrixAccounts
are a different namespace -- a client pointer at the service, not the
service -- and deliberately do not move.
2026-08-05 13:45:09 +02:00

420 lines
20 KiB
Nix

# Single nginx in front of every hyperhive web surface — dashboard,
# per-agent UIs (sub-path), forge + matrix (sub-domain), .well-known
# delegations — plus the hive-internal dnsmasq resolver, co-located in
# the same `hive-gateway` container (shared host netns, state-free).
# Full vhost map + discovery flow + design rationale in
# `docs/gateway.md`. Layout: ./options.nix (option declarations),
# ./vhosts.nix (the nginx virtual-host tree), ./error-pages.nix
# (styled static pages), ./dnsmasq.nix (resolver + DHCP config).
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.gateway;
hyperhiveDomain = config.services.hyperhive.domain;
matrixCfg = config.services.hyperhive.swarm.matrix;
forgeCfg = config.services.hyperhive.swarm.forge;
networkCfg = config.services.hyperhive.network;
# 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";
# Full hyperhive-themed Swagger UI dist — nginx serves this whole
# tree straight from the store at /api/docs/, no hive-c0re fallback
# (see vhosts.nix's `swaggerUiLocations` and
# nix/packages/swagger-ui-{dist,theme}.nix). Own option under this
# module (not c0re's) — hive-c0re has no relationship to it.
swaggerUiTheme = cfg.swaggerUiTheme;
# Self-signed TLS is the implicit floor: when neither an operator cert
# (`tls.certDir`) nor ACME (`tls.acme.enable`) is configured, the gateway
# generates + serves a hive-CA-signed leaf (see hive-tls.nix). There is no
# explicit toggle and no http-only mode — matrix discovery requires https,
# so the gateway always terminates TLS.
# `cfg.useSelfSigned` (options.nix) is the derived single source of truth.
useSelfSigned = cfg.useSelfSigned;
in
{
imports = [ ./options.nix ];
config = lib.mkIf config.services.hyperhive.enable {
assertions = [
{
assertion = !(cfg.tls.acme.enable && cfg.tls.certDir != null);
message = ''
services.hyperhive.gateway.tls.acme.enable = true and
tls.certDir are mutually exclusive. Pick one TLS mode.
'';
}
{
assertion = !cfg.tls.acme.enable || cfg.tls.acme.email != null;
message = ''
services.hyperhive.gateway.tls.acme.enable = true requires
services.hyperhive.gateway.tls.acme.email to be set
Let's Encrypt needs a contact address for the ACME account.
'';
}
];
# Ensure bind-mount sources exist at host boot before the gateway
# container's first start. nspawn would auto-create missing dirs;
# tmpfiles rules make the intent explicit and cover the fresh-boot
# window before c0re has run.
#
# /run/hive-agent — per-agent UDS socket dir, written by c0re's
# set_nspawn_flags when agents start. Owned by `hive-core` (the
# unprivileged coordinator user): c0re does the
# `create_dir_all(/run/hive-agent/<name>)` itself, so a root-owned
# parent would EACCES on the very first agent create on a fresh host
# (hive-priv only chowns the subdir afterwards, it doesn't make it).
# /var/lib/hyperhive — hyperhive state dir, created by c0re on
# first run. Also pre-seed agents.conf with an empty-but-valid
# header so nginx can start + include the file before c0re writes
# its first real content (f = create-if-absent, no overwrite).
systemd.tmpfiles.rules = [
# Must stay in step with the identical rule hive-priv generates into
# /etc/tmpfiles.d/hyperhive-agents.conf — the two used to declare
# different owners for this path.
"d /run/hive-agent 0755 hive-core hive-core - -"
"d /var/lib/hyperhive 0755 root root - -"
"d /var/lib/hyperhive/gateway 0755 root root - -"
"f /var/lib/hyperhive/gateway/agents.conf 0644 root root - # Generated by hive-c0re do not edit.\n"
# Pre-create the htpasswd file so nginx can open it even before any
# users have been added. An empty file causes all auth checks to
# return 401 (no valid credentials), which is the correct no-users
# behaviour. `f` = create-if-absent, never overwrite.
"f /var/lib/hyperhive/gateway/gateway.htpasswd 0644 root root - -"
];
# Keep the gateway's `/etc/resolv.conf` in step with the host's.
#
# nixos-container does `cp --remove-destination /etc/resolv.conf
# "$root/etc/resolv.conf"` in its start script, and nspawn's
# `--resolv-conf=auto` copies rather than binds for a writable,
# host-netns container like this one. Both are one-shot: systemd-nspawn(1)
# says outright that "no further propagation of configuration is
# generally done after the one-time early initialization (this is
# because the file is usually updated through copying and renaming)".
#
# So the gateway's copy is frozen at container start. dnsmasq has no
# explicit upstream (see ./dnsmasq.nix) and follows that file, which
# means a host network change — new router, new DHCP lease, laptop
# moving between networks — leaves dnsmasq forwarding to a resolver
# that is gone, and every non-hive lookup from every agent hangs. The
# agents' own resolvers point at the static bridge IP and never go
# stale, which is exactly why the symptom presents as "the gateway
# needs a kick".
#
# Hence: watch on the host, push into the container, reload dnsmasq.
# `reload` is `kill -HUP $MAINPID` (the upstream dnsmasq unit's
# ExecReload), so dnsmasq re-reads its upstream list and drops its
# cache without severing anything — nginx never notices. Why this has
# to run host-side, and why a file bind-mount is worse than the copy:
# `docs/network.md::Resolver behaviour`.
systemd.paths.hive-gateway-resolv = {
description = "Watch the host's /etc/resolv.conf for the hive-gateway container";
wantedBy = [ "multi-user.target" ];
# Arm the watch before anything configures the network, so the very
# first DHCP-driven resolv.conf write of the boot is caught too —
# that's the "gateway came up while DHCP was still settling" case.
# Inert if nothing pulls network-pre.target into the transaction.
before = [ "network-pre.target" ];
pathConfig = {
# PathChanged also watches the parent directory, so openresolv's
# atomic rename-over lands as IN_MOVED_TO on /etc and triggers —
# a watch on the inode alone would die with the replaced file.
PathChanged = "/etc/resolv.conf";
Unit = "hive-gateway-resolv.service";
};
};
systemd.services.hive-gateway-resolv = {
description = "Sync the host's resolvers into hive-gateway and reload dnsmasq";
# Also run once per gateway start, to catch a host resolver change
# that happened while the container was down. Deliberately NOT
# ordered after network-online.target: pulling that target in on
# every resolv.conf change risks blocking the sync behind a
# wait-online timeout on hosts where nothing else reaches it. The
# boot race is closed by arming the path unit early instead.
wantedBy = [ "container@hive-gateway.service" ];
after = [ "container@hive-gateway.service" ];
path = [
pkgs.systemd
pkgs.coreutils
pkgs.gnugrep
];
serviceConfig = {
Type = "oneshot";
SyslogIdentifier = "hive-gateway-resolv";
};
script = ''
set -euo pipefail
src=/etc/resolv.conf
# Marker lives on tmpfs, so the first sync after every host boot
# always goes through rather than trusting a stale comparison.
marker=/run/hive-gateway/resolv.synced
# No nameserver line means either a mid-rewrite snapshot or a host
# with no DNS at all. In both cases the gateway's existing copy is
# the best information available keep it and wait for the next
# event, instead of pushing a file that resolves nothing.
if ! grep -q '^[[:space:]]*nameserver[[:space:]]' "$src"; then
echo "host resolv.conf has no nameserver line keeping the gateway's current copy"
exit 0
fi
if [ -e "$marker" ] && cmp -s "$src" "$marker"; then
echo "host resolvers unchanged since last sync nothing to do"
exit 0
fi
# A stopped gateway needs no push: its next start copies the
# current host file itself.
if ! systemctl is-active --quiet container@hive-gateway.service; then
echo "hive-gateway not running its next start copies the current file itself"
exit 0
fi
# `machinectl copy-to` writes through the container's own mount
# namespace, so this stays correct regardless of how the container
# assembles /etc (e.g. if system.etc.overlay is ever turned on)
# unlike poking at the rootfs path from the host side.
machinectl copy-to hive-gateway "$src" /etc/resolv.conf --force
# Not fatal: dnsmasq polls resolv.conf for mtime changes on its own,
# so a failed reload costs a second of staleness, not correctness.
if ! systemctl -M hive-gateway reload dnsmasq.service; then
echo "dnsmasq reload failed (not up yet?) copy is in place, its own poll will pick it up"
fi
mkdir -p "$(dirname "$marker")"
install -m 0644 "$src" "$marker"
echo "synced host resolvers into hive-gateway and reloaded dnsmasq"
'';
};
containers.hive-gateway = {
autoStart = true;
ephemeral = false;
# Share host netns — nginx then binds host-level ports directly,
# `localhost` upstream resolution reaches hive-c0re without any
# port-forward dance, and the firewall config below is the only
# layer that matters.
privateNetwork = false;
# dnsmasq refuses to start once a dhcp-range is configured unless it
# holds CAP_NET_ADMIN (DNS-only mode doesn't need it). Private-network
# containers retain NET_ADMIN implicitly, but this container shares the
# host netns (above), so nspawn's default bounding set drops it — grant
# it explicitly. Note this is NET_ADMIN over the *host* netns; the
# gateway container is trusted infra (it already terminates TLS and
# fronts every vhost), so no new trust boundary is crossed.
additionalCapabilities = [ "CAP_NET_ADMIN" ];
# Bind-mount the per-agent socket dir so nginx inside the gateway
# container can `connect(2)` to the UDS upstreams.
# Read-only (we just connect; harness writes the socket inside
# the agent's own container). Host-side dir is pre-created by a
# tmpfiles rule so nspawn always finds a source at boot.
bindMounts."/run/hive-agent" = {
hostPath = "/run/hive-agent";
isReadOnly = true;
};
# Bind-mount ONLY the gateway-specific subdir of the hyperhive
# state dir. Scoped to /var/lib/hyperhive/gateway/ rather than
# the whole parent so the gateway container can't read forge
# tokens or other files that may live at the parent level.
# c0re writes agents.conf under this subdir and triggers an nginx
# reload from the host via systemd-run after each write.
# Pre-created by a tmpfiles rule.
bindMounts."/run/hive-state" = {
hostPath = "/var/lib/hyperhive/gateway";
isReadOnly = true;
};
# Operator-provided TLS cert dir (e.g. Let's Encrypt / ACME).
# Only mounted when `tls.certDir` is set; when it is, the self-signed
# floor is off (so the `/run/hive-ca` mount below is absent). nginx
# reads cert + key from `/run/hive-tls/<certName>` and `<keyName>`.
bindMounts."/run/hive-tls" = lib.mkIf (cfg.tls.certDir != null) {
hostPath = cfg.tls.certDir;
isReadOnly = true;
};
# Self-signed mode: the host `hive-tls-ca` service generates a hive
# CA + a leaf signed by it under `services.hyperhive.tls.stateDir`.
# Bind-mount that dir read-only so the in-container import service
# (below) can copy the leaf into nginx's state dir with the right
# owner/mode. Source files: `gateway.pem` + `gateway-key.pem`.
bindMounts."/run/hive-ca" = lib.mkIf useSelfSigned {
hostPath = config.services.hyperhive.tls.stateDir;
isReadOnly = true;
};
config =
{ pkgs, ... }:
let
tlsDir = "/var/lib/hive-gateway/tls";
# TLS cert + key paths inside the container.
# - self-signed (default): imported hive-CA-signed leaf in the
# persistent state dir.
# - tls.certDir set: operator-provided cert bind-mounted at /run/hive-tls.
tlsCert =
if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.certName}" else "${tlsDir}/cert.pem";
tlsKey =
if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.keyName}" else "${tlsDir}/key.pem";
nginxTree = import ./vhosts.nix {
inherit
lib
cfg
forgeCfg
matrixCfg
hyperhiveDomain
dashboardDist
swaggerUiTheme
tlsCert
tlsKey
;
errorPages = import ./error-pages.nix { inherit pkgs; };
};
in
{
system.stateVersion = "26.05";
# This container shares the host netns, so its own
# firewall.service would run against the HOST ruleset: flush
# the nixos-fw chains, rebuild them from this container's
# (empty) port list, and delete the host's nixos-nat-* chains
# — wiping the bridge DHCP/DNS holes and the agents' NAT on
# every container boot. The host firewall owns all filtering;
# never run one in here.
networking.firewall.enable = false;
# Keep the host-copied /etc/resolv.conf intact. nixos-container
# copies the host's file in at every container start, but
# resolvconf's host-tracking mode then regenerates it — to an
# empty file, since the host file doesn't cross the boundary
# after start (the same failure the matrix container hit).
# With resolvconf off, nothing in here touches the copy: nginx's
# own lookups (ACME) and dnsmasq's follow-the-host upstream
# default (see ./dnsmasq.nix) both read the host's resolvers.
# Keeping it stale-free is the host's job — see the
# `hive-gateway-resolv` path unit above.
networking.resolvconf.enable = false;
# ACME (Let's Encrypt) integration. nginx vhosts set
# `enableACME = true` via the vhost builder; this provides the
# shared ACME config (acceptTerms + email). The gateway
# container has shared host netns so outbound ACME requests
# work without extra routing config. Certs are stored in the
# container's persistent state (`ephemeral = false`).
security.acme = lib.mkIf cfg.tls.acme.enable {
acceptTerms = true;
defaults.email = cfg.tls.acme.email;
};
# Import the host-generated leaf cert before nginx starts.
# The hive CA + gateway leaf are generated on the HOST by
# `hive-tls-ca` (see `hive-tls.nix`) and bind-mounted read-only
# at `/run/hive-ca`; this service copies the leaf into nginx's
# state dir with the owner/mode nginx needs, rather than reading
# the bind-mount directly (the host key is 0600 root:root and a
# cross-namespace bind-mount can't be relaxed in place). nginx
# `Requires=` this via `requiredBy`, so it refuses to start until
# the copy succeeds. ALWAYS runs (no ConditionPathExists) and is
# idempotent — necessary to reconcile broken state from prior
# failed boots (a 0700 dir from a stale UMask, a truncated copy
# from an interrupted oneshot, etc.). The leaf covers the bare
# hive domain plus `forge.`, `matrix.` and `*.${hyperhiveDomain}`
# so all sub-domains validate under the same cert + the hive CA.
# See `docs/gateway.md` ("Self-signed TLS").
systemd.services.hive-gateway-self-signed-cert = lib.mkIf useSelfSigned {
description = "Import host-generated TLS leaf for hive-gateway";
wantedBy = [ "multi-user.target" ];
before = [ "nginx.service" ];
requiredBy = [ "nginx.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
# Pin the journal identity (else it's the `script` store-path wrapper).
SyslogIdentifier = "hive-gateway-self-signed-cert";
};
path = [ pkgs.coreutils ];
script = ''
set -eu
mkdir -p ${tlsDir}
# 0755 on BOTH the cert dir and its parent so the nginx
# user can traverse the full path. The parent
# `/var/lib/hive-gateway` lands at 0700 by default (systemd
# StateDirectory / mkdir umask depending on which service
# created it first), which on its own blocks traversal.
# Re-applied every boot in case a prior run left a tighter
# mode behind.
chmod 0755 ${builtins.dirOf tlsDir}
chmod 0755 ${tlsDir}
# Copy the host leaf in. `install` writes atomically with the
# target mode; run as root (container root == host root,
# privateUsers=false) so the 0600 root:root host key is
# readable. Key ends up root:nginx 0640 so nginx-pre-start
# (which runs `nginx -t` as the nginx user, not root) can
# read it a 0600 root:root key passes the master load but
# fails the pre-start config test with `BIO_new_file()
# Permission denied`, blocking the unit. Cert is world-read.
install -m 0644 /run/hive-ca/gateway.pem ${tlsCert}
install -m 0640 -g nginx /run/hive-ca/gateway-key.pem ${tlsKey}
'';
};
# nginx reload is triggered from the HOST side by hive-c0re
# via `systemctl -M hive-gateway reload nginx` after each
# agents.conf write — letting systemd resolve the nginx binary
# path avoids exit-203 EXEC failures. A path unit watching the
# bind-mounted file inside the container does not work: an
# IN_MOVED_TO from an atomic rename on the host does not
# propagate across the nspawn mount-namespace boundary. The
# host-side trigger is the correct approach.
services.nginx = {
enable = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
inherit (nginxTree) appendHttpConfig virtualHosts;
};
services.dnsmasq = import ./dnsmasq.nix {
inherit
lib
networkCfg
forgeCfg
matrixCfg
hyperhiveDomain
;
};
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [
cfg.port
# The gateway always terminates TLS (self-signed floor), so
# `httpsPort` is always opened alongside the plain-http `port`.
cfg.httpsPort
];
};
# `/etc/hosts` entries for local dev — bare hive domain + any
# sub-domain modules that are on. `lib.unique` dedupes if any
# sub-domain happens to equal another. See `docs/gateway.md`
# ("Local dev").
networking.hosts = lib.mkIf cfg.localHostsEntry {
"127.0.0.1" = lib.unique (
[ hyperhiveDomain ]
++ lib.optional (config.services.hyperhive.swarm.forge.behindGateway or false
) config.services.hyperhive.swarm.forge.domain
++ lib.optional (matrixCfg.enable && matrixCfg.gatewayHost != null) matrixCfg.gatewayHost
);
};
};
}