{ lib, config, pkgs, ... }: let cfg = config.services.hyperhive.tls; hyperhiveCfg = config.services.hyperhive; gatewayCfg = config.services.hyperhive.gateway; domain = hyperhiveCfg.domain; # The host-managed hive CA is the trust anchor for self-signed mode. # It is only stood up when the gateway actually serves a self-signed # cert: the gateway must be in self-signed mode. `domain` is required # (asserted in hive-network.nix), so the leaf SANs always have a # domain to derive from. The self-signed condition is the gateway # module's single source of truth (`gateway.useSelfSigned`): true when # neither an operator cert (`tls.certDir`) nor ACME is set. active = hyperhiveCfg.enable && gatewayCfg.useSelfSigned; in { # Host-side TLS trust root for the self-signed gateway mode. # # `gateway.selfSignedTls` historically generated a *bare* self-signed # leaf inside the gateway container at first boot. A bare leaf is its # own trust anchor, so every regeneration is a new anchor and every # consumer (agents, federation peers) would have to re-trust on each # rotation — and a runtime-generated, in-container cert can't be wired # into an agent's build-time trust store at all. # # This module moves the anchor to a long-lived **hive CA** held on the # host. The gateway serves a **leaf** signed by that CA (via the # existing `tls.certDir` bind-mount path); agents and federation peers # trust the *CA* once, and leaf rotation never re-breaks them. See # `docs/gateway.md` ("Self-signed TLS") and issue-tracker discussion of # agent web-UI reachability. options.services.hyperhive.tls = { stateDir = lib.mkOption { type = lib.types.str; default = "/var/lib/hive-tls"; description = '' Host directory holding the hive CA + gateway leaf cert for the self-signed gateway mode. `ca.pem` (the anchor agents and federation peers trust), `ca-key.pem` (0600, never leaves the host), `gateway.pem` / `gateway-key.pem` (the leaf the gateway container bind-mounts and nginx serves). Persistent so the CA survives reboots — re-deriving it would re-break every consumer. ''; }; caValidityDays = lib.mkOption { type = lib.types.int; default = 7300; description = '' Validity window of the hive CA in days (default ~20y). Kept long and well beyond `leafValidityDays` so the CA outlives many leaf rotations — the whole point of the CA is to be a stable anchor that consumers trust once. The CA is regenerated only if missing or already expired. ''; }; leafValidityDays = lib.mkOption { type = lib.types.int; default = 30; description = '' Validity window of the gateway leaf cert in days (default 30). Short-lived by design — ahead of the CA/Browser-Forum's move toward ~47-day max lifetimes — which bounds the blast radius of a leaf-key compromise. The leaf is re-signed by the (stable) CA when it is missing or near expiry; because it shares the CA anchor, a rotation does not disturb consumer trust. Agents and federation peers validate against the CA, not browser CA/B-forum limits. The weekly `hive-tls-resign` timer re-signs the leaf once it is within half its validity of expiry and propagates the new leaf into the running gateway, so a long-uptime host renews automatically without a reboot. ''; }; }; config = lib.mkIf active { # Generate (and rotate) the hive CA + gateway leaf before the gateway # container starts. Idempotent: the CA is created once and reused; the # leaf is re-signed on expiry under the same CA so the anchor is stable. systemd.services.hive-tls-ca = { description = "Generate hive CA + gateway leaf TLS cert (self-signed mode)"; wantedBy = [ "multi-user.target" ]; # Gateway nginx reads the leaf from the bind-mount, so the cert must # exist before the container starts. Declarative nixos-containers are # instances of the `container@.service` template. before = [ "container@hive-gateway.service" ]; requiredBy = [ "container@hive-gateway.service" ]; path = [ pkgs.openssl ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; UMask = "0077"; # Pin the journal identity (else it's the `script` store-path wrapper). SyslogIdentifier = "hive-tls-ca"; }; script = '' set -euo pipefail d=${lib.escapeShellArg cfg.stateDir} install -d -m 0755 "$d" ca="$d/ca.pem" cak="$d/ca-key.pem" leaf="$d/gateway.pem" leafk="$d/gateway-key.pem" # --- CA: generate once, reuse across leaf rotations. Regenerate # only if missing or already expired (checkend 0). A new CA means # every consumer must re-trust, so the leaf is dropped to force a # re-sign under the fresh CA. if [ ! -s "$ca" ] || [ ! -s "$cak" ] \ || ! openssl x509 -in "$ca" -noout -checkend 0 >/dev/null 2>&1; then echo "generating fresh hive CA at $ca" openssl req -x509 -newkey rsa:4096 -nodes -sha256 \ -days ${toString cfg.caValidityDays} \ -keyout "$cak" -out "$ca" \ -subj "/CN=hive-ca ${domain}" \ -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ -addext "keyUsage=critical,keyCertSign,cRLSign" chmod 0600 "$cak" chmod 0644 "$ca" rm -f "$leaf" "$leafk" fi # --- Leaf: (re)sign when missing or within 30 days of expiry, # always under the current (stable) CA. if [ ! -s "$leaf" ] || [ ! -s "$leafk" ] \ || ! openssl x509 -in "$leaf" -noout -checkend 2592000 >/dev/null 2>&1; then echo "signing fresh gateway leaf at $leaf" csr="$(mktemp "$d/gateway.csr.XXXXXX")" ext="$(mktemp "$d/leaf.ext.XXXXXX")" trap 'rm -f "$csr" "$ext"' EXIT openssl req -newkey rsa:4096 -nodes -sha256 \ -keyout "$leafk" -out "$csr" \ -subj "/CN=${domain}" # printf (not a heredoc) so the ext-file lines carry no leading # whitespace once nix has stripped the indented-string indent. { printf 'subjectAltName=DNS:%s,DNS:forge.%s,DNS:matrix.%s,DNS:*.%s\n' \ ${lib.escapeShellArg domain} ${lib.escapeShellArg domain} \ ${lib.escapeShellArg domain} ${lib.escapeShellArg domain} printf 'basicConstraints=critical,CA:FALSE\n' printf 'keyUsage=critical,digitalSignature,keyEncipherment\n' printf 'extendedKeyUsage=serverAuth\n' } > "$ext" openssl x509 -req -in "$csr" -CA "$ca" -CAkey "$cak" \ -CAcreateserial -days ${toString cfg.leafValidityDays} -sha256 \ -extfile "$ext" -out "$leaf" chmod 0600 "$leafk" chmod 0644 "$leaf" fi ''; }; # Weekly re-sign of the gateway leaf so short-lived leaves renew # without depending on a reboot. # # `hive-tls-ca` only re-signs at service activation (boot/rebuild); a # 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) # and propagates the new leaf into the running gateway container when # the file actually changed. # # Propagation mechanism: nginx in the gateway container serves a *copy* # of the leaf written by `hive-gateway-self-signed-cert` (which runs at # container start). A host-side `systemctl -M hive-gateway` call # triggers the re-import + reload, mirroring how hive-c0re reloads the # gateway after each agents.conf write. A path unit *inside* the # container was tried first but does not work: IN_MOVED_TO from an # atomic rename on the host does not propagate across the nspawn # mount-namespace boundary. # # `|| true` on propagation so a stopped gateway never fails the unit — # its next boot will import the already-rotated leaf anyway. systemd.services.hive-tls-resign = { 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 # to re-sign under it. On first boot `Persistent=true` on the weekly # timer fires immediately; without this ordering the resign could race # the CA initialisation and fail with "no such file" on the CA key. after = [ "hive-tls-ca.service" ]; path = [ pkgs.openssl pkgs.coreutils pkgs.systemd ]; serviceConfig = { Type = "oneshot"; UMask = "0077"; SyslogIdentifier = "hive-tls-resign"; }; script = '' set -euo pipefail d=${lib.escapeShellArg cfg.stateDir} ca="$d/ca.pem" cak="$d/ca-key.pem" leaf="$d/gateway.pem" leafk="$d/gateway-key.pem" # Re-sign only when the leaf is within half its validity of expiry. # The weekly cadence catches this window well before the leaf lapses. halflife=$(( ${toString cfg.leafValidityDays} * 86400 / 2 )) if [ -s "$leaf" ] && \ openssl x509 -in "$leaf" -noout -checkend "$halflife" >/dev/null 2>&1; then echo "gateway leaf valid for more than half its lifetime — no resign needed" exit 0 fi echo "gateway leaf missing or near expiry — re-signing under current CA" before="$(sha256sum "$leaf" 2>/dev/null || true)" csr="$(mktemp "$d/gateway.csr.XXXXXX")" ext="$(mktemp "$d/leaf.ext.XXXXXX")" trap 'rm -f "$csr" "$ext"' EXIT openssl req -newkey rsa:4096 -nodes -sha256 \ -keyout "$leafk" -out "$csr" \ -subj "/CN=${domain}" { printf 'subjectAltName=DNS:%s,DNS:forge.%s,DNS:matrix.%s,DNS:*.%s\n' \ ${lib.escapeShellArg domain} ${lib.escapeShellArg domain} \ ${lib.escapeShellArg domain} ${lib.escapeShellArg domain} printf 'basicConstraints=critical,CA:FALSE\n' printf 'keyUsage=critical,digitalSignature,keyEncipherment\n' printf 'extendedKeyUsage=serverAuth\n' } > "$ext" openssl x509 -req -in "$csr" -CA "$ca" -CAkey "$cak" \ -CAcreateserial -days ${toString cfg.leafValidityDays} -sha256 \ -extfile "$ext" -out "$leaf" chmod 0600 "$leafk" chmod 0644 "$leaf" after="$(sha256sum "$leaf" 2>/dev/null || true)" if [ "$before" != "$after" ]; then echo "gateway leaf rotated — propagating into hive-gateway" systemctl -M hive-gateway restart hive-gateway-self-signed-cert.service || true systemctl -M hive-gateway reload nginx.service || true else echo "gateway leaf unchanged (already up to date)" fi ''; }; systemd.timers.hive-tls-resign = { description = "Weekly gateway-leaf re-sign and propagation"; wantedBy = [ "timers.target" ]; timerConfig = { # Run weekly; Persistent=true fires a missed run on next boot if # the timer was not active (e.g. the host was off on the scheduled # day), preventing a dormant timer from letting the leaf lapse. OnCalendar = "weekly"; Persistent = true; }; }; # Signal the hive-c0re lifecycle that a hive CA exists: it bind-mounts # this file (read-only, the CA cert ONLY — never the key) into each # agent container so agents + their tools can trust the gateway's # self-signed leaf, and the meta flake wires the per-agent trust # bundle. Only the `ca.pem` path is exposed; `ca-key.pem` stays on the # host (an agent that could read it could mint trusted certs). systemd.services.hive-c0re.environment.HIVE_TLS_CA_PATH = "${cfg.stateDir}/ca.pem"; }; }