Every agent now connects to the queue and reads logs with its own secret from the store (#5181), so the per-hive `hive-<h>-agent` OIDC client has no reader left. Remove it end to end: - hive-c0re no longer forwards `hive-queue-agent-{secret,client-id}` into containers, and no longer forwards HIVE_AGENT_OIDC_TOKEN_ENDPOINT; the agent module that loaded them (nix/agent-modules/queue.nix) is gone, its options refused with a removal message. - The host reader (glue-queue-agent-credential.nix, swarm-bao-queue-agent), its store role and policy unit (swarm-bao-queue-agent-policy), its PKI leaf and the deploy.bao.queueAgent* / queue.agentCredentialDir options are gone; the options are refused with a removal message. - authelia mints no `hive-<h>-agent` client, and swarm-secret-publish no longer writes swarm/hives/<h>/queue/agent. - swarm-secret-client drops queue::agent_client_path / queue::Credential. - HIVE_AGENT_NATS_URL is now gated on the agents' queue address alone. The log store's authelia check stays for operators. swarm-nats-auth still refuses a `-agent` client id; that refusal and `agentClientSuffix` are left for a follow-up. Refs #4459 Refs #4765
180 lines
7 KiB
Nix
180 lines
7 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, 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.";
|
|
};
|
|
};
|
|
}
|
|
);
|
|
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"
|
|
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"
|
|
'';
|
|
};
|
|
};
|
|
}
|