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

View file

@ -38,33 +38,6 @@ let
# file would be handed to a daemon that cannot use it.
haveHiveClientCa = haveBaoIdentity && deployCfg.swarm-controller.hiveClientCaFile != null;
# The authority this daemon issues AGENT client leaves from — a different
# question from `hiveClientCaFile` above, which is the authority it *trusts*
# hives by. This one it signs with, so it needs the private key too.
#
# ⚠️ Deliberately NOT the store's own PKI (`glue-bao-tls.nix`'s
# `/var/lib/swarm-bao-pki`). A cert-auth role pins its authority by value,
# per role, so a role this daemon writes carries whatever authority this
# daemon hands it — which is what lets the controller mint from its own CA
# on its own host without anything being co-located and without any
# existing role changing. `swarm-controller/src/agent_identity.rs`'s module
# doc is the long form.
agentCaDir = "/var/lib/swarm-controller-agent-ca";
# No authority named means mint one here. The alternative — leaving agent
# identities off until an operator places a CA by hand — is the state where
# the whole path is configured and silently does nothing, which is the
# failure mode `glue-bao-tls.nix` avoids the same way.
selfSignAgentCa = deployCfg.swarm-controller.agentCaFile == null;
agentCaCert =
if selfSignAgentCa then "${agentCaDir}/ca.pem" else deployCfg.swarm-controller.agentCaFile;
agentCaKey =
if selfSignAgentCa then "${agentCaDir}/ca-key.pem" else deployCfg.swarm-controller.agentCaKeyFile;
# Minting an agent's identity means publishing it to the store, so the
# authority alone is not enough — same rule `haveHiveClientCa` states.
haveAgentCa = haveBaoIdentity && agentCaKey != null;
# `swarm_secret_client` reads these spellings explicitly rather than
# vaultrs's `VAULT_*` defaults — falling through to those builds a client
# with no identity and fails at the TLS handshake, naming neither. `%d` and
@ -88,12 +61,11 @@ let
# to put in each hive's cert-auth role.
SWARM_CONTROLLER_HIVE_CLIENT_CA_FILE = "%d/hive-client-ca.pem";
}
// lib.optionalAttrs haveAgentCa {
# The authority agent leaves are ISSUED FROM, so unlike every other
# `*_CA_FILE` here it comes with a key. `%d` for both: the key is
# `0600` and root-owned, and this daemon runs unprivileged.
SWARM_CONTROLLER_AGENT_CA_FILE = "%d/agent-ca.pem";
SWARM_CONTROLLER_AGENT_CA_KEY_FILE = "%d/agent-ca-key.pem";
// lib.optionalAttrs haveBaoIdentity {
# Where agent leaves are issued. The store host sets that mount and
# role up from the same two options, which keeps the spellings equal.
SWARM_CONTROLLER_AGENT_PKI_MOUNT = deployCfg.bao.agentPkiMountPath;
SWARM_CONTROLLER_AGENT_PKI_ROLE = deployCfg.bao.agentPkiRoleName;
};
# What `swarmctl` needs in order to act on authelia from the host.
@ -663,47 +635,6 @@ in
'';
};
agentCaFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/swarm-agent-ca/ca.pem";
description = ''
Authority this daemon **issues** agent client certificates from, so
that an agent container can authenticate to the swarm secret store
under its own name. Read together with
{option}`services.hyperhive.deploy.swarm-controller.agentCaKeyFile`,
which is the private key it signs with.
The mirror image of
{option}`services.hyperhive.deploy.swarm-controller.hiveClientCaFile`:
that one is an authority this daemon only *trusts by value*, so it is
public material and needs no key. This one signs, so it does.
Leaving this `null` — the default — makes the module mint a
self-signed authority in `${agentCaDir}` on first boot and use that.
That is the ordinary shape: the store pins an authority per cert-auth
role, by value, so the authority agents are issued from does not have
to be the store's own PKI and does not have to live on the store's
host. Name a file here only when an operator issues agent leaves from
somewhere else; doing so turns the self-signing unit off.
'';
};
agentCaKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/swarm-agent-ca/ca-key.pem";
description = ''
Private key for
{option}`services.hyperhive.deploy.swarm-controller.agentCaFile`.
Both or neither — an authority with no key signs nothing, and the
daemon refuses to start half-configured rather than looking ready.
A path, never a value: the key's bytes in a nix expression land in
the world-readable nix store, permanently.
'';
};
queue = {
clientSecretFile = lib.mkOption {
type = lib.types.str;
@ -734,10 +665,7 @@ in
services.hyperhive.swarm.otel.journaldUnits = [
"swarm-controller"
"swarm-controller-credential"
]
# Declared only where the unit exists — an entry for a unit that was
# never defined is a collector waiting on a journal that never speaks.
++ lib.optional (haveAgentCa && selfSignAgentCa) "swarm-controller-agent-ca";
];
users.users.swarm-controller = {
isSystemUser = true;
@ -824,18 +752,6 @@ in
state directory.
'';
}
{
assertion =
deployCfg.swarm-controller.agentCaFile == null || deployCfg.swarm-controller.agentCaKeyFile != null;
message = ''
services.hyperhive.deploy.swarm-controller.agentCaFile names an
authority but agentCaKeyFile is unset.
The controller does not merely trust this authority, it issues
agent client certificates from it, so it needs the private key.
Set both, or set neither and let the module mint its own.
'';
}
];
systemd.services.swarm-controller = {
@ -877,16 +793,7 @@ in
++ lib.optional (
haveBaoIdentity && deployCfg.bao.serverCaFile != null
) "bao-ca.pem:${deployCfg.bao.serverCaFile}"
++ lib.optional haveHiveClientCa "hive-client-ca.pem:${deployCfg.swarm-controller.hiveClientCaFile}"
# The agent authority, key included — same shape and same reason as
# the store identity above: the key is root-owned `0600` and this
# daemon runs as `swarm-controller`. `swarm-controller-agent-ca`
# below is `requiredBy` this unit, so the files exist by the time
# systemd resolves these.
++ lib.optionals haveAgentCa [
"agent-ca.pem:${agentCaCert}"
"agent-ca-key.pem:${agentCaKey}"
];
++ lib.optional haveHiveClientCa "hive-client-ca.pem:${deployCfg.swarm-controller.hiveClientCaFile}";
# The placeholder default that makes the above non-fatal.
# `LoadCredential=` takes priority over `SetCredential=`, so this is
@ -1059,50 +966,5 @@ in
ExecStart = "${pkgs.systemd}/bin/systemctl try-restart swarm-controller.service";
};
};
# The authority agent client leaves are issued from, minted here when the
# operator named none. Shape copied from ./glue-bao-tls.nix's
# `swarm-bao-pki`, including the rule that matters most:
#
# 🩸 Idempotent on ABSENCE, never on content. Re-issuing this CA would
# invalidate every agent leaf already published to the store AND every
# cert-auth role that pinned it by value, locking every agent container
# in the swarm out at once — on a rebuild that changed nothing an
# operator asked for.
#
# `before` + `requiredBy` rather than `after`: the daemon's
# `LoadCredential=` names these files by absolute path, and a
# `LoadCredential=` pointing at a file that is not there yet is fatal
# (`243/CREDENTIALS`), not a slow start.
systemd.services.swarm-controller-agent-ca = lib.mkIf (haveAgentCa && selfSignAgentCa) {
description = "mint the authority swarm agents' store certificates are issued from";
before = [ "swarm-controller.service" ];
requiredBy = [ "swarm-controller.service" ];
path = [
pkgs.openssl
pkgs.coreutils
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
set -euo pipefail
install -d -m 0700 ${agentCaDir}
if [ ! -s ${agentCaCert} ]; then
# `pathlen:0` — this authority signs leaves and nothing else. An
# intermediate under it would be a second issuer for the one name
# space the store matches agents by.
openssl req -x509 -newkey rsa:4096 -nodes -sha256 -days 3650 \
-keyout ${agentCaKey} -out ${agentCaCert} \
-subj "/CN=swarm-agent-ca ${swarmDomain}" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
chmod 0600 ${agentCaKey}
chmod 0644 ${agentCaCert}
fi
'';
};
};
}