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
167 lines
6.9 KiB
Nix
167 lines
6.9 KiB
Nix
# The swarm root CA: the anchor a whole swarm shares, and the issuer of
|
|
# each hive's own CA (which is where it gets used — see ./hive-tls.nix).
|
|
#
|
|
# One root rather than per-peer pinning, because cross-hive trust is then
|
|
# O(1): trust the root once and every present *and future* peer validates,
|
|
# instead of every hive having to name every other one.
|
|
#
|
|
# Two provisioning modes share ONE structure — what differs is who puts
|
|
# the artifacts on disk, never what the artifacts are. Which mode does
|
|
# what, and what moving between them costs:
|
|
# `docs/swarm/ca.md::Two provisioning modes, one structure`.
|
|
#
|
|
# ⚠️ Root key AND root cert are runtime files, never nix options. The key
|
|
# 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,
|
|
pkgs,
|
|
...
|
|
}:
|
|
let
|
|
cfg = config.services.hyperhive.swarm.ca;
|
|
hyperhiveCfg = config.services.hyperhive;
|
|
|
|
# The subject CN is a label for a human reading a chain, not an
|
|
# identity anything authenticates against. Fall through swarm name →
|
|
# hive domain → a constant so a hive that has set neither still
|
|
# evaluates; a missing `domain` is reported by its own assertion in
|
|
# hive-network.nix, and shouldn't also surface here as a null.
|
|
swarmLabel =
|
|
if hyperhiveCfg.swarm.name != null then
|
|
hyperhiveCfg.swarm.name
|
|
else if hyperhiveCfg.domain != null then
|
|
hyperhiveCfg.domain
|
|
else
|
|
"hyperhive";
|
|
in
|
|
{
|
|
options.services.hyperhive.swarm.ca = {
|
|
autoConfigure = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
example = true;
|
|
description = ''
|
|
Run the whole swarm CA on this one host: generate the swarm
|
|
root when it is missing, and issue this hive's CA under it.
|
|
|
|
`services.hyperhive.deploy.singleHostSwarm` turns this on as
|
|
part of the all-on-one-box mode. Set it here directly to run the
|
|
CA on a host that is not otherwise all-local.
|
|
|
|
**Off by default, deliberately.** A swarm's services and its
|
|
hives can live on different hosts, and this host has no way to
|
|
tell whether it is the one holding the root — so the swarm CA
|
|
is something an operator sets up, not something a host decides
|
|
it is. Turn this on for an all-on-one-host deployment (dev
|
|
boxes, single-hive swarms) and get the hierarchy for free.
|
|
|
|
With it off, both artifacts are operator-provided: the root
|
|
under `stateDir`, and this hive's CA under
|
|
`services.hyperhive.deploy.hive-controller.tls.stateDir`. A hive
|
|
given neither keeps the self-signed CA it has always had — it
|
|
simply isn't part of a swarm's trust hierarchy, which is the
|
|
correct outcome for a hive nobody has federated yet.
|
|
'';
|
|
};
|
|
|
|
stateDir = lib.mkOption {
|
|
type = lib.types.str;
|
|
default = "/var/lib/swarm-ca";
|
|
description = ''
|
|
Host directory holding the swarm root CA: `root.pem` (the
|
|
anchor, safe to distribute — copy it to this same path on every
|
|
other host in the swarm) and `root-key.pem`
|
|
(0600, the one file that must never reach the nix store or
|
|
another host). The directory itself is 0700: nothing reads
|
|
out of it but the hive CA issuance in `hive-tls.nix`.
|
|
|
|
Moving the swarm CA to its own host is a matter of moving this
|
|
directory and setting `autoConfigure = false` here.
|
|
'';
|
|
};
|
|
|
|
validityDays = lib.mkOption {
|
|
type = lib.types.int;
|
|
default = 10950;
|
|
description = ''
|
|
Validity window of the swarm root CA in days (default ~30y).
|
|
Deliberately longer than `services.hyperhive.deploy.hive-controller.tls.caValidityDays`:
|
|
the root must outlive the hive CAs it issues, or those chains
|
|
expire out from under hives that are still perfectly happy with
|
|
their own intermediate. Rotating a root is the one operation in
|
|
this system with no partial-failure mode — it invalidates every
|
|
peer at once, paced by the slowest peer's rebuild — so it is
|
|
never automatic and this window is meant to be uneventful.
|
|
'';
|
|
};
|
|
};
|
|
|
|
config = lib.mkIf (hyperhiveCfg.enable && cfg.autoConfigure) {
|
|
# A CA that fails to issue is invisible until something makes a TLS call
|
|
# 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";
|
|
wantedBy = [ "multi-user.target" ];
|
|
path = [ pkgs.openssl ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
UMask = "0077";
|
|
# Pin the journal identity (else it's the `script` store-path wrapper).
|
|
SyslogIdentifier = "swarm-ca";
|
|
};
|
|
script = ''
|
|
set -euo pipefail
|
|
d=${lib.escapeShellArg cfg.stateDir}
|
|
install -d -m 0700 "$d"
|
|
|
|
root="$d/root.pem"
|
|
rootk="$d/root-key.pem"
|
|
|
|
# Note the asymmetry with the hive CA in hive-tls.nix, which
|
|
# regenerates itself once expired: a root is never replaced
|
|
# automatically, not even an expired one. Consumers hold this
|
|
# cert, so replacing it is a swarm-wide flag day that wants an
|
|
# operator running it deliberately, with both roots trusted
|
|
# across the overlap.
|
|
if [ -s "$root" ] && [ -s "$rootk" ]; then
|
|
echo "swarm root CA already present at $root — leaving it alone"
|
|
exit 0
|
|
fi
|
|
|
|
# Half a root is not a root. Generating a fresh key beside an
|
|
# already-distributed cert (or the reverse) leaves every
|
|
# consumer trusting an anchor that no longer signs anything —
|
|
# and it would look like it worked.
|
|
if [ -e "$root" ] || [ -e "$rootk" ]; then
|
|
echo "swarm root CA half-provisioned ($root / $rootk) — refusing to generate over it" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "generating swarm root CA at $root"
|
|
# pathlen:1 — the root signs hive CAs, which sign leaves. One
|
|
# intermediate below the root and no deeper.
|
|
openssl req -x509 -newkey rsa:4096 -nodes -sha256 \
|
|
-days ${toString cfg.validityDays} \
|
|
-keyout "$rootk" -out "$root" \
|
|
-subj "/CN=swarm-ca ${swarmLabel}" \
|
|
-addext "basicConstraints=critical,CA:TRUE,pathlen:1" \
|
|
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
|
chmod 0600 "$rootk"
|
|
chmod 0644 "$root"
|
|
'';
|
|
};
|
|
};
|
|
}
|