From 5a83c40dca7a9648cb585d32475c77af854f1d59 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 5 Aug 2026 21:16:25 +0200 Subject: [PATCH] feat(nix): issue a swarm-services leaf, and renew it with the hive one The swarm's service names cannot go on the hive leaf: the hive CA is name-constrained to the hive domain and those names are siblings of it. So there is a second leaf, signed by the services sub-CA. signLeafScript is parameterised rather than duplicated -- same ceremony, different issuer and names -- so the two cannot drift in how they are built. The name list itself is derived once, as a read-only swarm.serviceDomains, and read by both the sub-CA that name-constrains those names and the leaf that carries them as SANs: two modules each assembling the list is how they stop agreeing. The renewal unit is the point of this commit as much as the leaf. hive-tls-resign now knows about both, because a leaf that first-boot issuance creates and weekly renewal ignores looks perfect for its entire validity and then expires with no warning -- the failure is invisible until it is total. The freshness test became a function over a leaf rather than a check of one, so adding a third leaf is a line rather than a rewrite. The services leaf is skipped where the sub-CA is absent: it exists only where the swarm CA is autoconfigured, and on a hive whose certs come from its operator the correct state is no leaf, not a stale one. Also drops a comment that documented signLeafScript's old signature from above an unrelated binding. --- nix/host-modules/hive-tls.nix | 108 +++++++++++++++++++++++++--------- nix/host-modules/swarm-ca.nix | 23 ++------ nix/host-modules/swarm.nix | 28 +++++++++ 3 files changed, 114 insertions(+), 45 deletions(-) diff --git a/nix/host-modules/hive-tls.nix b/nix/host-modules/hive-tls.nix index 553e5251..b3e3abe4 100644 --- a/nix/host-modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -10,6 +10,10 @@ let gatewayCfg = config.services.hyperhive.gateway; swarmCaCfg = config.services.hyperhive.swarm.ca; domain = hyperhiveCfg.domain; + # Derived once in ./swarm.nix and read here + in ./swarm-ca.nix, so + # the names this leaf carries as SANs and the names the sub-CA is + # constrained to cannot disagree. + swarmServiceDomains = hyperhiveCfg.swarm.serviceDomains; # 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 @@ -20,13 +24,6 @@ let # 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. @@ -97,28 +94,36 @@ let -addext "keyUsage=critical,keyCertSign,cRLSign" ''; + # Sign one leaf. Parameterised rather than hardcoded to `gateway.*` + # because there are now two: the hive's own leaf, issued by the hive + # CA, and the swarm-services leaf, issued by the services sub-CA that + # ./swarm-ca.nix maintains. Same ceremony, different issuer and names + # — and one script means the two cannot drift in how they are built. + # + # $1 stateDir $2 basename $3 CN $4 SAN list $5 issuer cert $6 issuer key 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")" + base="$2" + cn="$3" + sans="$4" + ca="$5" + cak="$6" + leaf="$d/$base.pem" + leafk="$d/$base-key.pem" + csr="$(mktemp "$d/$base.csr.XXXXXX")" + ext="$(mktemp "$d/$base.ext.XXXXXX")" + only="$(mktemp "$d/$base.leaf.XXXXXX")" trap 'rm -f "$csr" "$ext" "$only"' EXIT openssl req -newkey rsa:4096 -nodes -sha256 \ -keyout "$leafk" -out "$csr" \ - -subj "/CN=${domain}" + -subj "/CN=$cn" # 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 'subjectAltName=%s\n' "$sans" printf 'basicConstraints=critical,CA:FALSE\n' printf 'keyUsage=critical,digitalSignature,keyEncipherment\n' printf 'extendedKeyUsage=serverAuth\n' @@ -140,6 +145,35 @@ let chmod 0600 "$leafk" chmod 0644 "$leaf" ''; + + # The hive's own leaf: signed by the hive CA, covering the hive domain + # and its sub-domains. + signHiveLeaf = '' + ${signLeafScript} "$d" gateway ${lib.escapeShellArg domain} \ + ${lib.escapeShellArg "DNS:${domain},DNS:forge.${domain},DNS:matrix.${domain},DNS:*.${domain}"} \ + "$d/ca.pem" "$d/ca-key.pem" + ''; + + # The swarm-services leaf: signed by the services sub-CA, covering the + # swarm's service names. Those are *siblings* of the hive domain, not + # children, so the hive CA is name-constrained out of them and cannot + # sign this however its SAN list is written. + # + # Skipped when the sub-CA isn't on disk: it only exists where the + # swarm CA is autoconfigured, and a hive that gets its certs from its + # operator has nothing for this to do. + signServicesLeaf = lib.optionalString (swarmServiceDomains != [ ]) '' + servicesCa=${lib.escapeShellArg "${swarmCaCfg.stateDir}/services-ca.pem"} + servicesCaKey=${lib.escapeShellArg "${swarmCaCfg.stateDir}/services-ca-key.pem"} + if [ -s "$servicesCa" ] && [ -s "$servicesCaKey" ]; then + ${signLeafScript} "$d" swarm-services \ + ${lib.escapeShellArg (builtins.head swarmServiceDomains)} \ + ${lib.escapeShellArg (lib.concatMapStringsSep "," (n: "DNS:${n}") swarmServiceDomains)} \ + "$servicesCa" "$servicesCaKey" + else + echo "no swarm-services sub-CA at $servicesCa — skipping the services leaf" + fi + ''; in { # Host-side TLS trust root for the self-signed gateway mode. @@ -318,7 +352,8 @@ in 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" + ${signHiveLeaf} + ${signServicesLeaf} fi # --- Trust bundle: what a consumer must TRUST, as opposed to @@ -389,22 +424,39 @@ in set -euo pipefail d=${lib.escapeShellArg cfg.stateDir} leaf="$d/gateway.pem" + # ⚠️ EVERY leaf this host issues must be listed here. A leaf that + # first-boot issuance creates and this unit does not know about + # looks perfect for its entire validity and then expires with no + # warning — the failure is invisible until it is total. + svcleaf="$d/swarm-services.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. + # Re-sign only when a leaf is within half its validity of expiry. + # The weekly cadence catches this window well before one 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" + + fresh() { # a leaf is fresh if it exists and is not near expiry + [ -s "$1" ] && openssl x509 -in "$1" -noout -checkend "$halflife" >/dev/null 2>&1 + } + + # The services leaf is only expected where the sub-CA exists; + # elsewhere its absence is the correct state, not a stale leaf. + want_svc=${if swarmServiceDomains == [ ] then "0" else "1"} + if [ ! -s ${lib.escapeShellArg "${swarmCaCfg.stateDir}/services-ca.pem"} ]; then + want_svc=0 + fi + + if fresh "$leaf" && { [ "$want_svc" = 0 ] || fresh "$svcleaf"; }; then + echo "leaves valid for more than half their 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)" + echo "a leaf is missing or near expiry — re-signing under the current CAs" + before="$(sha256sum "$leaf" "$svcleaf" 2>/dev/null || true)" - ${signLeafScript} "$d" + ${signHiveLeaf} + ${signServicesLeaf} - after="$(sha256sum "$leaf" 2>/dev/null || true)" + after="$(sha256sum "$leaf" "$svcleaf" 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 diff --git a/nix/host-modules/swarm-ca.nix b/nix/host-modules/swarm-ca.nix index ef623f48..31102718 100644 --- a/nix/host-modules/swarm-ca.nix +++ b/nix/host-modules/swarm-ca.nix @@ -51,23 +51,12 @@ let else "hyperhive"; - # The names the services sub-CA is allowed to issue for, read from the - # service options rather than spelled out here: a constraint list that - # doesn't track its own consumers is a constraint that silently stops - # covering one. - # - # Sorted + deduplicated because this list is also the *rotation - # trigger* below — an unstable order would re-issue the CA on every - # rebuild, and a CA that churns is one nothing can pin. - serviceDomains = lib.sort (a: b: a < b) ( - lib.unique ( - lib.filter (d: d != null && d != "") [ - hyperhiveCfg.swarm.forge.domain - hyperhiveCfg.swarm.matrix.gatewayHost - hyperhiveCfg.swarm.authelia.domain - ] - ) - ); + # Derived once in ./swarm.nix, read here and by ./hive-tls.nix: the + # CA that name-constrains these and the leaf that carries them as SANs + # must agree exactly, and two modules each assembling the list is how + # they stop agreeing. It is also this unit's *rotation trigger* below, + # which is why the ordering is stable there rather than here. + serviceDomains = hyperhiveCfg.swarm.serviceDomains; in { options.services.hyperhive.swarm.ca = { diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 41add894..ab4d706a 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -153,9 +153,37 @@ in ''; }; + options.services.hyperhive.swarm.serviceDomains = lib.mkOption { + type = lib.types.listOf lib.types.str; + readOnly = true; + internal = true; + description = '' + Read-only: the public hostnames of the swarm's own services, in a + stable sorted order. Second derived set alongside `peerHives`, and + here for the same reason — the CA that name-constrains these and + the leaf that carries them as SANs must agree exactly, and two + modules each assembling the list is how they stop agreeing. + + Sorted and deduplicated deliberately: consumers compare this list + against what they issued last time to decide whether to re-issue, + so an unstable order would churn a certificate that other things + are meant to pin. + ''; + }; + config = { services.hyperhive.swarm.peerHives = lib.filterAttrs (name: _: name != cfg.hiveName) swarmCfg.hives; + services.hyperhive.swarm.serviceDomains = lib.sort (a: b: a < b) ( + lib.unique ( + lib.filter (d: d != null && d != "") [ + swarmCfg.forge.domain + swarmCfg.matrix.gatewayHost + swarmCfg.authelia.domain + ] + ) + ); + assertions = [ { # Guarded on `hiveName != null` so the required-hiveName