diff --git a/flake.nix b/flake.nix index 1c7a6f53..cefc5a2f 100644 --- a/flake.nix +++ b/flake.nix @@ -154,6 +154,9 @@ services.hyperhive.swarm.nats.authPackage = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth; + services.hyperhive.swarm.authelia.bridgePackage = + lib.mkDefault + self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-authelia-bridge; services.hyperhive.gateway.swaggerUiTheme = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.swagger-ui-theme; diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 4bc72f10..812e80b0 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -11,16 +11,18 @@ # # Operator and agents are both subjects of the same provider, # differentiated by roles/claims rather than by mechanism — there is one -# IdP and one auth path. The users store is therefore written by a -# program (swarm-controller), not maintained by hand: agents are created -# and destroyed continuously, so the subject set is *dynamic*. That is -# also why the file backend is the right one here and not a placeholder -# for LDAP: what makes a directory necessary is the size of the subject -# set, and this deployment's is bounded by one swarm. +# IdP and one auth path. The users store is written by a program +# (`swarm-authelia-bridge`, see that option's doc comment), not +# maintained by hand: agents are created and destroyed continuously, so +# the subject set is *dynamic*. That is also why the file backend is +# right here, not a placeholder for LDAP: what makes a directory +# necessary is the size of the subject set, bounded by one swarm. # # Two roles: a **session** provider always, an **OIDC** provider when -# `oidc.clients` is non-empty (derived, not flagged — authelia will not -# start with a clientless provider). Secrets map: docs/swarm/sso.md. +# `oidc.clients` is non-empty (derived, not flagged). In practice OIDC +# is always on now: the bridge needs its own machine-client identity for +# its introspection calls, contributed unconditionally, not behind +# `oidc.hiveIdentities`. Secrets map: docs/swarm/sso.md. # # Per-service integration — putting authelia's `auth_request` in front # of the gateway's existing `auth_basic` locations — is deliberately NOT @@ -94,17 +96,33 @@ let redirectUris = [ ]; }) hyperhiveCfg.swarm.hives; + # `swarm-authelia-bridge`'s own identity — distinct from + # `swarm-controller`'s (`swarm-controller.nix`'s `queueClientId`). A + # resource server introspecting a token proves its OWN identity to the + # IdP (RFC 7662), separately from whichever principal's token it is + # checking, so the bridge needs a client even though it never presents + # a token itself. Contributed unconditionally below (not gated behind + # `oidc.hiveIdentities`/an operator-declared `oidc.clients` entry): the + # bridge is a core, always-present part of this module, not an opt-in + # consumer — see `usersFile`'s doc comment. + bridgeClientId = "swarm-authelia-bridge"; + bridgeClient = { + id = bridgeClientId; + description = "HyperHive swarm-authelia-bridge (users-database writer)"; + kind = "machine"; + redirectUris = [ ]; + }; + # authelia refuses to start with an OIDC provider that has no clients, # so the provider is derived from the client list rather than carrying - # its own `enable`: one fact, and it cannot contradict itself. An empty - # list is the default, which makes every hive that has not opted in - # byte-identical to before. + # its own `enable`: one fact, and it cannot contradict itself. # - # ⚠️ The derived hive identities are definitions of this same option, - # so they can turn the provider on by themselves. That is only - # reachable where the queue is already enabled (`hiveIdentities` - # defaults to it) — and a queue-enabled hive already contributes a - # client, so no existing deployment flips. + # ⚠️ In practice this is now unconditionally `true` whenever the module + # is enabled: `bridgeClient` above is an unconditional definition of + # `oidc.clients` (see the `config` block), so the list is never empty. + # Kept as a derived boolean rather than simplified to a literal `true` + # so the OIDC-gated code below stays self-documenting about WHY it is + # conditional, not just that it happens to always be on today. oidcEnabled = cfg.oidc.clients != [ ]; # Secrets that are 64 random bytes of hex and nothing more. The OIDC @@ -335,12 +353,15 @@ in description = '' Path (inside the container) of authelia's file users database. - Written by swarm-controller, not by hand: agents come and go - continuously, so the subject set is dynamic and belongs to a - program. 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. + 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. ''; }; @@ -538,11 +559,58 @@ in `usersFile` as seen from the **host** — the container's root prefixed onto the path authelia sees. - The distinction is load-bearing: the users database is written - from the host by a program that does not live in this container, - while authelia only ever sees the inner path. Handing the wrong - one to either side yields a file nobody reads rather than an - error. + 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"; + description = '' + `swarm-authelia-bridge` package — the only process allowed to + write `usersFile`. Wired by default from this flake's own + package set (see `flake.nix`); override to run a different + build. + ''; + }; + + bridgePort = lib.mkOption { + type = lib.types.port; + default = 9092; + description = '' + TCP port `swarm-authelia-bridge` listens on, loopback-bound + (`127.0.0.1:''${bridgePort}`) — one above authelia's own default + `port` (9091), outside hyperhive's other claimed ranges. + + Reachable directly from this host's other processes (this + container shares the host netns, same as authelia's own `port`) + without going through the gateway — this is an internal + service-to-service endpoint, not something meant to be exposed + publicly. + ''; + }; + + bridgeUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + readOnly = true; + default = if cfg.enable then "http://127.0.0.1:${toString cfg.bridgePort}" else null; + defaultText = lib.literalExpression ''if enable then "http://127.0.0.1:''${bridgePort}" else null''; + description = '' + Where `swarm-authelia-bridge` answers, **as seen from this + host** — correct only when a caller (`swarm-controller`) also + runs on this host, the same co-location assumption + `swarm.nix`'s `clientSecretFile` documents for its own + cross-host case. `null` when this host doesn't run + `swarm-authelia` at all. + + A split-host swarm has no automated delivery for this address: + the operator points `swarm-controller`'s own option at wherever + this host has made the bridge reachable (a firewall rule, a + different bind address), the same manual-copy shape used + throughout this codebase's other cross-host cases. ''; }; }; @@ -550,11 +618,16 @@ in config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) { # The derived half of the client list, declared the same way an # operator declares one. Everything downstream then reads a single - # uniformly-typed `cfg.oidc.clients` and cannot tell the two apart — + # uniformly-typed `cfg.oidc.clients` and cannot tell the parts apart — # including the assertions below, which is why a hive named `x` # colliding with a declared `hive-x` is caught rather than rendered - # twice. - services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf cfg.oidc.hiveIdentities hiveClients; + # twice. `bridgeClient` is unconditional (plain list concatenation, + # not `mkIf`-gated like `hiveClients`): the bridge is always present + # wherever this module is, so `oidc.clients` is never actually empty + # — see `oidcEnabled`'s comment above. + services.hyperhive.swarm.authelia.oidc.clients = + lib.optionals cfg.oidc.hiveIdentities hiveClients + ++ [ bridgeClient ]; # A redirect URI on a machine client is not harmless-but-unused: it # means whoever wrote it believes a browser is involved. Failing here @@ -753,6 +826,68 @@ in ''; }; + # 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 + # point of this whole unit: it is that same account, so it + # owns the file it writes and needs no elevated privilege. + # Ordered after authelia's own secrets generator (needs its + # own client secret, minted by that unit's `mint` loop) and + # after authelia itself (introspects against its local + # `/api/oidc/introspection`, so needs it answering — not + # 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). + 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" + ]; + wants = [ + "${unitName}-secrets.service" + "${unitName}.service" + ]; + serviceConfig = { + ExecStart = "${cfg.bridgePackage}/bin/swarm-authelia-bridge"; + User = unitName; + Group = unitName; + Restart = "on-failure"; + RestartSec = "5s"; + }; + environment = { + SWARM_AUTHELIA_BRIDGE_BIND = "127.0.0.1:${toString cfg.bridgePort}"; + # Own canonical store, alongside authelia's own state — + # NOT `swarm-controller`'s state dir: the two processes + # aren't guaranteed to be on the same host, and this store + # has to live wherever its writer (this bridge) does. See + # `swarm-authelia-bridge/README.md`'s "known limitation" + # section for the resulting `swarmctl`-owns-a-second-store + # seam. + SWARM_AUTHELIA_BRIDGE_STORE = "${stateDir}/swarm-authelia-bridge-users.json"; + 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 + # `SWARMCTL_AUTHELIA_BIN`. + SWARM_AUTHELIA_BRIDGE_AUTHELIA_BIN = "${cfg.package}/bin/authelia"; + # Local loopback, not the public HTTPS vhost: this process + # runs right next to authelia (same container, same netns), + # so there is a faster, simpler path than round-tripping + # through the gateway's nginx for a call nothing external + # ever needs to see. + SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL = "http://127.0.0.1:${toString cfg.port}/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"; + }; + }; + services.authelia.instances.${instance} = { enable = true; package = cfg.package; diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 4f6ff103..a17d1242 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -87,6 +87,14 @@ let SWARM_CONTROLLER_FORGE_TOKEN_FILE = "%d/forge-token"; }; + # Not a secret to deliver — `swarm-authelia-bridge`'s own bearer check + # is satisfied by THIS daemon's existing queue OIDC identity + # (`queueEnv` above): "one identity per principal" already covers this, + # so there is nothing new to mint or copy, just the bridge's address. + authBridgeEnv = lib.optionalAttrs (cfg.authBridgeUrl != null) { + SWARM_CONTROLLER_AUTH_BRIDGE_URL = cfg.authBridgeUrl; + }; + # Wrapped rather than documented: every one of these values is derived # from an option this deployment already set, so making the operator # re-supply them on the command line would be asking them to repeat the @@ -325,6 +333,30 @@ in graceful-absence shape the queue coordinates already use. ''; }; + + authBridgeUrl = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = if autheliaCfg.enable then autheliaCfg.bridgeUrl else null; + defaultText = lib.literalExpression '' + authelia's own `bridgeUrl` when this host also runs + `swarm-authelia`, else null + ''; + example = "http://127.0.0.1:9092"; + description = '' + Where `swarm-authelia-bridge` (the only writer of authelia's + users database) answers — see that option's own doc comment for + the cross-host caveat, since this default is only correct when + this host also runs `swarm-authelia`. + + No new credential to configure: the bearer token presented to + the bridge is minted from THIS daemon's own existing queue OIDC + identity (`queue.*` above) — "one identity per principal" + already covers it. `null` means no agent-identity support: + `CreateIdentity` jobs fail with a clear "no auth bridge + configured here" error rather than the daemon refusing to + start, the same graceful-absence shape `forgeTokenFile` uses. + ''; + }; }; config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) { @@ -480,15 +512,16 @@ in ]; }; - # Queue coordinates (`queueEnv`) and forge coordinates (`forgeEnv`) - # merge in last. The daemon refuses a PARTIAL set of either rather - # than treating it as absent, which is why each is built as one - # attrset and never assigned individually. + # Queue coordinates (`queueEnv`), forge coordinates (`forgeEnv`), and + # the auth-bridge address (`authBridgeEnv`) merge in last. The + # daemon refuses a PARTIAL set of any of them rather than treating + # it as absent, which is why each is built as one attrset and never + # assigned individually. # # They differ in how absence is prevented: the queue's is checked by # the assertions above, because a controller without a queue is - # broken rather than lighter; the forge's is genuinely optional and - # stays gated on `forgeTokenFile` resolving. + # broken rather than lighter; forge's and the auth bridge's are + # genuinely optional and stay gated on their own option resolving. environment = { SWARM_CONTROLLER_SOCKET = cfg.socketPath; # The swarm's hive directory, JSON-encoded — the full directory @@ -514,7 +547,8 @@ in SWARM_CONTROLLER_STALE_AFTER_SECS = toString cfg.staleAfterSeconds; } // queueEnv - // forgeEnv; + // forgeEnv + // authBridgeEnv; }; # A systemd credential is a SNAPSHOT: it is materialised into `%d` once, diff --git a/nix/packages/default.nix b/nix/packages/default.nix index d110005c..00954079 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -156,6 +156,12 @@ in # rather than every hive's. swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder"; + # The only process allowed to write swarm-authelia's users database — + # same "runs *inside* a container, not on the host" placement as + # `swarm-nats-auth` above (this one lives in `swarm-authelia`'s + # container, as authelia's own user, not the host's closure). + swarm-authelia-bridge = mkBinPackage "swarm-authelia-bridge" "hyperhive swarm-authelia users-database write bridge"; + # The swarm operator's CLI, out of `daemonBins` for the same reason as # the daemon above: it is installed by the swarm-controller module on # the one host that runs the controller, and belongs in that hive's