From 2d2f16406fdaae7c2b74710a861ea6e83271c84a Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 18 Aug 2026 21:43:07 +0200 Subject: [PATCH] fix(#3462): re-sign a leaf when it stops covering the configured names Expiry was the only re-sign trigger, so a leaf signed when the name set was smaller stayed valid -- and wrong -- for its whole lifetime. Adding a service to swarm.serviceDomains reissues the sub-CA (its own .names reconciliation) but nothing regenerated the leaf nginx actually serves, which left the previous commit's config change unable to fix anything on a hive whose gateway leaf was not near expiry. covers() reads the DNS names back out of the certificate rather than a sidecar file: the pem is what nginx serves, and a bookkeeping file drifts from it the moment a leaf is replaced by hand. Applied to both leaves -- the hive leaf has the same defect if the hive domain ever changes. --- nix/host-modules/hive-tls.nix | 40 ++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/nix/host-modules/hive-tls.nix b/nix/host-modules/hive-tls.nix index 1e2087e0..6179f823 100644 --- a/nix/host-modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -514,6 +514,39 @@ in [ -s "$1" ] && openssl x509 -in "$1" -noout -checkend "$halflife" >/dev/null 2>&1 } + # Expiry is not the only way a leaf goes wrong. A leaf signed + # when the configured name set was smaller stays valid for its + # whole lifetime while omitting every name added since — so a + # config change can evaluate, build and deploy cleanly and the + # gateway still serves a certificate that does not cover the new + # service. The services sub-CA already reconciles this way (its + # `.names` comparison in ../swarm-ca.nix); the leaves did not, so + # adding a name to the config alone could not fix a mis-served + # certificate on any hive whose leaf was not already near expiry. + # + # Read the names out of the CERTIFICATE, not a sidecar file: the + # pem is what nginx actually serves, and a bookkeeping file + # drifts from it the moment anyone replaces a leaf by hand. + covers() { # $1 = leaf, $2 = space-separated names it must carry + [ -s "$1" ] || return 1 + _have="$(openssl x509 -in "$1" -noout -ext subjectAltName 2>/dev/null \ + | tr ',' '\n' | sed -n 's/^[[:space:]]*DNS:\(.*\)$/\1/p')" + [ -n "$_have" ] || return 1 + _missing=0 + # ⚠️ The hive leaf carries `*.`; without this the shell + # expands it against the cwd and the comparison silently tests + # a filename. + set -f + for _n in $2; do + printf '%s\n' "$_have" | grep -qxF "$_n" || _missing=1 + done + set +f + [ "$_missing" = 0 ] + } + + hiveNames=${lib.escapeShellArg "${domain} *.${domain}"} + svcNames=${lib.escapeShellArg (lib.concatStringsSep " " swarmServiceDomains)} + # 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"} @@ -521,12 +554,13 @@ in 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" + if fresh "$leaf" && covers "$leaf" "$hiveNames" \ + && { [ "$want_svc" = 0 ] || { fresh "$svcleaf" && covers "$svcleaf" "$svcNames"; }; }; then + echo "leaves valid, and covering the configured names — no resign needed" exit 0 fi - echo "a leaf is missing or near expiry — re-signing under the current CAs" + echo "a leaf is missing, near expiry, or missing a configured name — re-signing" before="$(sha256sum "$leaf" "$svcleaf" 2>/dev/null || true)" ${signHiveLeaf}