swarm-secret-publish and its path unit now run inside authelia's container, beside the mint, and read each client secret at the path authelia writes it. The host unit and host path unit, which read the secrets through the container's root on the host, are gone, and so is deploy.authelia.hostClientSecretDir, which only they read. The publisher keeps its store identity, the secret-publisher leaf. The host hands the leaf and the store's CA into the container as nspawn system credentials, and the unit loads them; where this host mints the leaf, the container waits for swarm-bao-pki. Authelia does not wait for the publisher, which retries every 30 seconds while the store is down. The container also runs the journal forwarder the other service containers run, pushing to a receiver of its own on the swarm collector (serviceProducers swarm-authelia) as a new machine OIDC client (swarm.authelia.otel.clientId) that authelia registers for itself and the publisher carries to the store. The fetch unit reads it back under the publisher's leaf, also handed to it as a system credential, so the swarm-secret-publisher policy gains read on that one leaf; its stanza keeps create and update, which the exact path would otherwise take from the services/* prefix. Without the leaf the forwarder is off. The container drops --link-journal=host, and the host collector stops reading it. The services' "nothing at <path>" errors name the publisher in authelia's container.
188 lines
7.5 KiB
Nix
188 lines
7.5 KiB
Nix
# The unit that puts swarm-level secrets into the swarm's secret store, so
|
|
# whoever needs one can read it there: a hive its own and its agents'
|
|
# credentials, a service or the controller its own. It mints nothing: every
|
|
# value is an OIDC client secret authelia minted, and this unit copies it.
|
|
#
|
|
# Imported inside authelia's container, beside the mint: it reads each secret
|
|
# where authelia writes it. The host module that declares the container sets
|
|
# the options below, as for ./swarm-journal-forwarder.nix.
|
|
#
|
|
# ⚠️ "Whoever", including a reader on authelia's own host. A swarm service's
|
|
# secret goes into the store even when the service runs beside authelia,
|
|
# because its reader fetches it from the store in every deployment —
|
|
# ../host-modules/swarm-grafana.nix states the ruling that made that the only
|
|
# route. Publishing "only when the reader is elsewhere" would be a second shape
|
|
# of this unit, gated on a fact about another host, to save a round trip on
|
|
# the one host that can afford it.
|
|
#
|
|
# ⚠️ ITS OWN STORE IDENTITY, not swarm-controller's. That principal may rewrite
|
|
# every hive's policy and login role; a unit that writes a handful of secrets
|
|
# has no business holding it. ../host-modules/swarm-bao.nix grants this one
|
|
# `create`/`update` under the hive prefix and nothing else — no `read`.
|
|
#
|
|
# ⚠️ THE SECRET NEVER REACHES `argv`. `bao` is an external binary, so every
|
|
# argument is world-readable in /proc for the life of the call — the value is
|
|
# passed as `@<path>` and read by bao itself.
|
|
#
|
|
# ⚠️ Nothing in the container waits for this unit. Authelia is the swarm's IdP
|
|
# and starts whether or not the store answers; a store that is down or sealed
|
|
# is a failed run that restarts.
|
|
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
let
|
|
cfg = config.services.hyperhive.swarmSecretPublisher;
|
|
in
|
|
{
|
|
options.services.hyperhive.swarmSecretPublisher = {
|
|
enable = lib.mkEnableOption "copying the OIDC client secrets authelia mints into the swarm secret store";
|
|
|
|
clientSecretDir = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = ''
|
|
Directory authelia mints each client's plaintext into, as
|
|
`<client id>.secret`. A path inside the container.
|
|
'';
|
|
};
|
|
|
|
secrets = lib.mkOption {
|
|
type = lib.types.listOf (
|
|
lib.types.submodule {
|
|
options = {
|
|
clientId = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "The minted client, whose `<client id>.secret` is copied.";
|
|
};
|
|
path = lib.mkOption {
|
|
type = lib.types.str;
|
|
example = "secret/swarm/services/swarm-grafana/oidc/client";
|
|
description = "KV path its reader fetches the value from.";
|
|
};
|
|
withClientId = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = false;
|
|
description = ''
|
|
Also store the client id beside the value, as `client_id`, for
|
|
a reader that has to reconstruct it from the store alone.
|
|
'';
|
|
};
|
|
};
|
|
}
|
|
);
|
|
description = "Every secret this unit copies, and where to.";
|
|
};
|
|
|
|
store = {
|
|
package = lib.mkOption {
|
|
type = lib.types.package;
|
|
description = "The `bao` CLI the unit logs in and writes with.";
|
|
};
|
|
addr = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "The swarm secret store's address, by name.";
|
|
};
|
|
clientCertFile = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = ''
|
|
The publisher's store identity, as a path inside the container. Its
|
|
subject must be
|
|
{option}`services.hyperhive.deploy.bao.secretPublisherCommonName`.
|
|
A path, never a value.
|
|
'';
|
|
};
|
|
clientKeyFile = lib.mkOption {
|
|
type = lib.types.str;
|
|
description = "Private key for `store.clientCertFile`. A path, never a value.";
|
|
};
|
|
caFile = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.str;
|
|
default = null;
|
|
description = "CA the store's server certificate chains to; null means the system trust store.";
|
|
};
|
|
};
|
|
};
|
|
|
|
config = lib.mkIf cfg.enable {
|
|
# Re-publish when authelia rotates a secret. The mint writes the file, so
|
|
# the file is the event — there is no signal from authelia to subscribe to.
|
|
systemd.paths.swarm-secret-publish = {
|
|
description = "watch for minted OIDC client secrets to publish";
|
|
wantedBy = [ "multi-user.target" ];
|
|
pathConfig = {
|
|
PathChanged = cfg.clientSecretDir;
|
|
Unit = "swarm-secret-publish.service";
|
|
};
|
|
};
|
|
|
|
systemd.services.swarm-secret-publish = {
|
|
description = "publish the swarm's minted secrets to the swarm secret store";
|
|
wantedBy = [ "multi-user.target" ];
|
|
after = [ "network.target" ];
|
|
path = [
|
|
cfg.store.package
|
|
pkgs.coreutils
|
|
];
|
|
# ⚠️ No `RemainAfterExit`. A oneshot that stays `active (exited)` turns
|
|
# every later start from the path unit above into a no-op.
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
# Bounded here rather than left to systemd's default, so the number a
|
|
# boot waits on is in the file that waits. A sealed store answers on
|
|
# the port and never answers the write.
|
|
TimeoutStartSec = 60;
|
|
Restart = "on-failure";
|
|
RestartSec = 30;
|
|
};
|
|
environment = {
|
|
BAO_ADDR = cfg.store.addr;
|
|
BAO_CLIENT_CERT = cfg.store.clientCertFile;
|
|
BAO_CLIENT_KEY = cfg.store.clientKeyFile;
|
|
}
|
|
// lib.optionalAttrs (cfg.store.caFile != null) {
|
|
BAO_CACERT = cfg.store.caFile;
|
|
};
|
|
script = ''
|
|
set -euo pipefail
|
|
|
|
# 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.
|
|
#
|
|
# Unhandled on purpose: `Restart=on-failure` above is what a store that
|
|
# cannot authenticate this container should get. Degrading here would
|
|
# report "published 0" as an ordinary quiet day.
|
|
BAO_TOKEN="$(bao login -method=cert -token-only)"
|
|
export BAO_TOKEN
|
|
|
|
published=0
|
|
skipped=0
|
|
|
|
${lib.concatMapStringsSep "\n" (s: ''
|
|
src=${lib.escapeShellArg "${cfg.clientSecretDir}/${s.clientId}.secret"}
|
|
if [ -s "$src" ]; then
|
|
# `value=@$src` hands bao the PATH: bao opens the file itself, so
|
|
# the plaintext is never an argument of this process. Writing it as
|
|
# `value="$(cat "$src")"` would publish it to /proc for anyone in
|
|
# the container to read.
|
|
bao kv put ${lib.escapeShellArg s.path} \
|
|
value=@"$src"${lib.optionalString s.withClientId " client_id=${lib.escapeShellArg s.clientId}"}
|
|
published=$((published + 1))
|
|
else
|
|
# Not an error: authelia mints on its FIRST BOOT, so an absent file
|
|
# is "not yet", and the path unit above re-runs this when it lands.
|
|
# A client registered nowhere in this swarm is never minted at all.
|
|
echo "no minted secret at $src yet; the path unit will re-run this" >&2
|
|
skipped=$((skipped + 1))
|
|
fi
|
|
'') cfg.secrets}
|
|
|
|
echo "published $published client secret(s), skipped $skipped"
|
|
'';
|
|
};
|
|
};
|
|
}
|