nix: issue the swarm-services leaf from bao's pki mount

The `pki` mount had no issuer and no principal could log in to it, so the
swarm's service certificates were still minted by two openssl hops from a
root key on disk. Close both halves and retire the openssl path with them.

The mount now generates its own root, once. The granting unit asks bao
whether an issuer already exists (`bao list pki/issuers`) before calling
`pki/root/generate/internal`, so a rebuild or a reboot re-asserts the role
and the grant without touching the anchor — a root that changed per boot
would invalidate every certificate issued under it and every browser
taught to trust it. The guard asks the store rather than looking for a
marker file on this host's disk: a file is a claim about a mount that may
have been restored from a snapshot or disabled and re-enabled underneath
it.

`swarm-services-issuer` stops being an inert policy. A fourth cert-auth
role attaches it, following the shape the controller, the publisher and
matrix-ctl already use, and glue-bao-tls.nix signs the leaf carrying its
CN — that credential is what opens the mount, so it cannot come out of it.

`swarm-services-cert.service` logs in with that leaf, calls
`pki/issue/swarm-services`, and writes the result to the path
hive-tls.nix already wrote and the gateway already copies from. The
sub-CA layer does not move; it stops existing. The role's
`allowed_domains`, read from the same `swarm.serviceDomains` the SANs
come from, enforces at issue time what the sub-CA encoded in x509
`nameConstraints`, and with the root inside the mount there is nothing
left for an intermediate to be an intermediate of.

Not a flag day: the issuing root is published beside the leaf as
`swarm-services-root.pem` (0644) and joins `trust-bundle.pem`, where the
swarm root still sits. A leaf chaining to the old sub-CA and one issued
by the store both verify against the same bundle, so hives can be
rebuilt in any order. The same file is what an operator hands a browser
— readable without a store login, which matters because every listener
demands a client certificate.

The eval-time warning about uncovered service names is gone rather than
reworded. It fired on "this host does not hold the swarm root key", which
was the reason a hive could end up serving its own leaf on a
swarm-service name. Every hive now asks the store with its own identity,
so that stopped being the thing that decides.

Closes #4586
This commit is contained in:
atlas 2026-09-21 17:59:22 +02:00 committed by mara
commit f4df4fc4a9
10 changed files with 700 additions and 315 deletions

View file

@ -14,6 +14,13 @@
# for the obvious reason; the cert as a consequence, and that one costs
# something real — nothing whose trust store is built at build time can
# name it: `docs/swarm/ca.md::Distributing the root`.
#
# ⚠️ This root no longer issues the swarm's SERVICE certificates, and the
# `swarm-services-ca` sub-CA that used to sit under it is gone rather than
# moved. Those come out of the secret store's own `pki` mount now, anchored
# on a root generated inside it (./swarm-bao.nix), and ./hive-tls.nix asks
# for them over the network instead of signing them here. What is left here
# is one job: the anchor each hive's own CA is issued under.
{
lib,
config,
@ -36,13 +43,6 @@ let
hyperhiveCfg.domain
else
"hyperhive";
# 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 = {
@ -90,23 +90,6 @@ in
'';
};
servicesValidityDays = lib.mkOption {
type = lib.types.int;
default = 1825;
description = ''
Validity window of the swarm-services sub-CA in days (~5y).
Deliberately far shorter than the root's: this CA is *meant* to
be re-issued adding a swarm service changes its name
constraints and rotates it so a long window buys nothing, and
a short one keeps the rotation path exercised rather than
theoretical.
Rotating it is cheap in the way rotating the root is not: it
touches only the swarm-service vhosts, and no peer hive holds it
as an anchor.
'';
};
validityDays = lib.mkOption {
type = lib.types.int;
default = 10950;
@ -125,11 +108,8 @@ in
config = lib.mkIf (hyperhiveCfg.enable && cfg.autoConfigure) {
# A CA that fails to issue is invisible until something makes a TLS call
# hours later, so these two oneshots are worth more than most services.
services.hyperhive.swarm.otel.journaldUnits = [
"swarm-ca"
"swarm-services-ca"
];
# hours later, so this oneshot is worth more than most services.
services.hyperhive.swarm.otel.journaldUnits = [ "swarm-ca" ];
systemd.services.swarm-ca = {
description = "Generate the swarm root CA when absent";
@ -183,107 +163,5 @@ in
chmod 0644 "$root"
'';
};
# The swarm-services sub-CA: issues leaves for the swarm's own
# service names, which no hive CA can sign — each of those is
# name-constrained to its own hive's domain, and the service names
# are siblings of it, not children.
#
# Rotation is the point of it being separate (mara: "swarm services
# sub ca that can rotate independently of swarm root ca"): the
# constraint enumerates the exact service names, so adding a service
# re-issues *this* and never touches the root or any hive CA.
systemd.services.swarm-services-ca = {
description = "Issue the swarm-services sub-CA under the swarm root";
wantedBy = [ "multi-user.target" ];
after = [ "swarm-ca.service" ];
requires = [ "swarm-ca.service" ];
path = [ pkgs.openssl ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
UMask = "0077";
SyslogIdentifier = "swarm-services-ca";
};
script = ''
set -euo pipefail
d=${lib.escapeShellArg cfg.stateDir}
root="$d/root.pem"
rootk="$d/root-key.pem"
ca="$d/services-ca.pem"
cak="$d/services-ca-key.pem"
# The name set this CA was last issued for. Comparing against it
# is what makes re-issuance happen exactly when the service
# names change — not every boot, and not never.
names="$d/services-ca.names"
want=${lib.escapeShellArg (lib.concatStringsSep "\n" serviceDomains)}
if [ -z "$want" ]; then
echo "no swarm service domains configured nothing to issue for"
exit 0
fi
# Same half-provisioned guard as the root: a key beside a cert
# that did not sign it looks like it works and issues nothing
# anyone will trust.
if { [ -e "$ca" ] && [ ! -e "$cak" ]; } || { [ -e "$cak" ] && [ ! -e "$ca" ]; }; then
echo "services sub-CA half-provisioned ($ca / $cak) refusing to generate over it" >&2
exit 1
fi
if [ -s "$ca" ] && [ -s "$cak" ] && [ -f "$names" ] \
&& [ "$(cat "$names")" = "$want" ]; then
echo "services sub-CA present and covers the configured names leaving it alone"
exit 0
fi
if [ ! -s "$root" ] || [ ! -s "$rootk" ]; then
echo "no swarm root CA at $root cannot issue the services sub-CA under it" >&2
exit 1
fi
echo "issuing services sub-CA at $ca for: $(echo "$want" | tr '\n' ' ')"
csr="$(mktemp "$d/services-ca.csr.XXXXXX")"
ext="$(mktemp "$d/services-ca.ext.XXXXXX")"
trap 'rm -f "$csr" "$ext"' EXIT
openssl req -newkey rsa:4096 -nodes -sha256 \
-keyout "$cak" -out "$csr" \
-subj "/CN=swarm-services-ca ${swarmLabel}"
{
# pathlen:0 — this signs leaves and delegates no further.
printf 'basicConstraints=critical,CA:TRUE,pathlen:0\n'
printf 'keyUsage=critical,keyCertSign,cRLSign\n'
printf 'subjectKeyIdentifier=hash\n'
printf 'authorityKeyIdentifier=keyid:always\n'
# Constrained to the exact service names, not to the whole
# swarm domain: a leaked services CA should mint `forge.`,
# `chat.`, `auth.` and nothing else. The IP exclusions are not
# redundant — a DNS constraint says nothing about an
# iPAddress SAN, and an unconstrained name type is a name
# type this CA is unconstrained for.
#
# ⚠️ EVERY entry carries its own `permitted;` / `excluded;`
# prefix. openssl's parser takes the qualifier per subtree, not
# once for a run of them: `permitted;DNS:a,DNS:b` is rejected
# outright with `v2i_NAME_CONSTRAINTS: invalid syntax`, which
# fails the whole unit. The hive CA next door emits exactly one
# permitted name, so the missing-prefix form is accidentally
# valid there and does NOT generalise — this list is always
# longer than one.
printf 'nameConstraints=critical,%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' \
"$(echo "$want" | sed 's/^/permitted;DNS:/' | paste -sd, -)"
} > "$ext"
openssl x509 -req -in "$csr" -CA "$root" -CAkey "$rootk" \
-CAcreateserial -days ${toString cfg.servicesValidityDays} -sha256 \
-extfile "$ext" -out "$ca"
printf '%s' "$want" > "$names"
chmod 0600 "$cak"
chmod 0644 "$ca" "$names"
'';
};
};
}