Watch
0
0
Fork
You've already forked hyperhive
0

keep authelia's oidc client secrets in the store, not on disk

The mint creates each client's secret in the swarm secret store and writes only the digest to disk. It mints only when the store answers and holds none. A new unit moves the plaintext files an earlier release left into the store and removes each once the store holds the same value. The bridge reads its secret from the store into /run before each start. The secret publisher unit and its path unit are gone.
This commit is contained in:
atlas 2026-10-30 03:25:57 +01:00 • committed by mara
commit d30cb7d122

View file

@ -237,7 +237,7 @@ let
publisherCfg.enable
&& publisherCfg.baoClientCertFile != null
&& publisherCfg.baoClientKeyFile != null;
publisherUnit = "swarm-secret-publish";
migrateUnit = "${unitName}-client-secrets";
forwarderFetchUnit = "swarm-journal-forwarder-oidc";
# The publisher's store identity, handed into the container as system
# credentials (credential id → host source). The forwarder's fetch presents
@ -465,39 +465,33 @@ let
''
);
# The OIDC half of the first-boot generator, kept out of the script
# body so neither is read through the other's indentation.
oidcGenScript = lib.optionalString oidcEnabled ''
${lib.optionalString (!publishes) ''
# The issuer key is the one secret here that is NOT interchangeable
# with a random blob: it *signs* id tokens, and every relying party
# verifies them against the public half served at `/jwks.json`. A
# symmetric secret cannot do that, so this one is an RSA pair.
#
# Rotating it invalidates every token already issued, which is why
# it is generated once and left alone — same reason as the session
# and storage keys above.
issuer=${lib.escapeShellArg stateDir}/oidc-issuer.key
if [ ! -s "$issuer" ]; then
openssl genrsa -out "$issuer" 4096
echo "generated $issuer"
fi
chmod 0600 "$issuer"
''}
# Each client's secret, minted once and kept as two files: the
# plaintext its relying party authenticates with, and the digest
# authelia compares against. The two live in different containers,
# so neither side can generate it alone — this is the only place
# that sees both.
#
# `--random` is why no plaintext ever reaches an argv: authelia
# generates the password itself and prints it beside its digest,
# so nothing has to be handed to a second process on a command
# line.
clients=${lib.escapeShellArg clientsDir}
mkdir -p "$clients"
chmod 0700 "$clients"
oidcClientSecrets = import ./lib/oidc-client-secrets.nix { };
# Every client with a secret to mint: a native client is public and has none.
secretClients = lib.filter (c: c.kind != "native") cfg.oidc.clients;
# Where a client's plaintext lives in the store: the path its reader fetches
# it from, which is `deploy.swarm-secret-publisher.secrets` for every client
# something reads. The rest, the bridge's own among them, sit with the
# services.
clientLeaf =
id:
let
listed = lib.findFirst (s: s.clientId == id) null publisherCfg.secrets;
in
if listed == null then "secret/swarm/services/${id}/oidc/client" else listed.path;
# Each secret is kept as two halves: the plaintext its relying party
# authenticates with, and the digest authelia compares against. The two live
# in different containers, so neither side can generate it alone, and this is
# the only place that sees both.
#
# `--random` is why no plaintext reaches an argv: authelia generates the
# password itself and prints it beside its digest.
#
# Without a store identity the plaintext has nowhere else to go, so it stays
# in a file beside the digest.
fileMintScript = ''
mint() {
sec="$clients/$1.secret"
dig="$clients/$1.digest"
@ -522,7 +516,46 @@ let
${lib.concatMapStrings (c: ''
mint ${lib.escapeShellArg c.id}
'') (lib.filter (c: c.kind != "native") cfg.oidc.clients)}
'') secretClients}
'';
# The plaintext goes straight into the store and only the digest is written
# to disk. A digest on disk is enough for authelia to start while the store is
# down; a client with no digest cannot be rendered, so that run fails and
# retries (./lib/store-retry.nix). A status of 3 is a state a retry does not
# change, and `RestartPreventExitStatus` leaves the unit failed for an
# operator.
storeMintScript = ''
${oidcClientSecrets}
${lib.concatMapStrings (c: ''
mint_client_secret ${lib.escapeShellArg c.id} ${lib.escapeShellArg (clientLeaf c.id)} || exit $?
'') secretClients}
'';
# The OIDC half of the first-boot generator, kept out of the script
# body so neither is read through the other's indentation.
oidcGenScript = lib.optionalString oidcEnabled ''
${lib.optionalString (!publishes) ''
# The issuer key is the one secret here that is NOT interchangeable
# with a random blob: it *signs* id tokens, and every relying party
# verifies them against the public half served at `/jwks.json`. A
# symmetric secret cannot do that, so this one is an RSA pair.
#
# Rotating it invalidates every token already issued, which is why
# it is generated once and left alone — same reason as the session
# and storage keys above.
issuer=${lib.escapeShellArg stateDir}/oidc-issuer.key
if [ ! -s "$issuer" ]; then
openssl genrsa -out "$issuer" 4096
echo "generated $issuer"
fi
chmod 0600 "$issuer"
''}
clients=${lib.escapeShellArg clientsDir}
mkdir -p "$clients"
chmod 0700 "$clients"
${if publishes then storeMintScript else fileMintScript}
# Re-rendered every boot, deliberately: the secret is minted once,
# but the metadata around it (a new redirect URI, a renamed client)
@ -537,10 +570,42 @@ let
} > ${lib.escapeShellArg "${clientsFile}.tmp"}
chmod 0600 ${lib.escapeShellArg "${clientsFile}.tmp"}
mv ${lib.escapeShellArg "${clientsFile}.tmp"} ${lib.escapeShellArg clientsFile}
${lib.optionalString publishes ''
# authelia reads its clients once, as it starts.
if [ -n "''${MINTED-}" ]; then
refresh_unit ${lib.escapeShellArg cfg.unit} ${lib.escapeShellArg clientsFile}
fi
''}
'';
storeRetry = import ./lib/store-retry.nix { };
# The bridge's own client secret, in the memory-backed runtime directory of
# its unit. While a plaintext file from an earlier release exists the bridge
# keeps using it, since it is the value the digest was made from; the file
# goes once the store holds the same value, and the store's takes over.
bridgeSecretFile = "/run/swarm-authelia-bridge/client-secret";
bridgeSecretFetch = ''
set -euo pipefail
${atomicWriteSecret}
${storeSeededSecret}
${oidcClientSecrets}
legacy=${lib.escapeShellArg "${clientsDir}/${bridgeClientId}.secret"}
store_init
store_login
if [ -s "$legacy" ]; then
value="$(< "$legacy")"
elif client_secret_read ${lib.escapeShellArg (clientLeaf bridgeClientId)}; then
value="$CLIENT_SECRET_VALUE"
else
echo "the store holds no readable secret for ${bridgeClientId}" >&2
exit 1
fi
atomic_write_secret 0600 "" ${lib.escapeShellArg bridgeSecretFile} "$value"
'';
# authelia's keys, one leaf each under its container's name. The nix half of
# `autheliaKeyLeaves` in ./swarm-bao.nix, which grants the publisher
# `create` and `read` on exactly these.
@ -1019,19 +1084,20 @@ in
imports = [
../container-modules/swarm-container.nix
../container-modules/swarm-journal-forwarder.nix
../container-modules/swarm-secret-publisher.nix
(import ../container-modules/swarm-container-resolver.nix {
inherit (networkCfg) bridgeIp;
# The forwarder dials the swarm collector and authelia by name,
# and its fetch, the publisher and the key unit the store.
# and its fetch, the bridge's fetch, the key and secret units the
# store.
dnsConsumers = [
cfg.unit
]
++ lib.optionals publishes [
"opentelemetry-collector.service"
"${forwarderFetchUnit}.service"
"${publisherUnit}.service"
"${migrateUnit}.service"
"${unitName}-secrets.service"
"swarm-authelia-bridge.service"
];
})
caBundleModule
@ -1068,20 +1134,77 @@ in
serviceConfig.LoadCredential = lib.attrNames storeFiles;
};
# Copies what the mint below leaves in `clientsDir` into the store,
# under the publisher's own identity, a system credential. Nothing
# orders authelia after it.
services.hyperhive.swarmSecretPublisher = lib.mkIf publishes {
enable = true;
clientSecretDir = clientsDir;
inherit (publisherCfg) secrets;
store = storeIdentityIn publisherUnit;
};
systemd.services.${publisherUnit} = lib.mkIf publishes {
# A run before the mint would only skip every secret and wait
# for the path unit.
# Moves the plaintext files an earlier release left in `clientsDir`
# into the store and removes each once the store holds the same
# value. With no file left it does nothing, store or no store. Nothing
# orders authelia after it: authelia starts from its digests.
systemd.services.${migrateUnit} = lib.mkIf publishes {
description = "move authelia's plaintext OIDC client secrets into the swarm secret store";
wantedBy = [ "multi-user.target" ];
after = [ "${unitName}-secrets.service" ];
serviceConfig.LoadCredential = lib.attrNames storeFiles;
path = [
pkgs.coreutils
pkgs.gnugrep
deployCfg.bao.package
];
startLimitBurst = storeRetry.startLimitBurst;
startLimitIntervalSec = storeRetry.startLimitIntervalSec;
serviceConfig = storeRetry.serviceConfig // {
Type = "oneshot";
User = unitName;
Group = socketGroup;
UMask = "0077";
LoadCredential = lib.attrNames storeFiles;
TimeoutStartSec = 120;
RestartPreventExitStatus = 3;
SyslogIdentifier = migrateUnit;
};
environment =
let
store = storeIdentityIn migrateUnit;
in
{
BAO_ADDR = store.addr;
BAO_CLIENT_CERT = store.clientCertFile;
BAO_CLIENT_KEY = store.clientKeyFile;
}
// lib.optionalAttrs (store.caFile != null) { BAO_CACERT = store.caFile; };
script = ''
set -uo pipefail
${storeSeededSecret}
${oidcClientSecrets}
clients=${lib.escapeShellArg clientsDir}
known=${lib.escapeShellArg (lib.concatMapStrings (c: " ${c.id}") secretClients + " ")}
pending=0
for f in "$clients"/*.secret; do
[ -e "$f" ] || continue
id="$(basename "$f" .secret)"
case "$known" in
*" $id "*) pending=1 ;;
*) echo "left $f in place: no client $id is registered here" >&2 ;;
esac
done
if [ "$pending" = 0 ]; then
echo "no plaintext client secret files to move"
exit 0
fi
store_init
store_login
status=0
${lib.concatMapStrings (c: ''
rc=0
migrate_client_secret ${lib.escapeShellArg c.id} ${lib.escapeShellArg (clientLeaf c.id)} || rc=$?
if [ "$rc" != 0 ] && [ "$status" != 1 ]; then
status="$rc"
fi
'') secretClients}
exit "$status"
'';
};
# The authelia binary itself, so an operator who gets a shell
@ -1121,6 +1244,7 @@ in
]
++ lib.optionals publishes [
deployCfg.bao.package
pkgs.gnugrep
pkgs.systemd
];
# A store that cannot answer, with a key missing on disk, is a
@ -1142,6 +1266,9 @@ in
// {
LoadCredential = lib.attrNames storeFiles;
TimeoutStartSec = 120;
# A secret that has no digest, or a digest that has no secret,
# is for an operator; minting again would rotate it.
RestartPreventExitStatus = 3;
}
);
environment = lib.optionalAttrs publishes (
@ -1189,23 +1316,40 @@ in
# load-bearing at start, since nothing calls the bridge yet at
# boot, but a clean dependency order beats a would-be-transient
# failure on the first real request).
#
# With the store identity its client secret is read from the store
# into `bridgeSecretFile` before each start (`bridgeSecretFetch`). A
# store that does not answer fails the start, and the unit retries.
systemd.services.swarm-authelia-bridge = {
description = "swarm-authelia-bridge: the only writer of authelia's users database";
wantedBy = [ "multi-user.target" ];
after = [
"${unitName}-secrets.service"
"${unitName}.service"
];
]
++ lib.optional publishes "${migrateUnit}.service";
wants = [
"${unitName}-secrets.service"
"${unitName}.service"
];
path = lib.optionals publishes [
pkgs.coreutils
pkgs.gnugrep
deployCfg.bao.package
];
preStart = lib.mkIf publishes bridgeSecretFetch;
startLimitBurst = lib.mkIf publishes storeRetry.startLimitBurst;
startLimitIntervalSec = lib.mkIf publishes storeRetry.startLimitIntervalSec;
serviceConfig = {
ExecStart = "${deployCfg.authelia.bridgePackage}/bin/swarm-authelia-bridge";
User = unitName;
Group = socketGroup;
Restart = "on-failure";
RestartSec = "5s";
RestartSec = if publishes then storeRetry.serviceConfig.RestartSec else "5s";
}
// lib.optionalAttrs publishes {
RuntimeDirectory = "swarm-authelia-bridge";
LoadCredential = lib.attrNames storeFiles;
};
environment = {
SWARM_AUTHELIA_BRIDGE_SOCKET = bridgeSocket;
@ -1230,17 +1374,27 @@ in
# uses, so there is one idiom rather than two.
SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL = "${cfg.url}/api/oidc/introspection";
SWARM_AUTHELIA_BRIDGE_CLIENT_ID = bridgeClientId;
# Minted by `${unitName}-secrets`'s `mint` loop (it iterates
# every entry in `cfg.oidc.clients`, which now always
# includes `bridgeClient`) — same file this container's own
# `renderClient` reads the digest half of.
SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE = "${clientsDir}/${bridgeClientId}.secret";
# Minted by `${unitName}-secrets` (it iterates every entry in
# `cfg.oidc.clients`, which always includes `bridgeClient`).
SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE =
if publishes then bridgeSecretFile else "${clientsDir}/${bridgeClientId}.secret";
# The one client the bridge answers. Every other active token
# (each hive and its agents hold one) is refused, on every
# request, since `/bridge/` is reachable from any host.
SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID = hyperhiveCfg.swarm.controller.queueClientId;
SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP = operatorGroup;
};
}
// lib.optionalAttrs publishes (
let
store = storeIdentityIn "swarm-authelia-bridge";
in
{
BAO_ADDR = store.addr;
BAO_CLIENT_CERT = store.clientCertFile;
BAO_CLIENT_KEY = store.clientKeyFile;
}
// lib.optionalAttrs (store.caFile != null) { BAO_CACERT = store.caFile; }
);
};
# Upstream's unit is `ProtectSystem=strict`, so the socket directory