diff --git a/nix/host-modules/hive-tls.nix b/nix/host-modules/hive-tls.nix index 3de3169b..8f785282 100644 --- a/nix/host-modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -248,6 +248,49 @@ in 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). @@ -298,12 +341,24 @@ in # 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" - if [ -s "$root" ]; then - cat "$ca" "$root" > "$bundle" - else - cat "$ca" > "$bundle" - fi + anchors=("$ca") + if [ -s "$prev" ]; then anchors+=("$prev"); fi + if [ -s "$root" ]; then anchors+=("$root"); fi + cat "''${anchors[@]}" > "$bundle" chmod 0644 "$bundle" ''; };