{ lib, config, pkgs, ... }: let cfg = config.services.hyperhive.tls; hyperhiveCfg = config.services.hyperhive; gatewayCfg = config.services.hyperhive.gateway; swarmCaCfg = config.services.hyperhive.swarm.ca; 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; # The leaf-signing action shared by the boot-time `hive-tls-ca` # generation and the weekly `hive-tls-resign` renewal: fresh key + # CSR, SAN ext-file, sign under the (stable) CA, tighten modes. # Takes the TLS state dir as `$1`; each caller keeps its own # when-to-sign condition. The leaf covers the bare hive domain plus # `forge.`, `matrix.` and `*.` so all sub-domains validate # under the same cert + the hive CA. # How this hive's CA comes into existence when it is missing — and it # is one of exactly two things, chosen by config rather than by what # happens to be on disk. # # Issuing a sub-CA requires the swarm root's PRIVATE key, so it can # only happen where that key legitimately lives: the single-host # deployment that `swarm.ca.autoConfigure` describes. A host cannot # infer that it is that host — a swarm's services and its hives can # sit anywhere — so with the flag off this hive self-signs exactly as # it always has, and an operator who wants it in the hierarchy # installs the CA themselves. Falling back to self-signed rather than # failing keeps a plain hive working out of the box; what it loses is # membership of a swarm's trust, which is the correct thing to lose # for a hive nobody has federated. caGenScript = if swarmCaCfg.autoConfigure then '' # The root should exist — `swarm-ca.service` runs before this and # is required by it. If it doesn't, something upstream failed and # signing with a half-provisioned root would be worse than stopping. if [ ! -s "$root" ] || [ ! -s "$rootk" ]; then echo "swarm.ca.autoConfigure is set but there is no root CA key at $rootk" >&2 echo "(swarm-ca.service should have generated it) — refusing to issue a hive CA." >&2 exit 1 fi echo "issuing fresh hive CA at $ca under the swarm root" cacsr="$(mktemp "$d/ca.csr.XXXXXX")" caext="$(mktemp "$d/ca.ext.XXXXXX")" trap 'rm -f "$cacsr" "$caext"' EXIT openssl req -newkey rsa:4096 -nodes -sha256 \ -keyout "$cak" -out "$cacsr" \ -subj "/CN=hive-ca ${domain}" # printf (not a heredoc) so the ext-file lines carry no leading # whitespace once nix has stripped the indented-string indent. { printf 'basicConstraints=critical,CA:TRUE,pathlen:0\n' printf 'keyUsage=critical,keyCertSign,cRLSign\n' printf 'subjectKeyIdentifier=hash\n' printf 'authorityKeyIdentifier=keyid:always\n' # The constraint is the point of the hierarchy, not a # flourish: without it a leaked hive CA mints any name in # the swarm, and it is verifiers that enforce this, not our # good behaviour. The IP exclusions are not redundant — a # DNS constraint says nothing about an iPAddress SAN, and a # name type nobody constrained is a name type this CA is # unconstrained for. printf 'nameConstraints=critical,permitted;DNS:%s,excluded;IP:0.0.0.0/0.0.0.0,excluded;IP:0:0:0:0:0:0:0:0/0:0:0:0:0:0:0:0\n' \ ${lib.escapeShellArg domain} } > "$caext" openssl x509 -req -in "$cacsr" -CA "$root" -CAkey "$rootk" \ -CAcreateserial -days ${toString cfg.caValidityDays} -sha256 \ -extfile "$caext" -out "$ca" rm -f "$cacsr" "$caext" trap - EXIT '' else '' echo "generating fresh self-signed 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" ''; signLeafScript = pkgs.writeShellScript "hive-tls-sign-leaf" '' set -euo pipefail d="$1" ca="$d/ca.pem" cak="$d/ca-key.pem" leaf="$d/gateway.pem" leafk="$d/gateway-key.pem" csr="$(mktemp "$d/gateway.csr.XXXXXX")" ext="$(mktemp "$d/leaf.ext.XXXXXX")" only="$(mktemp "$d/gateway.leaf.XXXXXX")" trap 'rm -f "$csr" "$ext" "$only"' 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 "$only" # nginx serves this file verbatim, so it must carry the leaf AND its # issuer: the hive CA is an intermediate under the swarm root, and a # client that anchors on the root cannot build the middle of the # chain by itself. Agents anchor on the hive CA directly and # validated either way — the appended cert is what makes a swarm # peer, or anything else holding only the root, work. # Leaf first: both `ssl_certificate` and the `openssl x509 -in` # expiry checks read the first cert in the file. cat "$only" "$ca" > "$leaf" chmod 0600 "$leafk" chmod 0644 "$leaf" ''; in { # Host-side TLS trust root for the self-signed gateway mode. # # A bare self-signed leaf would be its own trust anchor, so every # regeneration would be 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. # # So the issuer is a long-lived **hive CA** held on the host. The # gateway serves a **leaf** signed by that CA (via the `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"). # # That CA is self-signed by default. Under # `swarm.ca.autoConfigure` — the all-on-one-host case — it is instead # an intermediate issued under the swarm root (./swarm-ca.nix owns the # why) and name-constrained to this hive's domain, so it stays the # anchor agents pin while a peer holding only the root can validate # everything this hive serves. Either way an existing CA is left # alone; see the issuance comment below. 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" ]; # The issuance below needs the swarm root key on disk. When this # host generates it (single-host swarm) that unit must have run; # when the operator provides it there is no unit to wait for, so # the dependency is conditional rather than a unit that exists and # does nothing. after = lib.optional swarmCaCfg.autoConfigure "swarm-ca.service"; requires = lib.optional swarmCaCfg.autoConfigure "swarm-ca.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" root=${lib.escapeShellArg "${swarmCaCfg.stateDir}/root.pem"} rootk=${lib.escapeShellArg "${swarmCaCfg.stateDir}/root-key.pem"} prev="$d/ca-previous.pem" marker="$d/.swarm-ca-adopted" # --- Adoption: a hive whose CA predates the swarm root. # # Runs ONLY where this host also owns the root (`autoConfigure`), # because adoption invalidates an anchor that consumers already # trust and they refresh on their own schedule. On one box that # schedule is knowable; across hosts it is not, so there the # operator does it and this unit only says so, loudly. # # Guarded by a marker rather than by the state of the world: the # marker's ABSENCE is the trigger, so this fires once per hive # instead of re-deciding every activation. That is the difference # between a migration and a kicking machine. if [ -s "$root" ] && [ -s "$ca" ] && [ ! -e "$marker" ] \ && ! openssl verify -CAfile "$root" "$ca" >/dev/null 2>&1; then ${ if swarmCaCfg.autoConfigure then '' echo "adopting the swarm CA: $ca does not chain to $root" >&2 # Keep the old CA as an anchor across the overlap. Consumers # read the bundle, so adoption is additive before it is # subtractive — agents pick up new trust only when their # container restarts, which is a window even on one host. cp "$ca" "$prev" chmod 0644 "$prev" rm -f "$ca" "$cak" touch "$marker"'' else '' echo "hive-tls: this hive's CA does not chain to the swarm root." >&2 echo " hive CA: $ca" >&2 echo " swarm root: $root" >&2 echo "Adopting the hierarchy is not automatic here: it invalidates an" >&2 echo "anchor that peers and agents on OTHER hosts still trust, and they" >&2 echo "refresh on their own schedule — only you know when that is safe." >&2 echo "To adopt: rm $ca $cak && systemctl restart hive-tls-ca.service" >&2 echo "To keep the current CA deliberately: touch $marker" >&2 exit 1'' } fi # --- CA: generated once and reused across leaf rotations, in # whichever of the two shapes `caGenScript` selected. # Regenerated 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. # # An existing CA is never re-rooted here. A hive predating the # swarm root carries a self-signed `ca.pem`, and swapping it for # a swarm-issued one would break every consumer already trusting # it — including agents, whose trust is refreshed only when their # container restarts. Adopting the hierarchy on such a hive is # therefore an operator step (drop the CA, let this unit # re-issue), which is also what keeps this change non-disruptive # to hives that never adopt it. if [ ! -s "$ca" ] || [ ! -s "$cak" ] \ || ! openssl x509 -in "$ca" -noout -checkend 0 >/dev/null 2>&1; then ${caGenScript} 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" ${signLeafScript} "$d" fi # --- Trust bundle: what a consumer must TRUST, as opposed to # `ca.pem`, which is what this host SIGNS with. The two were the # same file while the hive CA was self-signed, and stopped being # the same file the moment it became an intermediate: openssl # refuses to end a chain at a trusted cert that isn't # self-signed (that's what `-partial_chain` is for), so an # agent's curl handed only `ca.pem` fails with "unable to get # issuer certificate". Verified in both directions before this # was written — rustls and Go accept a trusted intermediate, # which is what makes the breakage partial and easy to miss. # # Consumers therefore trust hive CA + swarm root; on a hive that # still has a self-signed CA the bundle is just that CA, so the # consumer side needs no condition at all. Trusting the root is # also the point of the hierarchy — it is what lets a peer hive # validate without being hand-pinned. # # Written IN PLACE, never renamed into position: containers bind # -mount this file, and a bind mount follows the inode. A # rename would leave every consumer holding the old one. # `ca-previous.pem` rides along after an adoption for the same # reason: a consumer that has not restarted yet is still pinned to # the old anchor, and dropping it the instant the new one exists # would break exactly the hosts the migration was supposed to # spare. Removing it is a deliberate later step, not this unit's # call — "long enough" is a deployment fact. # # Built as an explicit anchor ARRAY with `if` guards, not # `[ -s f ] && anchors+=(f)`: under `set -e` an && list whose test # fails IS a failing command and kills the unit, so the tidier # form would break precisely on the hive where the optional file # is legitimately absent. The `|| true` patch for that is worse — # it would also swallow a real failure to read the hive CA. bundle="$d/trust-bundle.pem" anchors=("$ca") if [ -s "$prev" ]; then anchors+=("$prev"); fi if [ -s "$root" ]; then anchors+=("$root"); fi cat "''${anchors[@]}" > "$bundle" chmod 0644 "$bundle" ''; }; # 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 cannot do this: 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} leaf="$d/gateway.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)" ${signLeafScript} "$d" 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, public certs ONLY — never a 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. It is the ANCHOR bundle rather than `ca.pem` for the reason # spelled out where the bundle is written above; no key path is ever # exposed (an agent that could read one could mint trusted certs). systemd.services.hive-c0re.environment.HIVE_TLS_CA_PATH = "${cfg.stateDir}/trust-bundle.pem"; }; }