nix: unbreak the swarm-services leaf the gateway waits on

Three defects in the store-issued path, each of which alone kept nginx
from starting at all. The gateway's cert import `Requires=` this leaf, so
a leaf that is never issued is not a name mismatch — it is an empty
listener, and the swarm's own forge stopped answering on :443.

`swarm-services-cert` declared `Before=hive-tls-ca` for the trust
bundle's sake while also being `After=` the store's container, which is
itself `After=hive-tls-ca`. systemd resolved the cycle the only way it
can, by deleting the job, so the leaf went unissued on every activation.
The edge is gone; the bundle converges the other way round, through the
restart this unit already performed when the root it wrote was new.

The `pki` mount was enabled without `-max-lease-ttl`, so bao clamped the
30-year root to the 768h default and then refused every issue call,
because a leaf of the mount's own default length would outlive the CA
signing it. The mount is tuned on every run, the role pins a 720h leaf,
and a root that can no longer cover one is replaced rather than left to
refuse forever. A hive tests its own copy of that certificate against the
same threshold, so both ends reach a fresh leaf without signalling.

`swarm-bao-pki` mints the services-issuer leaf that opens the mount, but
only `swarm-bao-certs` required it. `RemainAfterExit` plus an
already-active unit means an activation that ADDS a leaf mints nothing —
which is how a host whose config named `services-issuer.pem` came to have
no such file. A target wants it now, like every sibling granting unit.
This commit is contained in:
müde 2026-09-23 21:36:46 +02:00
commit 5704c0c583
3 changed files with 118 additions and 18 deletions

View file

@ -336,6 +336,18 @@ let
# absorbed the churn; nothing sits above this one.
servicesPkiRootTtl = "262800h";
# The leaf's window, pinned on the role rather than left to the mount's
# default. Two reasons it cannot be implicit: the default is 768h, which is
# also what an untuned mount clamps the ROOT to, so the two collided at
# exactly the length that makes issuance impossible; and the guard below
# compares the issuer's remaining life against this number, which it can
# only do if this number exists. 720h matches
# `deploy.hive-controller.tls.leafValidityDays`, the 30 days ./hive-tls.nix
# documents for every other leaf it holds.
servicesPkiLeafTtlHours = 720;
servicesPkiLeafTtl = "${toString servicesPkiLeafTtlHours}h";
servicesPkiLeafTtlSeconds = servicesPkiLeafTtlHours * 3600;
# The role every swarm-service certificate will be issued through, and the
# whole narrowing of this mount. A role rather than a name-constrained
# sub-CA: the engine enforces the same "these names and no others" at issue
@ -1788,6 +1800,9 @@ in
path = [
baoCli
pkgs.coreutils
# Reads the notAfter of the issuer already on the pki mount; the
# regeneration guard below turns that into a decision.
pkgs.openssl
];
# Named but not placed is a legitimate state: all-local supplies the
# path as a default and the operator drops the file there after
@ -1849,6 +1864,21 @@ in
*) bao secrets enable -path=${servicesPkiMountPath} pki ;;
esac
# 🩸 `max_lease_ttl` is the root's real lifetime, not the `ttl=` the
# generation below asks for. A mount defaults to 768h and SILENTLY
# CLAMPS anything longer, so the 30-year root came out 32 days long
# — and then every issue call failed, because a leaf of the mount's
# own default length would outlive the CA that signs it:
#
# cannot satisfy request, as TTL would result in notAfter of
# <now+768h> that is beyond the expiration of the CA certificate
#
# Tuned on EVERY run rather than only at enable: the clamp already
# happened on stores provisioned before this line existed, and a
# `tune` is the only thing that lets the regeneration guard below
# replace what it produced.
bao secrets tune -max-lease-ttl=${servicesPkiRootTtl} ${servicesPkiMountPath}
# ⚠️ THE ROOT, AND THE ONE THING THIS UNIT MUST NEVER DO TWICE.
#
# `root/generate/internal` mints a new self-signed CA every time it
@ -1868,7 +1898,29 @@ in
# status IS the question — no output parsing, no error string to
# recognise. The default issuer is what `${servicesPkiMountPath}/issue/`
# signs with, and there is exactly one.
#
# "Never twice" is the rule; "an issuer that cannot issue" is not a
# case it was written for. A root with less than a leaf's window left
# refuses every request rather than returning a shorter certificate,
# so leaving it alone preserves an anchor nothing can chain to — the
# outage the rule exists to prevent, arrived at by obeying it. Note
# the asymmetry: this replaces a root that is already useless, and
# still never touches one a leaf can be issued under.
regenerate=0
if ! bao list ${lib.escapeShellArg "${servicesPkiMountPath}/issuers"} >/dev/null 2>&1; then
regenerate=1
elif ! bao read -field=certificate ${lib.escapeShellArg "${servicesPkiMountPath}/cert/ca"} \
| openssl x509 -noout -checkend ${toString servicesPkiLeafTtlSeconds} >/dev/null 2>&1; then
echo "the ${servicesPkiMountPath} root cannot outlive a ${servicesPkiLeafTtl} leaf — replacing it" >&2
# `root` rather than the named issuer: the replacement re-uses
# `issuer_name`, and generating into a name already in use is a
# refusal. Clears the mount's keys with it, which is the whole of
# what it holds — one issuer, by the design above.
bao delete ${lib.escapeShellArg "${servicesPkiMountPath}/root"} >/dev/null
regenerate=1
fi
if [ "$regenerate" = 1 ]; then
echo "generating the swarm services root into the ${servicesPkiMountPath} mount"
# `-field=issuing_ca` discards the rest of the response. The
# private key is not in it under `internal` and cannot be — that
@ -1938,7 +1990,9 @@ in
server_flag=true \
client_flag=false \
key_type=rsa \
key_bits=4096
key_bits=4096 \
ttl=${servicesPkiLeafTtl} \
max_ttl=${servicesPkiLeafTtl}
''
+ lib.optionalString (baoDeploy.clientCaFile != null) ''