swarm-controller: accept an agent's external matrix account and put it in the store

The swarm UI had nowhere to POST an external matrix account to: this daemon
had no matrix-account code at all and no `swarm-secret-client` dependency, so
the last leg of #3726 — a credential reaching an agent — had no entry point.

`PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` writes the
credential to the store under the agent's own path and publishes a
`CredentialNotice` on that hive's credential subject. All three path names are
load-bearing: agent + account locate the secret, hive routes the notice. The
account is a path segment rather than a body field so that splitting the 1:1
account-to-agent mapping later is a new route, not a changed payload.

Store first, notify second, and the order cannot be swapped: a notice that
overtakes its own write reaches a hive that reads nothing, and the hive
deliberately does not retry. The publish is followed by a flush for the reason
`publish_deploy` flushes — `publish` hands the message to the connection's
write buffer and returns, so the response could otherwise outrun the notice it
reports as sent.

The store client is built per request rather than held in `AppState`, matching
what the hive side does inside `deliver`: a login that expires is not worth
caching for a route this cold.

`swarm_hive` is `declaration_target`'s two name checks, extracted so this
handler makes them identically rather than in a second copy free to drift.
`declaration_target` still tests the writer first, so a deployment with no
queue answers 503 whatever the caller spelled.

## The nix half

#4081 minted the controller's leaf and gave it `baoClientCertFile` /
`baoClientKeyFile`, deliberately stopping there — the leaf is minted whether or
not a controller runs on that host. Nothing consumed those options, so the
identity never reached the process. Measured before writing: `git grep
baoClientCertFile` returned 5 sites and zero consumers, against a control
(`tokenEndpoint`, 4 hits in the same file) proving the search can see
consumption where it exists.

The unit now gets `BAO_ADDR` / `BAO_CLIENT_CERT` / `BAO_CLIENT_KEY` /
`BAO_CACERT` and the matching `LoadCredential` entries, following
`hive-c0re/environment.nix`'s `%d` credential shape.

The gate is `deploy.swarm-controller.baoClientCertFile`, NOT
`deploy.bao.clientCertFile`. The latter is the hive reader's identity and its
policy scopes a hive's own secrets; wiring it here would evaluate, deploy, and
fail only when the daemon tried to write an agent's credential.

Two `module-eval` arms cover exactly that. The presence arm asserts the
`LoadCredential` *source path* (`…:/var/lib/swarm-bao-pki/controller.pem`) and
not just the `%d` name, because a `%d`-only assertion passes while the daemon
holds the wrong policy. The absence arm (`controllerNoStore`) is what makes the
presence arm mean anything.

`RestrictAddressFamilies` already covers the store client; its own comment asks
for the family to be added with the client, and AF_INET/AF_INET6 are present.

Contributes to #3726
This commit is contained in:
atlas 2026-09-08 13:52:53 +02:00 committed by mara
commit 0d88ca5e7f
6 changed files with 252 additions and 14 deletions

View file

@ -18,6 +18,37 @@ let
deployCfg = config.services.hyperhive.deploy;
autheliaCfg = config.services.hyperhive.swarm.authelia;
# Where the secret store is, and whether this host holds the controller's
# own leaf for it. ⚠️ The controller's pair, NOT `deploy.bao.clientCertFile`
# — that one is the hive reader's, and its policy scopes a hive's own
# secrets. The two are deliberately separate identities.
#
# Gated on the leaf, never on `deploy.bao.enable`: a controller three
# networks away from the store holds a leaf issued out of band and wants
# exactly this wiring. Same rule ./glue-controller-bao-identity.nix states
# for the paths themselves.
baoCfg = config.services.hyperhive.swarm.bao;
haveBaoIdentity =
deployCfg.swarm-controller.baoClientCertFile != null
&& deployCfg.swarm-controller.baoClientKeyFile != 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
# not the paths themselves: see the `LoadCredential` below.
baoEnv =
lib.optionalAttrs haveBaoIdentity {
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
BAO_CLIENT_CERT = "%d/bao-client.pem";
BAO_CLIENT_KEY = "%d/bao-client-key.pem";
}
// lib.optionalAttrs (haveBaoIdentity && deployCfg.bao.serverCaFile != null) {
# Absent means the system trust store — right for a real CA, wrong for
# the self-signed one ./glue-bao-tls.nix mints, which is why that file
# names this path rather than leaving it to a default.
BAO_CACERT = "%d/bao-ca.pem";
};
# What `swarmctl` needs in order to act on authelia from the host.
#
# Only set when authelia actually runs **here**: the controller can be
@ -645,7 +676,17 @@ in
]
++ lib.optional (
deployCfg.swarm-controller.forgeTokenFile != null
) "forge-token:${deployCfg.swarm-controller.forgeTokenFile}";
) "forge-token:${deployCfg.swarm-controller.forgeTokenFile}"
# The store identity, same shape and same reason as hive-c0re's: the
# key is root-owned `0600` and this daemon runs as `swarm-controller`,
# so it never gets read access to the original file.
++ lib.optionals haveBaoIdentity [
"bao-client.pem:${deployCfg.swarm-controller.baoClientCertFile}"
"bao-client-key.pem:${deployCfg.swarm-controller.baoClientKeyFile}"
]
++ lib.optional (
haveBaoIdentity && deployCfg.bao.serverCaFile != null
) "bao-ca.pem:${deployCfg.bao.serverCaFile}";
# The placeholder default that makes the above non-fatal.
# `LoadCredential=` takes priority over `SetCredential=`, so this is
@ -770,6 +811,7 @@ in
// webhookEnv
// authBridgeEnv
// swarmNameEnv
// baoEnv
// otelEnv;
};

View file

@ -570,6 +570,42 @@ let
in
c.baoClientCertFile == null && c.baoClientKeyFile == null;
}
{
# Being *pointed at* a leaf and *being handed* one are different claims,
# and the options above were the first without the second — declared,
# defaulted, and read by nothing. This is the arm that makes them reach
# the process.
#
# ⚠️ The LoadCredential source is asserted, not just the `%d` name: the
# controller's leaf and the hive reader's are two identities with two
# policies, and wiring `deploy.bao.clientCertFile` here would satisfy
# every `%d`-only check while giving the daemon a policy that cannot
# write an agent's credential.
name = "the controller is handed its own store leaf, not the hive reader's";
ok =
let
s = baoControllerHere.systemd.services;
in
s ? swarm-controller
&& (s.swarm-controller.environment ? BAO_ADDR)
&& (s.swarm-controller.environment.BAO_CLIENT_CERT or null) == "%d/bao-client.pem"
&& (s.swarm-controller.environment.BAO_CLIENT_KEY or null) == "%d/bao-client-key.pem"
&& builtins.elem "bao-client.pem:/var/lib/swarm-bao-pki/controller.pem" s.swarm-controller.serviceConfig.LoadCredential
&& builtins.elem "bao-client-key.pem:/var/lib/swarm-bao-pki/controller-key.pem" s.swarm-controller.serviceConfig.LoadCredential;
}
{
# Absence arm for the one above, and what makes it mean anything: a
# controller with no leaf gets no store environment at all rather than
# variables naming files this host never receives.
name = "a controller with no store leaf is given no store environment";
ok =
let
s = controllerNoStore.systemd.services;
in
s ? swarm-controller
&& !(s.swarm-controller.environment ? BAO_ADDR)
&& !(lib.any (c: lib.hasPrefix "bao-" c) s.swarm-controller.serviceConfig.LoadCredential);
}
{
# The CN is an interface between two files: the store writes a role that
# matches it, the PKI mints a leaf that carries it. They read one option,