credentials.md drops the target-shape framing and keeps the rules, where a secret lives, the swarm- and hive-level secrets and how to add one. The queue responder's keys, per-principal identities, how a reader reaches the store, the store UI's login and where the root lives move to 4-implementation/swarm/credentials.md. The per-secret reference moves to 4-implementation/swarm/credentials-reference.md. Links and comments follow.
17 KiB
Credentials: mechanics
The store's identities, the queue responder's keys, how a reader reaches the store, the store UI's login and where the root lives. The overview and the rules a credential follows are in credentials; each credential's minter, reader and renewal are in the reference.
The queue's callout nkeys
The responder holds two nkeys besides the callout xkey below. It signs every user JWT it issues with the issuer key, an account key, so possession of the issuer seed is the authority to admit anyone to the queue. It connects to the queue as the user key, the one identity the server exempts from callout. You configure nothing about either: both live in the swarm secret store beside the xkey, and come into being on the queue's first boot.
- Issuer seed at
secret/swarm/services/swarm-nats/callout/issuer-seed, public half atsecret/swarm/public/swarm-nats/callout-issuer. - User seed at
secret/swarm/services/swarm-nats/callout/user-seed, public half atsecret/swarm/public/swarm-nats/callout-user.
The unit that creates the xkey creates these too, under the same rules: only
the swarm-nats-auth role reads or creates them, nothing replaces a stored
seed, and the policy of swarm-secret-publisher denies it both seed paths.
The server renders both public halves into its auth_callout block as it
starts, and doesn't start without them.
A seed file at /var/lib/swarm-nats-callout/issuer.seed or
/var/lib/swarm-nats-callout/callout-user.seed on the queue's host is what
swarm-nats-auth ensure-callout-keys stores for that key when the store holds
none, so the key keeps its public half. Once a read of the store returns a
seed, the verb removes the file. When the store already holds a different
seed, the verb keeps the store's seed, logs a warning naming the file, then
removes it.
A hive that sets autoGenerateCallout, calloutUserPublicKey,
calloutIssuerPublicKey, calloutUserSeedFile or calloutIssuerSeedFile,
under deploy.nats or swarm.nats, fails at eval with a message naming the
option and where the keys live now.
To replace either key, delete both of its paths the way the xkey's section
below shows for the xkey, then restart swarm-bao-nats-callout-xkey.
One consequence worth knowing before you debug it: the queue assembles its config each time the server starts rather than at build time, in every mode, because the callout keys exist only at runtime. A malformed config surfaces when the server starts instead of when the system builds. The server names the offending file and refuses to run.
The queue's callout xkey
The server seals every auth-callout request to a curve key, the callout xkey, and the responder seals its reply back, so nothing else in the callout account or on the wire can read the request or the user JWT minted for it. You configure nothing about the key: it lives in the swarm secret store and comes into being on the queue's first boot.
- Seed at
secret/swarm/services/swarm-nats/callout/xkey-seed. - Public half at
secret/swarm/public/swarm-nats/callout-xkey.
Only the swarm-nats-auth role reads either path, and it may create each but
never replace it. The policy of swarm-secret-publisher, which writes the rest
of swarm/services/, denies it the seed's path.
On the queue's host, swarm-bao-nats-callout-xkey runs before the
swarm-nats container, under the responder's store leaf:
swarm-nats-auth ensure-callout-keyscreates the pair when the store holds no seed, and otherwise only reads it. It needs no queue, so on a first boot the key exists before the server that needs it.- It reads the public half back from the store into
/var/lib/nats-callout/callout-xkey.pubinside the container. - Each time
nats-serverstarts, it renders that key intoauth_callout.xkey. With no file, the server doesn't start. - The responder starts after the server and reads the seed from the store. A responder that can't read it doesn't start, rather than answering requests it can't open.
The store has to be reachable each time the responder starts. When it's down, the fetch unit fails and retries for a day. The server starts with the key fetched last time, or not at all on a first boot. A responder that starts while the store is down exits and retries every 5 seconds, and until one start reads the seed the queue refuses every client, OIDC ones included. The retry that lands the file starts or restarts the server, and the responder comes up with it.
The responder's store leaf is deploy.nats.authBaoClientCertFile /
authBaoClientKeyFile. On the store's host both default to the leaf
glue-bao-tls.nix mints there. A queue host without the store gets no
default, even when it holds the hive's own reader deploy.bao.clientCertFile:
issue a leaf whose subject is deploy.bao.natsAuthCommonName, place it, and
name it in both options. Until you do, evaluation fails naming both.
Where the queue and the store run on different hosts, deploy the store's host
first. swarm-bao-nats-auth-policy there writes the role's grants on the
keys' paths, and swarm-bao-secret-publisher-policy the publisher's denial of
the seeds' paths; where the queue runs on that host too, the fetch waits for
both. A queue host deployed before it can't read or create the keys, so its
fetch fails, and the queue refuses every client until you deploy the store's
host: the server starts only on public keys fetched before, and the responder
can't load its seeds. Seed files the import is waiting for stay where they
are. If that takes longer than the fetch's day of retries, run
systemctl restart swarm-bao-nats-callout-xkey on the queue's host.
Two starts that create the key at once can't both win: each write is a create that fails once the path holds anything, and the start that lost reads the winner's key back.
To replace the key, remove both paths with their metadata and every version,
then restart the fetch unit on the queue's host. Its restart of nats.service
restarts the responder too, which then loads the new seed:
bao kv metadata delete secret/swarm/services/swarm-nats/callout/xkey-seed
bao kv metadata delete secret/swarm/public/swarm-nats/callout-xkey
systemctl restart swarm-bao-nats-callout-xkey
bao kv delete isn't enough. It deletes the latest version but keeps the
path's metadata, so the store still counts the path as existing and refuses
the create that makes a new key. No principal hyperhive configures may
delete either path's metadata, so run the two deletes with a token that may,
such as the root token.
Per-principal identities
Bao matches a cert-auth role on the certificate's subject, so a certificate is an identity and sharing one merges the identities. The leaves, their option names and what each reaches are in the reference.
Each reaches a path that names a swarm service rather than a hive, so one role
each is enough and their subjects are the flat
deploy.bao.grafanaOidcCommonName, deploy.bao.otelOidcCommonName,
deploy.bao.forwarderOidcCommonName, deploy.bao.ciRunnerCommonName,
deploy.bao.reviewClassifierCommonName, deploy.bao.builderCommonName,
deploy.bao.ciMetricsCommonName, deploy.bao.atticCommonName, deploy.bao.archiveCommonName,
deploy.bao.claudeLoginCommonName and deploy.bao.forgePublisherCommonName.
The last three are the writers, and the last is the one leaf two units share:
the forge's host holds one store identity for the token it publishes and the
OIDC client secret it reads.
On a host that mints its own PKI, glue-bao-tls.nix signs all ten and defaults
all twenty options, and there is nothing to do. Elsewhere you copy each leaf from
the store's host and name it here: a service principal's from the top of
/var/lib/swarm-bao-pki/, another hive's leaves from
/var/lib/swarm-bao-pki/hives/<hive>/ (bao.md § TLS lists that
directory). That's one file per principal rather than one file
shared by ten, which is the whole of what this buys. swarm-ci-runner.nix
refuses the build wherever the runner runs without its pair, as
swarm-review-classifier.nix does for the classifier, swarm-ci-metrics.nix
for the CI run poller and swarm-grafana.nix for Grafana. The forge's pair refuses nothing: without
it forgejo-swarm-controller-account and swarm-bao-forge-oidc render, fail at start
naming both options, and the controller runs without forge access and the forge
without a login source until an operator places the leaf.
On a host without the store, where the forwarder never runs, forgetting one of
the others isn't a quiet degrade. A hand-configured remote-store hive that
names the hive's own clientCertFile and misses a principal's pair has the
build refused, by the module of the reader it missed, naming that pair — see
what a missing leaf costs below.
The roles of swarm-nats-auth, swarm-logs-auth and swarm-metrics-auth issue
batch tokens with a five-minute TTL (token_type=batch, token_ttl=5m). The
responders log in on every check, and the store keeps no record of a batch token,
so unauthenticated requests don't grow its token storage. Nothing can renew
or revoke a batch token.
The other roles issue service tokens that live an hour (token_ttl=1h and
token_max_ttl=1h). That covers the roles swarm-bao.nix writes, and the
agent and hive roles the controller writes through
SecretStore::write_cert_role, which takes the same value from
CERT_ROLE_TOKEN_TTL. The store keeps a lease for every token until it
expires, and the mount default of 32 days leaves one lease per login for a
month; at the 17 thousand logins a day this swarm made, that came to half a
million leases. A reader logs in per operation and holds the token for seconds.
The longest hold is the controller's forge work, which stops after two minutes.
A client that keeps a SecretStore handle longer than an hour must log in
again, because nothing renews the token. A role the controller writes takes the
TTL at its next write: a hive's at the controller's start, an agent's at its
next certificate mint or renewal. At its start the controller also reads every
existing hive-agent-* role and, for one whose token_ttl or token_max_ttl
differs from the TTL, writes those two fields and no others. The store keeps the
fields a write leaves out, so the role's certificate, allowed names and
policies stay. A role already at the TTL isn't written. The start logs one line
with rewritten, already_current and failed counts, and a role that fails
gets a warning that names it, then the pass moves on. Tokens issued before
expire on their own; the controller revokes nothing. At that rate an hour keeps
about 700 tokens live.
The granter's role keeps 15 minutes. Only the one-time bootstrap token writes it, and a deployed store never rewrites it, so the longer TTL would not reach it; the granter logs in and finishes within seconds.
How a reader reaches the store
Every reader dials the same URL — https://bao.<swarm domain>:<port> — and on
the host that runs the store that name resolves twice. /etc/hosts answers a
host-side unit with loopback, where openbao binds; the hive's dnsmasq answers a
container with the bridge IP, where nginx holds the same port. One BAO_ADDR,
and the network namespace that asked decides which half of it answers.
The container's half goes through nginx and still doesn't terminate TLS. It
is an nginx stream server with ssl_preread on: it reads the SNI off the
ClientHello and splices the rest of the connection through byte for byte, so
openbao completes the handshake itself. A vhost would decrypt here instead, and
the client certificate — the store's whole authentication — would stop at nginx,
leaving openbao seeing one client for every hive in the swarm. That's why the
store has no vhost and gets a passthrough: not an exception to the no-vhost
rule, the one shape that keeps it.
The stream listener binds the bridge IP rather than every address, because
openbao already holds loopback on that port in the same network namespace and a
wildcard bind would fail with EADDRINUSE — taking nginx, and with it every
hive domain behind the gateway, down. network.exposeHostPorts opens the port
on the bridge firewall and nowhere else.
Reaching the port grants nothing by itself: openbao answers nothing without a
client certificate signed by deploy.bao.clientCaFile or by the store's own
agent CA. The listener reads both from listener-client-ca.pem, which no
cert-auth role pins. The passthrough carries
whichever certificate the reader presents, unchanged.
OIDC login to the store UI
The OIDC tab logs in through authelia as the client swarm-bao-ui
(swarm.bao.ui.oidc.clientId). An admins member gets a token under
swarm-operator-viewer: list and read on secret/metadata/*, and
create and update on
secret/data/swarm/services/review-classifier/anthropic-api-key,
secret/data/swarm/services/review-classifier/acp-provider and the cache's five
leaves under secret/data/swarm/services/attic/ (push-token, pull-token,
url, cache and public-key). That shows the key tree and each key's
versions and timestamps, and no value: the store refuses every secret/data/
read. The writes are the leaves the operator fills in by hand: the review
classifier's keys, and the cache's tokens and facts, which configure an external
cache or replace the in-swarm cache's own. The cache's host units create those
leaves only while the store holds none, so a saved value survives their next run
and every deploy. The cache's signing key, attic/jwt-secret, isn't writable here.
Anything more needs a token.
To replace one of the cache's leaves in the UI, open secret > swarm >
services > attic, pick the leaf and choose Create new version. The
form starts empty because the login can't read the current value: type the
key value and the new value, and save. A new version replaces the whole
secret, so value is the only key to enter. bao kv put with the same login
works too.
| piece | written by |
|---|---|
authelia client, with redirect swarm.bao.ui.oidc.redirectUri |
glue-bao-ui-oidc-client.nix, wherever authelia runs |
its secret at swarm/services/swarm-bao-ui/oidc/client |
authelia's mint, like every other service's |
the oidc auth mount |
swarm-bao-granter-role, with the bootstrap token |
swarm-operator-viewer policy, the mount's config and role swarm-operator-viewer |
swarm-bao-operator-viewer-policy, as the granter, which reads the secret above into the config |
The granter holds no sys/auth, so enabling the mount stays a bootstrap-token
step. A store set up before the mount existed needs the
one-time granter step again: it re-writes
bao-bootstrap, which covers sys/auth/oidc, and the granter's own
policy, which covers auth/oidc/. Until then
swarm-bao-operator-viewer-policy writes the viewer policy, logs the step, and
exits 0, and the UI offers token login only. swarm-bao-granter-role re-runs
it each time the step succeeds, so OIDC login needs no further restart.
The constraint that decides where the root lives
The store issues a hive's gateway leaf through a role whose allowed_domains is
that hive's domain and *. that domain, and a swarm service name is a sibling of
the hive domain rather than a child — forge.<swarm> next to <hive>.<swarm>.
That role can't issue a certificate for a swarm service; the store refuses it.
The store's swarm-services role issues them instead, from the root inside its
pki mount. That root's private key never leaves the mount, and its
certificate is the only anchor the swarm has. Each hive fetches the certificate
from the store at boot, so nothing build-time can name it — the system reads
security.pki.certificateFiles' value at build time, which makes it the wrong
tool here. Host containers get a bind-mounted bundle assembled at boot; an
agent fetches the root itself with its own store certificate.