diff --git a/docs/swarm/README.md b/docs/swarm/README.md index e5ae679e..2729f9d1 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -338,7 +338,7 @@ On a host that runs the queue and the IdP itself, all three default to the local ones and there is nothing to set. Any other hive needs them spelled out, and needs the secret to physically be there: the swarm does not distribute it. Copy `hive-.secret` out of the swarm host's -`deploy.authelia.hostClientSecretDir` with whatever secret management the +`swarm.authelia.hostClientSecretDir` with whatever secret management the deployment already uses. The identity is not a choice — a hive authenticates as `hive-` diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index b50032e5..f1964fc9 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -93,19 +93,6 @@ in [ "services" "hyperhive" "swarm" "authelia" "enable" ] [ "services" "hyperhive" "deploy" "authelia" "enable" ] ) - (lib.mkRenamedOptionModule - [ "services" "hyperhive" "swarm" "authelia" "usersFile" ] - [ "services" "hyperhive" "deploy" "authelia" "usersFile" ] - ) - # `hostClientSecretDir` and `hostUsersFile` moved in the same commit and - # deliberately have NO entry here. A rename module contributes a - # *definition* to its target, and both are `readOnly`, which refuses a - # second one — so a shim for either makes every evaluation fail with - # "set multiple times", naming the option's own default as the rival - # definition. Nothing could have set them anyway: both are derived and - # read-only, so the shim's only possible customer is a reader of the old - # path, and that reader now gets "option does not exist" instead, which - # says the same thing sooner. (lib.mkRenamedOptionModule [ "services" "hyperhive" "swarm" "nats" "enable" ] [ "services" "hyperhive" "deploy" "nats" "enable" ] diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index a171553f..b89b5120 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -428,7 +428,7 @@ in host, which copies the token out). Same role for this token as - `services.hyperhive.deploy.authelia.hostClientSecretDir` plays + `services.hyperhive.swarm.authelia.hostClientSecretDir` plays for the OIDC secret: a **host**-local path (not inside any container), read directly by `swarm-controller.nix`'s `LoadCredential` when the controller runs on this same host. @@ -1258,7 +1258,7 @@ in script = '' set -euo pipefail - src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${cfg.sso.clientId}.secret"} + src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.sso.clientId}.secret"} dst=${lib.escapeShellArg "/var/lib/nixos-containers/hive-forge${forgeSecretPath}"} # authelia's container is up, but its first-boot generator may diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 49861540..1438b020 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -799,7 +799,7 @@ in script = '' set -euo pipefail - src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${cfg.sso.clientId}.secret"} + src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.sso.clientId}.secret"} dst=${lib.escapeShellArg "/var/lib/nixos-containers/hive-matrix${toString deployCfg.matrix.sso.clientSecretFile}"} # authelia's container is up, but its first-boot generator may diff --git a/nix/host-modules/local-defaults.nix b/nix/host-modules/local-defaults.nix index 7f23761e..afa94b7b 100644 --- a/nix/host-modules/local-defaults.nix +++ b/nix/host-modules/local-defaults.nix @@ -137,6 +137,6 @@ in # evaluation of a single-host swarm. config.services.hyperhive.deploy.swarm-controller.queue.clientSecretFile = lib.mkIf cfg.deploy.singleHostSwarm ( - lib.mkDefault "${config.services.hyperhive.deploy.authelia.hostClientSecretDir}/swarm-controller.secret" + lib.mkDefault "${config.services.hyperhive.swarm.authelia.hostClientSecretDir}/swarm-controller.secret" ); } diff --git a/nix/host-modules/otel.nix b/nix/host-modules/otel.nix index 95ed69e1..9bff01ae 100644 --- a/nix/host-modules/otel.nix +++ b/nix/host-modules/otel.nix @@ -234,11 +234,11 @@ in if config.services.hyperhive.deploy.authelia.enable && config.services.hyperhive.hiveName != null then - "${config.services.hyperhive.deploy.authelia.hostClientSecretDir}/" + "${config.services.hyperhive.swarm.authelia.hostClientSecretDir}/" + "${config.services.hyperhive.swarm.authelia.hiveClientPrefix}${config.services.hyperhive.hiveName}.secret" else null; - defaultText = lib.literalExpression ''"''${deploy.authelia.hostClientSecretDir}/''${swarm.authelia.hiveClientPrefix}''${hiveName}.secret" when this host runs the swarm's IdP, else null''; + defaultText = lib.literalExpression ''"''${swarm.authelia.hostClientSecretDir}/''${swarm.authelia.hiveClientPrefix}''${hiveName}.secret" when this host runs the swarm's IdP, else null''; example = "/var/lib/secrets/hive-telemetry.secret"; description = '' Absolute path to this hive's OAuth2 client secret, used to diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 0c0e7e30..afb989f6 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -421,6 +421,25 @@ in ''; }; + usersFile = lib.mkOption { + type = lib.types.str; + default = "${stateDir}/users.yml"; + defaultText = lib.literalExpression ''"/var/lib/authelia-swarm/users.yml"''; + description = '' + Path (inside the container) of authelia's file users database. + + Written by `swarm-authelia-bridge`, not by hand: agents come and + go continuously, so the subject set is dynamic and belongs to a + program. `swarm-controller` cannot write this file itself — a + different uid owns it — so the bridge is the only writer, + running inside this same container as this file's actual owner. + This module only guarantees the file *exists* and is valid YAML + at first boot, so authelia starts with no subjects rather than + failing to start — a provider with nobody in it yet is the + correct state before anything has provisioned users. + ''; + }; + oidc.hiveIdentities = lib.mkOption { type = lib.types.bool; default = deployCfg.nats.enable; @@ -699,6 +718,43 @@ in ''; }; + hostClientSecretDir = lib.mkOption { + type = lib.types.str; + readOnly = true; + default = "/var/lib/nixos-containers/${cfg.machine}${clientsDir}"; + description = '' + Where the minted client secrets sit **as seen from the host** — + `.secret` holds a plaintext, `.digest` the hash authelia + itself reads. + + Published for the same reason as `hostUsersFile`: the plaintext's + other reader lives in a **different container**, and containers + that share this host's network namespace still have separate + filesystem roots. The host is the only place both trees are + addressable, so the host is where a delivery step has to run. + + ⚠️ Nothing here exists until authelia's **first boot** has run. + A consumer must wait for it — it cannot be a `bindMounts` source, + because nixos-container refuses to start when a bind source is + missing, and that turns a fresh hive into a boot-order deadlock. + ''; + }; + + hostUsersFile = lib.mkOption { + type = lib.types.str; + readOnly = true; + default = "/var/lib/nixos-containers/${cfg.machine}${cfg.usersFile}"; + description = '' + `usersFile` as seen from the **host** — the container's root + prefixed onto the path authelia sees. + + Published for callers that only ever need to *read* the file + (e.g. an operator diagnosing a bad entry). `swarm-authelia-bridge` + itself never uses this path — it runs inside the container, as + the file's own owner, and writes the in-container path directly. + ''; + }; + bridgePackage = lib.mkOption { type = lib.types.package; defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-authelia-bridge"; @@ -748,75 +804,6 @@ in }; }; - # What stays above is what authelia IS to every hive: where it answers - # (`url`), the OIDC register every service checks itself against, its port. - # What the host running the container decides is here, and all three of - # these are the same kind of thing — a filesystem path that only exists on - # the machine that runs `swarm-authelia`. A hive that does not run it has - # nothing at any of them. `enable` already lives in ./deploy.nix, which - # also carries the renames. - # - # ⚠️ `usersFile` is a path *inside* the container and still belongs here: - # a path's scope is the scope of the filesystem it names, and that - # filesystem is this host's container root. - options.services.hyperhive.deploy.authelia = { - usersFile = lib.mkOption { - type = lib.types.str; - default = "${stateDir}/users.yml"; - defaultText = lib.literalExpression ''"/var/lib/authelia-swarm/users.yml"''; - description = '' - Path (inside the container) of authelia's file users database. - - Written by `swarm-authelia-bridge`, not by hand: agents come and - go continuously, so the subject set is dynamic and belongs to a - program. `swarm-controller` cannot write this file itself — a - different uid owns it — so the bridge is the only writer, - running inside this same container as this file's actual owner. - This module only guarantees the file *exists* and is valid YAML - at first boot, so authelia starts with no subjects rather than - failing to start — a provider with nobody in it yet is the - correct state before anything has provisioned users. - ''; - }; - - hostClientSecretDir = lib.mkOption { - type = lib.types.str; - readOnly = true; - default = "/var/lib/nixos-containers/${cfg.machine}${clientsDir}"; - description = '' - Where the minted client secrets sit **as seen from the host** — - `.secret` holds a plaintext, `.digest` the hash authelia - itself reads. - - Published for the same reason as `hostUsersFile`: the plaintext's - other reader lives in a **different container**, and containers - that share this host's network namespace still have separate - filesystem roots. The host is the only place both trees are - addressable, so the host is where a delivery step has to run. - - ⚠️ Nothing here exists until authelia's **first boot** has run. - A consumer must wait for it — it cannot be a `bindMounts` source, - because nixos-container refuses to start when a bind source is - missing, and that turns a fresh hive into a boot-order deadlock. - ''; - }; - - hostUsersFile = lib.mkOption { - type = lib.types.str; - readOnly = true; - default = "/var/lib/nixos-containers/${cfg.machine}${deployCfg.authelia.usersFile}"; - description = '' - `usersFile` as seen from the **host** — the container's root - prefixed onto the path authelia sees. - - Published for callers that only ever need to *read* the file - (e.g. an operator diagnosing a bad entry). `swarm-authelia-bridge` - itself never uses this path — it runs inside the container, as - the file's own owner, and writes the in-container path directly. - ''; - }; - }; - config = lib.mkIf (hyperhiveCfg.enable && deployCfg.authelia.enable) { # The derived half of the client list, declared the same way an # operator declares one. Everything downstream then reads a single @@ -1182,7 +1169,7 @@ in # it. authelia refuses to start without one, and the # alternative to an empty file is a placeholder account — # which is a credential nobody meant to create. - users=${lib.escapeShellArg deployCfg.authelia.usersFile} + users=${lib.escapeShellArg cfg.usersFile} if [ ! -s "$users" ]; then echo "users: {}" > "$users" echo "seeded empty users database at $users" @@ -1191,7 +1178,7 @@ in ''; }; - # The only process allowed to write `deployCfg.authelia.usersFile` — see that + # The only process allowed to write `cfg.usersFile` — see that # option's doc comment, and the crate's own README for the full # "why does an unprivileged swarm-controller need a bridge at # all" reasoning. Runs as `unitName` (`authelia-swarm`) — THE @@ -1230,7 +1217,7 @@ in # canonical stores for one physical file, which is what # made `swarm agent create` refuse to start on a hive whose # `users.yml` already held users. - SWARM_AUTHELIA_BRIDGE_USERS_FILE = deployCfg.authelia.usersFile; + SWARM_AUTHELIA_BRIDGE_USERS_FILE = cfg.usersFile; # The CONFIGURED authelia, not whatever is on `PATH`: the # argon2 parameters baked into a hash have to match the # verifier's — same reasoning as `swarmctl`'s own @@ -1349,7 +1336,7 @@ in # write the file at all. Without this, an identity it creates is # real on disk and invisible until something unrelated restarts. authentication_backend.file = { - path = deployCfg.authelia.usersFile; + path = cfg.usersFile; watch = true; }; diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 85d9bee6..fe1c505e 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -29,7 +29,7 @@ let # The CONFIGURED authelia, not whatever is on PATH: the argon2 # parameters baked into a hash have to match the verifier's. SWARMCTL_AUTHELIA_BIN = "${autheliaCfg.package}/bin/authelia"; - SWARMCTL_AUTHELIA_USERS_FILE = deployCfg.authelia.hostUsersFile; + SWARMCTL_AUTHELIA_USERS_FILE = autheliaCfg.hostUsersFile; }; forgeCfg = config.services.hyperhive.swarm.forge; diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 58e7fce0..3ec186e0 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -478,7 +478,7 @@ in script = '' set -euo pipefail - src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${cfg.oidc.clientId}.secret"} + src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.oidc.clientId}.secret"} dst=${lib.escapeShellArg "/var/lib/nixos-containers/${cfg.machine}${secretPath}"} # authelia's container is up, but its first-boot generator may still diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index 031eeedc..605c5c4a 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -57,7 +57,7 @@ let responderConfigured = deployCfg.nats.autoGenerateCallout || (deployCfg.nats.calloutUserSeedFile != "" && deployCfg.nats.calloutIssuerSeedFile != ""); - clientSecretSource = "${deployCfg.authelia.hostClientSecretDir}/${cfg.clientId}.secret"; + clientSecretSource = "${autheliaCfg.hostClientSecretDir}/${cfg.clientId}.secret"; introspectionUrl = "${toString autheliaUrl}/api/oidc/introspection"; # Where the responder's seeds actually come from. One name for two diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index 9b8c0adf..a6e8f278 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -654,7 +654,7 @@ in script = '' set -euo pipefail - src=${lib.escapeShellArg "${deployCfg.authelia.hostClientSecretDir}/${cfg.clientId}.secret"} + src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.clientId}.secret"} dst=${lib.escapeShellArg "/var/lib/nixos-containers/${cfg.machine}${collectorSecretInContainer}"} # authelia's container is up, but its first-boot generator may diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 8867cc85..bf0a73cc 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -483,11 +483,8 @@ in clientSecretFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = - if queueLocal then - "${deployCfg.authelia.hostClientSecretDir}/hive-${cfg.hiveName}.secret" - else - null; - defaultText = lib.literalExpression ''"''${deploy.authelia.hostClientSecretDir}/hive-''${hiveName}.secret" when this host runs both the queue and the IdP, else null''; + if queueLocal then "${swarmCfg.authelia.hostClientSecretDir}/hive-${cfg.hiveName}.secret" else null; + defaultText = lib.literalExpression ''"''${swarm.authelia.hostClientSecretDir}/hive-''${hiveName}.secret" when this host runs both the queue and the IdP, else null''; example = "/var/lib/secrets/swarm-queue-client.secret"; description = '' Path to a file holding the plaintext client secret for this @@ -501,7 +498,7 @@ in this host. On any other hive the secret has to get here somehow, and the swarm does not distribute it — copy it out of the swarm host's - {option}`services.hyperhive.deploy.authelia.hostClientSecretDir` + {option}`services.hyperhive.swarm.authelia.hostClientSecretDir` with whatever secret management this deployment already uses. ''; }; diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 712587c7..e61db7b4 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -118,24 +118,6 @@ let swarm.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed"; }; - # Seventh split slice. Only `usersFile` has a rename entry: the other two - # movers are `readOnly`, and a rename module contributes a definition, which - # a read-only option refuses — see ./host-modules/deploy.nix. So the two arms - # below have different jobs. `usersFile` tests the rename; the nats one tests - # that a reader repointed to the new namespace still renders the derived - # path, which is the failure this slice could actually have shipped — seven - # of those reads went through an alias a path-shaped grep cannot see. - autheliaOldPath = hive { - deploy.authelia.enable = true; - deploy.nats.enable = true; - swarm.authelia.usersFile = "/var/lib/test-authelia/users.yml"; - swarm.nats.autoGenerateCallout = false; - swarm.nats.calloutUserPublicKey = "UTESTUSERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - swarm.nats.calloutIssuerPublicKey = "ATESTISSUERPUBKEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - swarm.nats.calloutUserSeedFile = "/run/secrets/nats-user.seed"; - swarm.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed"; - }; - controllerOldPath = hive { deploy.swarm-controller.enable = true; swarm.controller.socketPath = "/run/test-ctrl/ctrl.sock"; @@ -369,20 +351,6 @@ let rule: lib.hasInfix "/run/test-grafana-sock" rule ) grafanaOldPath.systemd.tmpfiles.rules; } - { - name = "a config written against the pre-rename authelia usersFile still reaches the bridge"; - ok = - autheliaOldPath.containers.swarm-authelia.config.systemd.services.swarm-authelia-bridge.environment.SWARM_AUTHELIA_BRIDGE_USERS_FILE - == "/var/lib/test-authelia/users.yml"; - } - { - # Not a rename test. `hostClientSecretDir` is `readOnly`, so the fixture - # cannot define it; what can break is a reader left pointing at the - # namespace it moved out of. Five modules read this through an - # `autheliaCfg` alias, where a path-shaped grep does not see it. - name = "a consumer of authelia's host client-secret dir renders it from the deploy namespace"; - ok = lib.hasInfix "/var/lib/nixos-containers/swarm-authelia/var/lib/authelia-swarm/oidc-clients/" autheliaOldPath.systemd.services.swarm-nats-auth-secrets.script; - } { # The gateway's per-name issuer choice. If this ever collapses to a # constant, every swarm-service vhost serves a certificate its CA