The hive's bao policy grants read on swarm/agents/* and swarm/hives/<hive>/* only. Five comments across four modules still listed swarm/services/* among its reads, or said a shared leaf would let a unit fetch another service's OIDC client secret, or that the hive's leaf reads every secret in the store. Comment and option-description text only.
280 lines
14 KiB
Nix
280 lines
14 KiB
Nix
# Glue: this hive's agent queue credential comes out of the secret store.
|
||
#
|
||
# 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-logs` inside the agent reads the secret and the client id
|
||
# each from its own file (`<prefix>_OIDC_CLIENT_SECRET_FILE`,
|
||
# `<prefix>_OIDC_CLIENT_ID_FILE`), so splitting them here is what lets
|
||
# hive-c0re 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:
|
||
# 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. `deploy.bao.enable` would have been the co-location assumption
|
||
# itself; the reader needs a certificate, not a neighbour. On the store's own
|
||
# host ./glue-bao-tls.nix supplies one as a `mkDefault`; elsewhere an operator
|
||
# places the leaf and names it, and the same unit works. It also needs this
|
||
# host to run a hive (`deploy.hive-controller.enable`): the credential is for
|
||
# the agents hive-c0re creates here at runtime, and the unit writes under
|
||
# hive-c0re's own state directory.
|
||
{
|
||
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. Bao
|
||
# matches a cert-auth role on the CN, so one leaf shared by four readers
|
||
# would be ONE principal holding the union of four grants: read on
|
||
# `swarm/agents/*` AND `swarm/hives/<hive>/*`, 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;
|
||
|
||
# Does this host read the store at all — the hive's own leaf, which is the
|
||
# one thing a remote-store deployment has to place by hand. Only used to
|
||
# decide whether a missing per-principal leaf is a mistake or a deployment
|
||
# that has no store: a host holding neither is the supported no-store
|
||
# shape, and one holding this pair but not the pair above reads the store
|
||
# without a reader identity of its own.
|
||
hiveReaderIdentity = baoDeploy.clientCertFile != null && baoDeploy.clientKeyFile != 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 wherever
|
||
# `deploy.hive-controller.enable` is.
|
||
credentialPath = "secret/swarm/hives/${hyperhiveCfg.hiveName}/queue/agent";
|
||
|
||
atomicWriteSecret = import ./lib/atomic-write-secret.nix { };
|
||
storeRetry = import ./lib/store-retry.nix { };
|
||
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.mkMerge [
|
||
# ⚠️ A SEPARATE arm from the unit below, and that separation is the whole
|
||
# mechanism: the unit's arm is gated on `haveClientIdentity`, so an
|
||
# assertion written inside it could never be reached in the state it
|
||
# exists to report.
|
||
#
|
||
# Shaped after ./swarm-grafana.nix's `haveClientIdentity` assertion — the
|
||
# same refusal, named to this principal's own pair. Where Grafana's gate
|
||
# is `deploy.grafana.enable`, this reader's is the hive's own: every hive
|
||
# runs one for its own agents, so on a hive, reading the store at all is
|
||
# what asks for it. Hence `hiveReaderIdentity` — a host holding no hive
|
||
# leaf has no store to read and nothing is missing.
|
||
(lib.mkIf (deployCfg.hive-controller.enable && hiveReaderIdentity) {
|
||
assertions = [
|
||
{
|
||
assertion = haveClientIdentity;
|
||
message = ''
|
||
This host reads the swarm secret store (services.hyperhive.deploy.bao.clientCertFile
|
||
is set), so it needs the agent queue credential reader's own client
|
||
identity: set both
|
||
|
||
services.hyperhive.deploy.bao.queueAgentClientCertFile
|
||
services.hyperhive.deploy.bao.queueAgentClientKeyFile
|
||
|
||
swarm-bao-queue-agent.service fetches this hive's agent queue
|
||
credential out of the store, and without these it is not rendered
|
||
at all — leaving hive-c0re with no queue credential and no unit
|
||
that would ever have written one.
|
||
|
||
Every hive runs this reader for its own agents, so unlike the other
|
||
three principals there is no per-service toggle that turns it off:
|
||
a hive that reads the store at all is a hive that needs this pair.
|
||
|
||
⚠️ This reader's OWN leaf, not deploy.bao.clientCertFile. That one
|
||
is the hive's, and its grant reads every secret in the store; this
|
||
role reads the one queue-credential path. Pointing this option at
|
||
the hive's leaf would evaluate, deploy and log in — and undo the
|
||
split.
|
||
|
||
On a hive that runs the store, glue-bao-tls.nix supplies both as
|
||
defaults and there is nothing to do. Elsewhere the leaf is issued
|
||
from that CA out of band and named here — see docs/swarm/secrets.md.
|
||
'';
|
||
}
|
||
];
|
||
})
|
||
|
||
(lib.mkIf (deployCfg.hive-controller.enable && haveClientIdentity) {
|
||
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 one attempt
|
||
# (`TimeoutStartSec` below) rather than failing it — hive-c0re starts
|
||
# after that attempt with whatever credential is on disk, while
|
||
# ./lib/store-retry.nix keeps retrying in the background.
|
||
before = [ "hive-c0re.service" ];
|
||
wantedBy = [
|
||
"multi-user.target"
|
||
"hive-c0re.service"
|
||
];
|
||
path = [
|
||
baoDeploy.package
|
||
pkgs.coreutils
|
||
];
|
||
# ./lib/store-retry.nix.
|
||
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
|
||
serviceConfig = storeRetry.serviceConfig // {
|
||
Type = "oneshot";
|
||
RemainAfterExit = true;
|
||
# What actually bounds each attempt at 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;
|
||
};
|
||
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
|
||
|
||
${atomicWriteSecret}
|
||
|
||
# `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: 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}
|
||
|
||
atomic_write_secret 0600 "" ${lib.escapeShellArg secretFile} "$secret"
|
||
|
||
# `0644` on purpose: an OIDC client id is presented to the token
|
||
# endpoint on every connection and is public by construction.
|
||
atomic_write_secret 0644 "" ${lib.escapeShellArg clientIdFile} "$client_id"
|
||
'';
|
||
};
|
||
})
|
||
];
|
||
}
|