Four units read one path each out of the store, and all four logged in holding `deploy.bao.clientCertFile` — the hive's own leaf. Bao identifies a principal by the subject of the certificate it presents, so four readers behind one certificate were ONE principal, and the only grant expressible was the union of what the four need: read on `swarm/agents/*`, `swarm/hives/<hive>/*` and `swarm/services/*`. The unit fetching Grafana's OIDC client secret could fetch every agent credential in the swarm; the one fetching this hive's matrix token could fetch Grafana's. Least privilege was not misconfigured here, it was unrepresentable. Each now holds a leaf, a cert-auth role and a policy of its own, and each policy is the single `secret/data/…` path that unit's own script names — spelled to the leaf, not to a prefix, the way matrix-ctl's already is. Following the four exemplars in-tree rather than building a mechanism: `signLeaf` mints the leaves, `swarm-bao.nix` writes the roles from the bootstrap token, the consumers name their own pair. Two of the four are written PER HIVE and two are not, which is the shape of the paths rather than a preference. A matrix appservice token and a queue credential live under `swarm/hives/<name>/` and every hive runs a reader for its own, so one role for all of them would have to be granted `hives/*` — letting one hive read another's, a reach no hive has today. An OIDC client secret lives under `swarm/services/<client-id>/` and a swarm registers each exactly once, so one role each is enough. The per-hive subjects are `<prefix>-<hive>` and swarm.nix reserves every composed spelling as a hive name, so a hive cannot be named into another hive's role. The shared leaf stays: hive-c0re still passes it into its container, the `bao` CLI wrapper still defaults to it, and the three `glue-*-bao-identity.nix` files derive the PKI directory from it. module-eval-bao-grants gains a negative arm per principal — each pins the three stanzas the hive's leaf carried and the two wildcards a later widening would reach for, so a policy that grows fails here rather than in a store. Plus the consuming side: repointing a unit back at the hive's leaf would evaluate, deploy and log in, and silently restore the union. A hive that reads a store on another machine now places one leaf per principal instead of one shared by four. That cost is the point, and docs/swarm/secrets.md lists the pairs.
237 lines
11 KiB
Nix
237 lines
11 KiB
Nix
# Glue: this hive's agent queue credential comes out of the secret store.
|
||
#
|
||
# The store's second reader, and deliberately the same shape as its first
|
||
# (./glue-matrix-bao-token.nix): a cert login that fails LOUDLY because every
|
||
# state it fails on is one a retry fixes, then a read that degrades QUIETLY
|
||
# because no retry turns "no value there" into a value.
|
||
#
|
||
# ⚠️ Two files, not one, and that is the consumer's shape rather than a
|
||
# preference. `swarm_queue_client::QueueConfig::from_env` takes the secret as
|
||
# a PATH (`<prefix>_OIDC_CLIENT_SECRET_FILE`) and the client id as a VALUE
|
||
# (`<prefix>_OIDC_CLIENT_ID`), so splitting them here is what lets the next
|
||
# slice hand both to an agent without parsing anything.
|
||
#
|
||
# ⚠️ An ABSENT file means this hive's agents do not connect to the queue, and
|
||
# that is correct rather than degraded. The publisher runs on the authelia
|
||
# host and authelia mints on its first boot, so "nothing at that path yet" is
|
||
# the ordinary early state of a swarm. Nothing here writes a local stand-in:
|
||
# unlike a matrix appservice token there is no such thing as a locally valid
|
||
# OIDC client secret, so a placeholder would turn a hive that cannot connect
|
||
# into one that is refused, which reaches the agent as a timeout.
|
||
#
|
||
# 📌 This runs wherever a client identity is configured, NOT only where the
|
||
# store is — the rule ./glue-matrix-bao-token.nix states in full. There is no
|
||
# "this hive runs agents" condition to gate it on as well: agent containers
|
||
# are created at runtime by hive-c0re, so every hyperhive host is a host that
|
||
# may run one.
|
||
{
|
||
pkgs,
|
||
lib,
|
||
config,
|
||
...
|
||
}:
|
||
let
|
||
hyperhiveCfg = config.services.hyperhive;
|
||
deployCfg = hyperhiveCfg.deploy;
|
||
baoCfg = hyperhiveCfg.swarm.bao;
|
||
|
||
baoDeploy = deployCfg.bao;
|
||
|
||
# What decides whether this unit exists at all. A reader is defined by holding
|
||
# a certificate the store accepts, and that is true on the store's own host
|
||
# and on a hive three networks away for exactly the same reason.
|
||
#
|
||
# 🩸 This principal's OWN leaf, not `clientCertFile` — the hive's, which four
|
||
# units used to share. Bao matches a cert-auth role on the CN, so one leaf for
|
||
# four readers was ONE principal holding the union of four grants: read on
|
||
# `swarm/agents/*` AND `swarm/hives/<hive>/*` AND `swarm/services/*`, when
|
||
# this unit reads one queue credential and nothing else. Its own leaf carries
|
||
# `<queueAgentCommonNamePrefix>-<hive>` and its role grants the single path
|
||
# below — still this hive's own, so the narrowing costs no reach.
|
||
haveClientIdentity =
|
||
baoDeploy.queueAgentClientCertFile != null && baoDeploy.queueAgentClientKeyFile != null;
|
||
|
||
credentialDir = toString deployCfg.hive-controller.queue.agentCredentialDir;
|
||
secretFile = "${credentialDir}/secret";
|
||
clientIdFile = "${credentialDir}/client_id";
|
||
|
||
# Where the credential lives in the store, spelled from the same pieces the
|
||
# writer uses. Whoever writes it and whoever reads it must agree, and the
|
||
# agreement belongs in one visible place.
|
||
#
|
||
# ⚠️ The `hives/<name>` segment is not decoration — it is what the reader's
|
||
# own grant covers, so a path outside it is a 403 rather than a miss, however
|
||
# correct it looks. `swarm-secret-client`'s `queue::agent_client_path` builds
|
||
# the same string from the same pieces; this literal is the nix half of that
|
||
# one agreement.
|
||
#
|
||
# `hiveName` has no fallback here for the reason ./glue-bao-tls.nix gives at
|
||
# its own use of it: it is asserted set for every hyperhive host.
|
||
credentialPath = "secret/swarm/hives/${hyperhiveCfg.hiveName}/queue/agent";
|
||
in
|
||
{
|
||
options.services.hyperhive.deploy.hive-controller.queue = {
|
||
agentCredentialDir = lib.mkOption {
|
||
type = lib.types.path;
|
||
default = "/var/lib/hyperhive/queue-agent";
|
||
description = ''
|
||
Host directory holding this hive's agent queue credential: `secret`
|
||
(the OIDC client secret, `0600`) and `client_id` (the client that
|
||
secret authenticates, `0644` — it is sent to the token endpoint on
|
||
every connection and is not itself a secret).
|
||
|
||
Both files are written by `swarm-bao-queue-agent.service`, which also
|
||
creates this directory. Named here because nothing else creates it:
|
||
the parent `/var/lib/hyperhive` belongs to `hive-c0re.service`'s
|
||
`StateDirectory=`, which re-applies its own mode on every start, and a
|
||
second declaration of *that* path is the two-mechanisms-one-path trap
|
||
./hive-gateway/default.nix records — so this directory is the unit's
|
||
to make and the parent stays c0re's.
|
||
|
||
Absent files mean this hive has no queue credential yet, which is what
|
||
a swarm looks like before the publisher on the authelia host has run.
|
||
'';
|
||
};
|
||
};
|
||
|
||
config = lib.mkIf (hyperhiveCfg.enable && haveClientIdentity) {
|
||
# Same rule as the unit's own gate: this reader exists on any host holding
|
||
# a client identity, which is not every host that runs the store, so the
|
||
# store's module cannot name it.
|
||
services.hyperhive.swarm.otel.journaldUnits = [ "swarm-bao-queue-agent" ];
|
||
|
||
systemd.services.swarm-bao-queue-agent = {
|
||
description = "fetch this hive's agent queue credential from the swarm secret store";
|
||
# Every one of these names a unit that exists only where the store runs.
|
||
# `Requires=` on an absent unit fails the job outright, so the ordering is
|
||
# conditional even though the read is not: off-host there is nothing local
|
||
# to wait for, and the timeout below is what bounds the attempt instead.
|
||
after = lib.optionals baoDeploy.enable [
|
||
"swarm-bao-pki.service"
|
||
"container@${baoCfg.machine}.service"
|
||
];
|
||
wants = lib.optionals baoDeploy.enable [ "container@${baoCfg.machine}.service" ];
|
||
requires = lib.optionals baoDeploy.enable [ "swarm-bao-pki.service" ];
|
||
# Ordered before hive-c0re, so no agent container renders ahead of an
|
||
# attempt at its credential. `Wants=`, not `Requires=`: a store this
|
||
# unit can't reach delays hive-c0re's start by its own start-limit
|
||
# window (`TimeoutStartSec`, retried up to `startLimitBurst` times
|
||
# below) rather than failing it — hive-c0re starts once that window
|
||
# elapses, whatever credential is or isn't on disk by then.
|
||
before = [ "hive-c0re.service" ];
|
||
wantedBy = [
|
||
"multi-user.target"
|
||
"hive-c0re.service"
|
||
];
|
||
path = [
|
||
baoDeploy.package
|
||
pkgs.coreutils
|
||
];
|
||
# Sized for the race this loses, not for an unseal: `swarm-bao` comes up
|
||
# seconds before this unit asks, and the cert-auth role it logs in
|
||
# against is written seconds after, so a few short attempts cover it.
|
||
# An hours-long window would be a bet on a store that is sealed, and the
|
||
# degrade below is already correct for that.
|
||
#
|
||
# `StartLimit*` are `[Unit]` settings, so they go here and not in
|
||
# `serviceConfig` — systemd ignores them under `[Service]`. The window
|
||
# has to exceed `RestartSec × burst`.
|
||
startLimitBurst = 4;
|
||
startLimitIntervalSec = 300;
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
RemainAfterExit = true;
|
||
# What actually bounds the reads below. Stated here rather than left to
|
||
# systemd's default, so the number a boot waits on is in the file that
|
||
# waits.
|
||
TimeoutStartSec = 30;
|
||
Restart = "on-failure";
|
||
RestartSec = 15;
|
||
};
|
||
environment = {
|
||
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
||
BAO_CLIENT_CERT = baoDeploy.queueAgentClientCertFile;
|
||
BAO_CLIENT_KEY = baoDeploy.queueAgentClientKeyFile;
|
||
}
|
||
# Absent means the system trust store, which is what a deployment with a
|
||
# real CA wants and what a self-signed one must not be left with.
|
||
// lib.optionalAttrs (baoDeploy.serverCaFile != null) {
|
||
BAO_CACERT = baoDeploy.serverCaFile;
|
||
};
|
||
script = ''
|
||
set -euo pipefail
|
||
|
||
# `bao`'s own message is the only thing separating a missing value from
|
||
# a refused identity from an unreachable host. This unit's degraded
|
||
# mode is correct for all three, so it reports which one rather than
|
||
# asserting all three in a sentence of ours.
|
||
err="$(mktemp)"
|
||
trap 'rm -f "$err"' EXIT
|
||
|
||
# Cert auth is a login, not a transport setting. The `BAO_CLIENT_*`
|
||
# variables above only decide which certificate the TLS handshake
|
||
# presents; without a token `bao` asks its token helper instead, and
|
||
# that is a `sh` this unit's `path` does not carry. `-token-only`
|
||
# answers on stdout and skips the helper on both sides.
|
||
#
|
||
# Fails LOUDLY, unlike the reads below: the three states a login
|
||
# failure covers — store not up, sealed, role not written yet — are all
|
||
# things a retry fixes, and `Restart=on-failure` above is what retries.
|
||
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
|
||
echo "could not log in to swarm-bao with this host's certificate; leaving the queue credential in ${credentialDir} as it is." >&2
|
||
if [ -s "$err" ]; then
|
||
cat "$err" >&2
|
||
else
|
||
echo "bao failed without writing a diagnostic." >&2
|
||
fi
|
||
exit 1
|
||
fi
|
||
export BAO_TOKEN
|
||
|
||
# Two reads of one object rather than one `-format=json` parsed with
|
||
# `jq`: no sibling unit carries `jq` on its `path`, and the pair cannot
|
||
# actually disagree — the client id is derived from this hive's name, so
|
||
# a rotation landing between these two calls changes the secret and
|
||
# rewrites the same id.
|
||
if ! secret="$(bao kv get -field=value ${lib.escapeShellArg credentialPath} 2>"$err")"; then
|
||
echo "swarm-bao did not return ${credentialPath}; this hive's agents have no queue credential yet." >&2
|
||
if [ -s "$err" ]; then
|
||
cat "$err" >&2
|
||
else
|
||
echo "bao failed without writing a diagnostic." >&2
|
||
fi
|
||
exit 0
|
||
fi
|
||
|
||
if ! client_id="$(bao kv get -field=client_id ${lib.escapeShellArg credentialPath} 2>"$err")"; then
|
||
echo "${credentialPath} holds no client_id; the secret alone is not a usable credential, so nothing is written." >&2
|
||
if [ -s "$err" ]; then
|
||
cat "$err" >&2
|
||
else
|
||
echo "bao failed without writing a diagnostic." >&2
|
||
fi
|
||
exit 0
|
||
fi
|
||
|
||
# Both or neither, for the reason `QueueConfig::from_env` refuses a
|
||
# half-set environment: a client that finds one of the two comes up
|
||
# "fine" and never connects.
|
||
if [ -z "$secret" ] || [ -z "$client_id" ]; then
|
||
echo "swarm-bao returned an empty field of ${credentialPath}; leaving the files as they are." >&2
|
||
exit 0
|
||
fi
|
||
|
||
install -d -m 0755 ${lib.escapeShellArg credentialDir}
|
||
|
||
umask 077
|
||
printf '%s\n' "$secret" > ${lib.escapeShellArg secretFile}
|
||
chmod 0600 ${lib.escapeShellArg secretFile}
|
||
|
||
# `0644` on purpose: an OIDC client id is presented to the token
|
||
# endpoint on every connection and is public by construction.
|
||
printf '%s\n' "$client_id" > ${lib.escapeShellArg clientIdFile}
|
||
chmod 0644 ${lib.escapeShellArg clientIdFile}
|
||
'';
|
||
};
|
||
};
|
||
}
|