swarm: OIDC clients registered wherever authelia runs; nats + matrix secrets from bao
The three client registrations move into glue-{controller,nats,matrix}-oidc-client.nix,
gated only on deploy.authelia.enable and reading their ids from swarm.* options,
so the host running authelia registers every one of them whichever host runs
the service. The matrix callback is a new read-only swarm.matrix.sso.redirectUri,
read by both the homeserver's config and its client entry.
swarm-secret-publish now also publishes the queue's and the homeserver's minted
secrets to swarm/services/<id>/oidc/client. The queue host reads its responder's
secret there with swarm-bao-nats-auth-oidc under the responder's own leaf
(swarm-nats-auth role gains that one read path), and the homeserver's host with
swarm-bao-matrix-oidc under matrix-ctl's leaf (swarm-matrix-ctl role gains its
one read path). The host-file copies are gone: swarm-nats-auth-secrets now
delivers only the callout seeds and the store identity, and
hive-matrix-oidc-secret is removed. A fetch unit whose leaf is unset still
renders and fails naming the option pair.
Closes #4943
This commit is contained in:
parent
72d0d8ccba
commit
962da3b784
20 changed files with 702 additions and 293 deletions
|
|
@ -57,7 +57,10 @@ let
|
|||
responderConfigured =
|
||||
deployCfg.nats.autoGenerateCallout
|
||||
|| (deployCfg.nats.calloutUserSeedFile != "" && deployCfg.nats.calloutIssuerSeedFile != "");
|
||||
clientSecretSource = "${deployCfg.authelia.hostClientSecretDir}/${cfg.clientId}.secret";
|
||||
# Where swarm-secret-publish on authelia's host leaves this client's secret,
|
||||
# composed from the same swarm-wide client id it publishes under. The
|
||||
# responder's store role grants read on this one path.
|
||||
storeSecretPath = "secret/swarm/services/${cfg.clientId}/oidc/client";
|
||||
introspectionUrl = "${toString autheliaUrl}/api/oidc/introspection";
|
||||
|
||||
# Where the responder's seeds actually come from. One name for two
|
||||
|
|
@ -234,6 +237,10 @@ let
|
|||
);
|
||||
authCredential = id: "/run/credentials/swarm-nats-auth.service/${id}";
|
||||
|
||||
atomicWriteSecret = import ./lib/atomic-write-secret.nix { };
|
||||
refreshConsumer = import ./lib/refresh-consumer.nix { };
|
||||
storeRetry = import ./lib/store-retry.nix { };
|
||||
|
||||
# Re-issue once the leaf is past half of the 720h `pki/roles/swarm-nats`
|
||||
# grants it (./swarm-bao.nix), so the daily timer below has two weeks of
|
||||
# retries before it lapses.
|
||||
|
|
@ -430,13 +437,17 @@ in
|
|||
example = "/var/lib/swarm-bao-pki/nats-auth.pem";
|
||||
description = ''
|
||||
Client certificate the auth-callout responder presents to the swarm's
|
||||
secret store to read agent queue credentials. Its subject must be
|
||||
secret store to read agent queue credentials, and that
|
||||
`swarm-bao-nats-auth-oidc` presents to read the responder's own OIDC
|
||||
client secret. Its subject must be
|
||||
{option}`services.hyperhive.deploy.bao.natsAuthCommonName`.
|
||||
|
||||
No default. ./glue-nats-auth-bao-identity.nix points it at the leaf
|
||||
./glue-bao-tls.nix mints, where this host mints one. Unset, or set to
|
||||
a file that does not exist, the responder denies every agent token and
|
||||
admits OIDC clients as before.
|
||||
./glue-bao-tls.nix mints, where this host mints one; on any other
|
||||
queue host it is a file the operator places. Unset, or set to a file
|
||||
that does not exist, `swarm-bao-nats-auth-oidc` fails; a responder
|
||||
with no OIDC client secret on disk cannot start, and the queue then
|
||||
refuses every client.
|
||||
|
||||
A path, never a value.
|
||||
'';
|
||||
|
|
@ -532,33 +543,6 @@ in
|
|||
}
|
||||
];
|
||||
|
||||
# One declaration, two readers. The queue knows which client id it
|
||||
# authenticates under; making the operator restate it in authelia's
|
||||
# client list would be a second source of truth for a string whose
|
||||
# mismatch is an opaque 401 from the token endpoint.
|
||||
#
|
||||
# `client_credentials` rather than an authorization-code flow: a
|
||||
# queue's clients are daemons with nobody to redirect, so a
|
||||
# non-interactive grant is what makes a hive able to authenticate at
|
||||
# all. No redirect URI exists or is wanted.
|
||||
#
|
||||
# ⚠️ `kind` says that, and an empty `redirectUris` does NOT. This
|
||||
# declaration carried the comment above while rendering as an
|
||||
# interactive client, because authelia permits only the grants a
|
||||
# client names and an omitted `grant_types` means authorization-code
|
||||
# alone. Measured against 4.39.20: the token endpoint answered
|
||||
# `unauthorized_client: The OAuth 2.0 Client is not allowed to use
|
||||
# authorization grant 'client_credentials'`. Introspection — which
|
||||
# is all the responder needs today — worked throughout, which is why
|
||||
# nothing was visibly broken while the comment was untrue.
|
||||
services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf deployCfg.authelia.enable [
|
||||
{
|
||||
id = cfg.clientId;
|
||||
description = "HyperHive swarm queue";
|
||||
kind = "machine";
|
||||
}
|
||||
];
|
||||
|
||||
# Declared here rather than in the collector's module: an entry then
|
||||
# exists only where the service that named it runs. Gated on a
|
||||
# collector, because a target nobody reads asserts a collection that is
|
||||
|
|
@ -882,17 +866,16 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
# Deliver the responder's three credentials into the container before
|
||||
# it starts. Same shape as `hive-matrix-oidc-secret`, and for the same
|
||||
# reason it is a copy rather than a `bindMounts` entry: nixos-container
|
||||
# refuses to start when a bind source is missing, so one absent seed
|
||||
# would take down the **whole container including the queue**, not
|
||||
# merely the responder. A far larger blast radius than the fault.
|
||||
# Order the container after the host CA generator, so the bind source
|
||||
# exists before nspawn sets the mount up. Without it a late CA fails the
|
||||
# container start outright rather than degrading.
|
||||
systemd.services."container@${machine}" = caTrust.containerOrdering;
|
||||
|
||||
# Deliver the responder's seeds and store identity into the container
|
||||
# before it starts. A copy rather than a `bindMounts` entry:
|
||||
# nixos-container refuses to start when a bind source is missing, so one
|
||||
# absent seed would take down the **whole container including the queue**,
|
||||
# not merely the responder.
|
||||
systemd.services.swarm-nats-auth-secrets = lib.mkIf responderConfigured {
|
||||
description = "deliver the swarm queue responder's credentials";
|
||||
before = [ "container@swarm-nats.service" ];
|
||||
|
|
@ -903,13 +886,6 @@ in
|
|||
# or absent seed is worse than not running.
|
||||
after =
|
||||
lib.optional deployCfg.nats.autoGenerateCallout "swarm-nats-callout-keys.service"
|
||||
# The third credential does not come from the generator above — it is
|
||||
# minted by authelia's FIRST BOOT, inside its own container. Ordering
|
||||
# after that container is necessary and NOT sufficient: the container
|
||||
# being up says nothing about whether its in-container secrets unit
|
||||
# has finished. The wait in the script is what actually closes it;
|
||||
# this only stops us spinning for the full timeout on every boot.
|
||||
++ lib.optional deployCfg.authelia.enable "container@${autheliaCfg.machine}.service"
|
||||
# Where the store's PKI is minted on this host, the responder's leaf is
|
||||
# one of its files. Ordering only: elsewhere the unit does not exist.
|
||||
++ lib.optional authStoreActive "swarm-bao-pki.service";
|
||||
|
|
@ -917,14 +893,6 @@ in
|
|||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
# Must exceed the script's own wait below, and is set rather than
|
||||
# left to the default for exactly that reason: systemd's
|
||||
# `DefaultTimeoutStartSec` is 90s, so a 120s wait would be killed at
|
||||
# 90 and the operator would get a generic unit timeout instead of
|
||||
# the message that names the missing file and says what to do.
|
||||
# The bound that matters belongs in one place, and this makes the
|
||||
# two visibly related.
|
||||
TimeoutStartSec = "180s";
|
||||
SyslogIdentifier = "swarm-nats-auth-secrets";
|
||||
};
|
||||
path = [ pkgs.coreutils ];
|
||||
|
|
@ -935,38 +903,10 @@ in
|
|||
${lib.escapeShellArg (hostPath "callout-user.seed")}
|
||||
install -m 0400 ${lib.escapeShellArg issuerSeedFile} \
|
||||
${lib.escapeShellArg (hostPath "issuer.seed")}
|
||||
# Wait for authelia's minted secret rather than failing the instant
|
||||
# it is absent. On a fresh boot this unit and authelia's first-boot
|
||||
# generator race, and losing that race used to cost the WHOLE QUEUE:
|
||||
# this exits 1, the responder never starts, and `auth_callout` with
|
||||
# no responder refuses every client — fail-closed by design, so the
|
||||
# symptom appears on every queue client and nowhere near the cause.
|
||||
#
|
||||
# Bounded, not indefinite. Where authelia runs on another host the
|
||||
# file is never going to appear, and blocking the queue container
|
||||
# forever would replace a clear failure with a hang. After the
|
||||
# timeout this fails exactly as it did before, having first given
|
||||
# the co-located case the seconds it actually needs.
|
||||
secret=${lib.escapeShellArg clientSecretSource}
|
||||
deadline=$(( SECONDS + 120 ))
|
||||
while [ ! -s "$secret" ]; do
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
echo "the swarm queue responder's OIDC secret never appeared at $secret" >&2
|
||||
echo "(authelia mints it on first boot; if authelia runs on another host," >&2
|
||||
echo " copy the secret there and this unit will pick it up)" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
install -m 0400 "$secret" \
|
||||
${lib.escapeShellArg (hostPath "oidc-client.secret")}
|
||||
''
|
||||
# The store identity is optional to the responder, so an absent source
|
||||
# is delivered as an empty file rather than failing this unit: a missing
|
||||
# `LoadCredential` source stops the responder starting, and a responder
|
||||
# that does not start denies every client. An empty identity fails only
|
||||
# the store login, which denies agent tokens.
|
||||
# The store identity is optional to the responder's agent-token path, so
|
||||
# an absent source is delivered as an empty file rather than failing this
|
||||
# unit: a missing `LoadCredential` source stops the responder starting.
|
||||
+ lib.concatStrings (
|
||||
lib.mapAttrsToList (id: source: ''
|
||||
if [ -s ${lib.escapeShellArg source} ]; then
|
||||
|
|
@ -979,6 +919,117 @@ in
|
|||
);
|
||||
};
|
||||
|
||||
# The responder's OIDC client secret, out of the swarm secret store under
|
||||
# the responder's own leaf — the one route, wherever authelia runs. Same
|
||||
# shape as ./swarm-grafana.nix's `swarm-bao-grafana-oidc`: a login that
|
||||
# fails loudly, since a retry fixes every state it fails on, then a read
|
||||
# that degrades quietly, since an absent value is "not published yet".
|
||||
#
|
||||
# ⚠️ Without the secret the responder does not start (its `LoadCredential`
|
||||
# source is missing), and `auth_callout` with no responder refuses every
|
||||
# client. A host with no responder leaf therefore fails here, naming the
|
||||
# options, rather than rendering no unit.
|
||||
systemd.services.swarm-bao-nats-auth-oidc = {
|
||||
description = "fetch the swarm queue responder's OIDC client secret from the swarm secret store";
|
||||
# The store's units exist only where the store runs; `Requires=` on an
|
||||
# absent unit fails the job, so off-host the timeout below bounds the
|
||||
# attempt instead.
|
||||
after = [
|
||||
"swarm-nats-auth-secrets.service"
|
||||
]
|
||||
++ 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" ];
|
||||
before = [ "container@${machine}.service" ];
|
||||
wantedBy = [
|
||||
"multi-user.target"
|
||||
"container@${machine}.service"
|
||||
];
|
||||
path = [
|
||||
baoDeploy.package
|
||||
pkgs.coreutils
|
||||
pkgs.systemd
|
||||
];
|
||||
# ./lib/store-retry.nix. The container waits for one attempt; a secret a
|
||||
# later attempt lands restarts the responder (below).
|
||||
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
|
||||
serviceConfig = storeRetry.serviceConfig // {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
SyslogIdentifier = "swarm-bao-nats-auth-oidc";
|
||||
TimeoutStartSec = 30;
|
||||
};
|
||||
environment = {
|
||||
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
||||
}
|
||||
// lib.optionalAttrs authStoreActive {
|
||||
BAO_CLIENT_CERT = deployCfg.nats.authBaoClientCertFile;
|
||||
BAO_CLIENT_KEY = deployCfg.nats.authBaoClientKeyFile;
|
||||
}
|
||||
// lib.optionalAttrs (baoDeploy.serverCaFile != null) {
|
||||
BAO_CACERT = baoDeploy.serverCaFile;
|
||||
};
|
||||
script =
|
||||
if !authStoreActive then
|
||||
''
|
||||
echo "the swarm queue responder has no store identity, so it cannot read its OIDC client secret: set services.hyperhive.deploy.nats.authBaoClientCertFile and authBaoClientKeyFile" >&2
|
||||
exit 1
|
||||
''
|
||||
else
|
||||
''
|
||||
set -euo pipefail
|
||||
|
||||
${atomicWriteSecret}
|
||||
${refreshConsumer}
|
||||
|
||||
err="$(mktemp)"
|
||||
trap 'rm -f "$err"' EXIT
|
||||
|
||||
# `-token-only` answers on stdout and skips the token helper, a
|
||||
# `sh` this unit's `path` does not carry.
|
||||
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
|
||||
echo "could not log in to swarm-bao with the queue responder's certificate; leaving its OIDC client secret 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
|
||||
|
||||
if ! secret="$(bao kv get -field=value ${lib.escapeShellArg storeSecretPath} 2>"$err")"; then
|
||||
echo "swarm-bao did not return ${storeSecretPath}; the queue responder has no OIDC client secret yet." >&2
|
||||
if [ -s "$err" ]; then
|
||||
cat "$err" >&2
|
||||
else
|
||||
echo "bao failed without writing a diagnostic." >&2
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$secret" ]; then
|
||||
echo "swarm-bao returned an empty ${storeSecretPath}; leaving the file as it is." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Root-owned 0400: `LoadCredential` reads it as root before the
|
||||
# responder's `DynamicUser` exists. `printf` inside
|
||||
# `atomic_write_secret` is a builtin, so the plaintext never
|
||||
# becomes an argument in /proc.
|
||||
install -d -m 0700 ${lib.escapeShellArg secretDir}
|
||||
if secret_differs ${lib.escapeShellArg (hostPath "oidc-client.secret")} "$secret"; then
|
||||
atomic_write_secret 0400 "" ${lib.escapeShellArg (hostPath "oidc-client.secret")} "$secret"
|
||||
fi
|
||||
|
||||
# `LoadCredential` copies the file once, at the responder's start.
|
||||
refresh_consumer ${lib.escapeShellArg machine} swarm-nats-auth.service ${lib.escapeShellArg (hostPath "oidc-client.secret")}
|
||||
'';
|
||||
};
|
||||
|
||||
# ⚠️ Minted on the HOST, not in the container, because the responder is
|
||||
# a separate unit that needs the user seed: generating it inside would
|
||||
# trap it there and require a secret-export path back out — the exact
|
||||
|
|
|
|||
Loading…
Reference in a new issue