Watch
0
0
Fork
You've already forked hyperhive
0

swarm-bao: agent certificates issued by a store-generated agent CA

An agent's store identity was signed in swarm-controller's memory by a CA
a controller-host unit generated on disk, and the listener never trusted
that CA. Agent leaves now come from the store itself: a `pki-agents` PKI
mount whose root openbao generates internally, so the agent CA's key
never exists outside the store.

- swarm-bao-agent-pki (new, store host, as the bao granter): enables and
  tunes the mount, generates the root once (guarded on an empty issuer
  list, no replace branch), upserts the `swarm-agent` role (client
  certificates named `hive-agent-*` only, 90 days), caches the CA at
  /var/lib/swarm-bao-tls/agent-ca.pem and composes the listener bundle.
- The listener's tls_client_ca_file is a new listener-client-ca.pem
  (client-ca.pem, then the agent CA). Host cert-auth roles still pin
  client-ca.pem, so an agent leaf satisfies no host role. swarm-bao-certs
  composes the same bundle before openbao starts.
- openbao reads tls_client_ca_file only at start, so when the bundle
  changed after openbao started, swarm-bao-agent-pki restarts
  openbao.service in the container; under `seal = "shamir"` it prints
  the step instead. Once swarm-bao-certs has a cached CA, later boots
  start openbao with it and do not restart.
- The controller policy gains exactly `update` on
  pki-agents/issue/swarm-agent. mint_and_verify now asks that role for
  the leaf (the store generates the key), writes the agent's cert-auth
  role pinning the issuing CA bao returned, and writes the agent's
  policy as render_agent alone: the hive-shared queue credential stanza
  is gone.
- deploy.bao.agentPkiRoleName (must start `swarm-`, asserted with the
  other pki role names); swarm-controller gets
  SWARM_CONTROLLER_AGENT_PKI_MOUNT/_ROLE from the deploy.bao options.

Deleted: swarm-controller-agent-ca and its options (agentCaFile,
agentCaKeyFile), env, LoadCredential entries and assertion;
agent_identity's Authority, rcgen signing and validity window; the
rcgen and time dependencies of swarm-controller (rcgen leaves the
workspace); policy::render_agent_with_queue and its tests. The CN-prefix
assertion policy.rs said was owed is not: agent and host roles pin
different CAs.

Migration is re-creating each agent after deploy; that overwrites the
stale role and policy.

Closes #4756
This commit is contained in:
atlas 2026-09-27 19:30:38 +02:00
commit 6170e74a31
16 changed files with 894 additions and 925 deletions

View file

@ -356,6 +356,9 @@ let
# The swarm appservice token, read-only: the controller creates agents'
# matrix accounts with it and never writes it. matrix-ctl mints and publishes
# it (`matrixCtlPolicyText` below).
#
# The agent PKI grant is its only path on that mount: it can ask the one role
# for a certificate, not write that role or reach the issuer.
controllerPolicyText = ''
path "auth/cert/certs/hive-*" {
capabilities = ["create", "update", "read", "delete"]
@ -380,6 +383,10 @@ let
path "${credentialMountPath}/data/${swarmAppserviceTokenLeaf}" {
capabilities = ["read"]
}
path "${agentPkiMountPath}/issue/${agentPkiRoleName}" {
capabilities = ["update"]
}
'';
# The identity that copies authelia's minted OIDC client secrets into the
@ -489,7 +496,9 @@ let
#
# ⚠️ NOT bao's own client-auth PKI. That one is ./glue-bao-tls.nix's
# self-signed CA under `/var/lib/swarm-bao-pki`, and it stays outside the
# store permanently: bao cannot issue the credential that opens bao.
# store permanently: bao cannot issue the credential a host opens bao with.
# Agent leaves come from `agentPkiMountPath`, requested by a principal that
# has already logged in.
#
# The mount's own root is generated into it by the bootstrap unit below and
# its private key never leaves — `root/generate/internal` keeps it inside
@ -500,13 +509,6 @@ let
# and has to spell it the same way.
servicesPkiMountPath = baoDeploy.servicesPkiMountPath;
# The PKI mount agent client certificates are issued from. Its root is
# generated inside the store, so the agent CA's key never exists outside it.
# A mount of its own because the services mount holds exactly one issuer; a
# root apart from ./glue-bao-tls.nix's CA because that is what keeps an
# agent's certificate from satisfying any host role.
agentPkiMountPath = baoDeploy.agentPkiMountPath;
# Subject of the root generated into that mount. A label for a human reading
# a chain, not an identity anything authenticates against — same fall-through
# ./swarm-ca.nix:29-37 uses, and for the same reason: a hive that has set
@ -594,6 +596,77 @@ let
}
'';
# The PKI mount agent client certificates are issued from. Its root is
# generated inside the store, so the agent CA's key never exists outside it.
# A mount of its own because the services mount holds exactly one issuer; a
# root apart from ./glue-bao-tls.nix's CA because that is what keeps an
# agent's certificate from satisfying any host role.
agentPkiMountPath = baoDeploy.agentPkiMountPath;
agentPkiRoleName = baoDeploy.agentPkiRoleName;
# Every common name the agent role issues for: `swarm_secret_client`'s
# `policy::AGENT_PREFIX`, which names each agent's cert-auth role and
# policy too. Outside it the role refuses, so the controller cannot obtain
# a certificate for any other name.
agentCnGlob = "hive-agent-*";
# The anchor every agent's cert-auth role pins by value, so it is never
# replaced by a deploy. 262800h like `servicesPkiRootTtl`, and for the same
# reason: the mount is tuned to it before generation, or bao clamps it.
agentPkiRootTtl = "262800h";
# The agent leaf's window, pinned on the role. Nothing re-issues a leaf
# before it ends; an operator re-runs agent creation.
agentPkiLeafTtl = "2160h";
# The agent CA's certificate, cached on this host by `swarm-bao-agent-pki`,
# so `swarm-bao-certs` can compose the listener's bundle before the store
# is up. Public material.
agentCaCachePath = "${tlsDir}/agent-ca.pem";
# What the listener verifies client certificates against: `client-ca.pem`
# first, then the agent CA. A file of its own because every host cert-auth
# role pins `client-ca.pem`: with the agent CA in that file, every leaf the
# controller can request would satisfy every host role.
listenerClientCaPath = "${tlsDir}/listener-client-ca.pem";
# Writes `listenerClientCaPath` from `clientCaPath` and, when it parses, the
# cached agent CA. The file is replaced only when its bytes change, so its
# mtime says when the listener's trust last changed; `swarm-bao-agent-pki`
# restarts openbao on exactly that. Refuses, leaving the current file, when
# the result would not begin with `client-ca.pem`: host logins depend on it.
composeListenerBundle = ''
compose_listener_bundle() {
if [ ! -s ${clientCaPath} ]; then
echo "${clientCaPath} is missing or empty; not composing the listener bundle" >&2
return 1
fi
local tmp
tmp="$(mktemp -p ${tlsDir} .listener-client-ca.XXXXXX)"
cat ${clientCaPath} > "$tmp"
if [ -s ${agentCaCachePath} ]; then
if openssl x509 -noout -in ${agentCaCachePath} >/dev/null 2>&1; then
printf '\n' >> "$tmp"
cat ${agentCaCachePath} >> "$tmp"
else
echo "${agentCaCachePath} does not parse; the listener will not trust agent certificates" >&2
fi
fi
if ! cmp -s -n "$(stat -c %s ${clientCaPath})" ${clientCaPath} "$tmp"; then
rm -f "$tmp"
echo "the composed listener bundle does not begin with ${clientCaPath}; leaving the current one" >&2
return 1
fi
if cmp -s "$tmp" ${listenerClientCaPath}; then
rm -f "$tmp"
else
chmod 0644 "$tmp"
mv -f "$tmp" ${listenerClientCaPath}
echo "wrote ${listenerClientCaPath}"
fi
}
'';
# ── the four principals that used to share the hive's own leaf ─────────────
#
# 🩸 Each of the four reads exactly ONE path in the store, and until this
@ -760,7 +833,7 @@ let
tls_key_file = serverKeyCredentialPath;
}
// lib.optionalAttrs (baoDeploy.clientCaFile != null) {
tls_client_ca_file = clientCaPath;
tls_client_ca_file = listenerClientCaPath;
tls_require_and_verify_client_cert = true;
};
@ -1251,7 +1324,9 @@ in
⚠️ NOT the store's own client-auth PKI. That one is
./glue-bao-tls.nix's self-signed CA on disk, and it stays outside the
store permanently — bao cannot issue the credential that opens bao.
store permanently — bao cannot issue the credential a host opens bao
with. Agent certificates come from
{option}`services.hyperhive.deploy.bao.agentPkiMountPath`.
'';
};
@ -1269,6 +1344,17 @@ in
'';
};
agentPkiRoleName = lib.mkOption {
type = lib.types.str;
default = "swarm-agent";
description = ''
Role on {option}`services.hyperhive.deploy.bao.agentPkiMountPath`
agent client certificates are issued through. It issues client
certificates named `hive-agent-*` and nothing else, and swarm-controller
may call its `issue` endpoint and no other path on the mount.
'';
};
servicesPkiRoleName = lib.mkOption {
type = lib.types.str;
default = "swarm-services";
@ -1859,13 +1945,18 @@ in
# else, so a role named otherwise is a 403 at deploy time.
assertion =
!haveGranter
|| (lib.hasPrefix "swarm-" servicesPkiRoleName && lib.hasPrefix "swarm-" natsPkiRoleName);
|| (
lib.hasPrefix "swarm-" servicesPkiRoleName
&& lib.hasPrefix "swarm-" natsPkiRoleName
&& lib.hasPrefix "swarm-" agentPkiRoleName
);
message = ''
services.hyperhive.deploy.bao.servicesPkiRoleName
(${servicesPkiRoleName}) and
(${servicesPkiRoleName}),
services.hyperhive.deploy.bao.natsPkiRoleName (${natsPkiRoleName})
must both start with `swarm-`: the bao granter that writes them may
write pki roles under that prefix only.
and services.hyperhive.deploy.bao.agentPkiRoleName
(${agentPkiRoleName}) must all start with `swarm-`: the bao granter
that writes them may write pki roles under that prefix only.
'';
}
];
@ -1929,6 +2020,7 @@ in
"swarm-bao-forwarder-oidc-policy"
"swarm-bao-services-issuer-policy"
"swarm-bao-nats-tls-policy"
"swarm-bao-agent-pki"
];
# 🚫 No `swarm.otel.scrapeTargets.bao` entry any more, and its absence is
@ -2119,7 +2211,11 @@ in
description = "deliver the swarm secret store's server certificate";
before = [ "container@${cfg.machine}.service" ];
requiredBy = [ "container@${cfg.machine}.service" ];
path = [ pkgs.coreutils ];
path = [
pkgs.coreutils
pkgs.diffutils
pkgs.openssl
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
@ -2155,6 +2251,12 @@ in
exit 1
fi
install -m 0644 ${lib.escapeShellArg baoDeploy.clientCaFile} ${tlsDir}/client-ca.pem
# Composed here as well as by `swarm-bao-agent-pki` so that openbao
# starts already trusting the cached agent CA, and so the file it
# refuses to start without exists before the store's first run.
${composeListenerBundle}
compose_listener_bundle
'';
};
@ -2734,6 +2836,170 @@ in
'';
};
# The agent PKI mount: its root, its one role, the host's copy of the
# root's certificate, and the listener's trust in it.
#
# ⚠️ This unit RESTARTS openbao. openbao reads `tls_client_ca_file` only
# when a listener is built, at start: neither SIGHUP nor a reload re-reads
# it. So when the listener bundle changed after openbao last started,
# openbao is restarted here, which drops every client for the restart and
# the unseal. `swarm-bao-certs` composes the same bundle before every
# later start, so this happens when the agent CA first appears (or is
# replaced by a re-initialised store), not per boot. Under `shamir` a
# restart needs a human unseal, so there it prints the step instead.
#
# `after` the granting units so a restart does not cut their writes off.
systemd.services.swarm-bao-agent-pki = lib.mkIf haveGranter {
description = "set up the swarm agent PKI mount and make the store's listener trust it";
after = [
"container@${cfg.machine}.service"
"swarm-bao-controller-policy.service"
"swarm-bao-secret-publisher-policy.service"
"swarm-bao-matrix-ctl-policy.service"
"swarm-bao-matrix-token-policy.service"
"swarm-bao-queue-agent-policy.service"
"swarm-bao-grafana-oidc-policy.service"
"swarm-bao-otel-oidc-policy.service"
"swarm-bao-forwarder-oidc-policy.service"
"swarm-bao-services-issuer-policy.service"
"swarm-bao-nats-tls-policy.service"
]
++ granterAfter;
requires = [ "swarm-bao-pki.service" ];
wantedBy = [ "multi-user.target" ];
path = [
baoCli
pkgs.coreutils
pkgs.diffutils
pkgs.openssl
];
environment = granterEnv;
# Same unseal wait as its siblings above.
startLimitBurst = 2880;
startLimitIntervalSec = 90000;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = 30;
};
script = ''
set -euo pipefail
${granterLogin}
# Asked rather than attempted: `secrets enable` errors on a path
# already in use.
mounts="$(bao secrets list -format=json)"
case "$mounts" in
*'"${agentPkiMountPath}/"'*) ;;
*) bao secrets enable -path=${agentPkiMountPath} pki ;;
esac
# Before generation, on every run: an untuned mount silently clamps
# the root to 768h (see the services mount above).
bao secrets tune -max-lease-ttl=${agentPkiRootTtl} ${agentPkiMountPath}
# Generated once, ever. Every agent's cert-auth role pins this root
# by value, so a second one locks every agent out; there is no
# replace branch, and the granter holds no delete on the root. The
# mount's own issuer list is the guard, for the reason the services
# root gives: `{}` is the only answer that means "none".
if issuers="$(bao list -format=json ${lib.escapeShellArg "${agentPkiMountPath}/issuers"})"; then
echo "the ${agentPkiMountPath} mount already has an issuer — leaving it alone"
elif [ "$issuers" = '{}' ]; then
echo "generating the swarm agent CA into the ${agentPkiMountPath} mount"
# `max_path_length=0`: this CA signs leaves only.
bao write -field=issuing_ca ${lib.escapeShellArg "${agentPkiMountPath}/root/generate/internal"} \
common_name=${lib.escapeShellArg "swarm-bao-agent-ca ${servicesPkiRootLabel}"} \
issuer_name=swarm-agent-ca \
ttl=${agentPkiRootTtl} \
max_path_length=0 \
key_type=rsa \
key_bits=4096 >/dev/null
else
echo "could not list the ${agentPkiMountPath} issuers — not generating a root over one that may exist" >&2
exit 1
fi
# Upserted every run. The whole narrowing of what the controller can
# obtain: client certificates, named `hive-agent-*`, nothing else.
# `allow_localhost` and `server_flag` are on by default in bao, so
# they are turned off here, not left out. No `issuer_ref`: the mount
# holds one issuer, which is its default.
bao write ${lib.escapeShellArg "${agentPkiMountPath}/roles/${agentPkiRoleName}"} \
allowed_domains=${lib.escapeShellArg agentCnGlob} \
allow_glob_domains=true \
allow_bare_domains=false \
allow_subdomains=false \
allow_wildcard_certificates=false \
allow_localhost=false \
allow_any_name=false \
allow_ip_sans=false \
enforce_hostnames=true \
server_flag=false \
client_flag=true \
code_signing_flag=false \
email_protection_flag=false \
key_usage=DigitalSignature \
key_type=ec \
key_bits=256 \
ttl=${agentPkiLeafTtl} \
max_ttl=${agentPkiLeafTtl}
ca="$(bao read -field=certificate ${lib.escapeShellArg "${agentPkiMountPath}/cert/ca"})"
if ! openssl x509 -noout <<<"$ca" >/dev/null 2>&1; then
echo "the ${agentPkiMountPath} CA that bao returned does not parse — not caching it" >&2
exit 1
fi
tmp="$(mktemp -p ${tlsDir} .agent-ca.XXXXXX)"
printf '%s\n' "$ca" > "$tmp"
if cmp -s "$tmp" ${agentCaCachePath}; then
rm -f "$tmp"
else
chmod 0644 "$tmp"
mv -f "$tmp" ${agentCaCachePath}
echo "wrote ${agentCaCachePath}"
fi
${composeListenerBundle}
compose_listener_bundle
machine=${lib.escapeShellArg cfg.machine}
if ! systemctl --machine="$machine" is-active --quiet openbao.service; then
echo "openbao in $machine is not running; it reads ${listenerClientCaPath} when it starts"
exit 0
fi
started="$(systemctl --machine="$machine" show --timestamp=us+utc -p ActiveEnterTimestamp --value openbao.service)"
started_us="$(date -u -d "$started" +%s%6N)"
written_us="$(stat -c %.6Y ${listenerClientCaPath} | tr -d .)"
if [ "$written_us" -le "$started_us" ]; then
echo "openbao started after ${listenerClientCaPath} last changed; nothing to pick up"
exit 0
fi
# Fail closed: host logins go through this file too.
if [ ! -s ${listenerClientCaPath} ] \
|| ! cmp -s -n "$(stat -c %s ${clientCaPath})" ${clientCaPath} ${listenerClientCaPath} \
|| ! openssl x509 -noout -in ${listenerClientCaPath} >/dev/null 2>&1; then
echo "${listenerClientCaPath} is empty, unparseable or does not begin with ${clientCaPath}; not restarting openbao" >&2
exit 1
fi
''
+ (
if baoDeploy.seal == "pkcs11" then
''
echo "${listenerClientCaPath} changed after openbao started; restarting openbao in $machine (it unseals itself)"
systemctl --machine="$machine" restart openbao.service
''
else
''
echo "${listenerClientCaPath} changed after openbao started. A restart seals a ${baoDeploy.seal} store, so it is left to you, as root on this host:" >&2
echo " systemctl --machine=$machine restart openbao.service # then unseal" >&2
''
);
};
# The CA bind source is written at runtime by a host unit, so the
# container has to start after it — otherwise nspawn sets up a mount
# over a file that does not exist yet.
@ -2754,7 +3020,8 @@ in
# /var, systemd owns `${stateDir}` through `StateDirectory=`, and
# binding over it is what breaks the unit.
bindMounts = {
# Read-only: `swarm-bao-certs` on the host is the only writer, and
# Read-only: host units write it (`swarm-bao-certs`, and
# `swarm-bao-agent-pki` for the agent CA and the listener bundle), and
# the store has no reason to modify its own identity.
${tlsDir} = {
hostPath = tlsDir;
@ -2985,12 +3252,15 @@ in
];
};
# ⚠️ Upstream sets `restartIfChanged = false` on this unit, on
# purpose: a restart SEALS the store and disconnects every client.
# So a change to the settings above does NOT take effect on
# `nixos-rebuild switch` — it lands in the config file and waits.
# Restarting is an operator action with an unseal on the far side of
# it, which is why nothing here tries to be clever about it.
# ⚠️ Upstream sets `restartIfChanged = false` on this unit: a restart
# SEALS the store and disconnects every client. The container around
# it does restart on `nixos-rebuild switch` whenever its config
# (these settings included) changes: nixos-containers sets
# `restartTriggers` on `container@${cfg.machine}` and nothing here
# overrides its `restartIfChanged`. The only in-place restart of this
# unit is `swarm-bao-agent-pki` on the host, once, when the listener's
# client-CA bundle changed after openbao started, and only under the
# self-unsealing `pkcs11` seal.
# This container's own journal forwarder, copied from an agent
# container's (nix/agent-modules/otel.nix) because every container