# 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 `@` 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 `.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 `.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" ''; }; }; }