From 37ca7676d66c3331d4c875e217fcac3a01b94ee7 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 03:25:42 +0200 Subject: [PATCH] deploy: move the SSO provider toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The largest of these moves: sixteen references spelled through `let` aliases across eight modules, plus eight more spelled as a path, plus five documentation pages. authelia is also the clearest case for why the two namespaces exist. `swarm.authelia.url` is needed by *every* hive in the swarm — it says where to send a browser to authenticate — while running the container is the business of exactly one host. The client half and the server half were sharing a namespace whose whole contract is "identical everywhere", and only one of them could honour it. `swarm.authelia.oidc.clients` stays where it is for the same reason: several modules register a client there, gated on authelia running here, and the registry itself is what the service *is* rather than a decision about this machine. One sweep note worth recording: a grep for `swarm.authelia.enable` misses `swarmCfg.authelia.enable`, because the prefix is whatever the reading file bound. Grepping the suffix `.authelia.enable` finds both, and found a reference in swarm.nix that the path-shaped pattern did not. --- docs/gateway.md | 4 +-- docs/setup.md | 2 +- docs/swarm/services.md | 2 +- docs/swarm/sso.md | 2 +- nix/host-modules/deploy.nix | 22 ++++++++++++++ nix/host-modules/hive-forge/default.nix | 3 +- nix/host-modules/hive-matrix.nix | 3 +- nix/host-modules/local-defaults.nix | 2 +- nix/host-modules/otel.nix | 9 +++--- nix/host-modules/swarm-authelia.nix | 28 ++++++------------ nix/host-modules/swarm-controller.nix | 6 ++-- nix/host-modules/swarm-grafana.nix | 2 +- nix/host-modules/swarm-nats.nix | 5 ++-- nix/host-modules/swarm-otel.nix | 4 +-- nix/host-modules/swarm-required-services.nix | 31 ++++++++++++++------ nix/host-modules/swarm.nix | 2 +- 16 files changed, 77 insertions(+), 50 deletions(-) diff --git a/docs/gateway.md b/docs/gateway.md index 00ddcd7c..6d306c7f 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -14,7 +14,7 @@ Single nginx in front of every hyperhive web surface. Runs on the **host**, next | `chat./_matrix/*` | `chat.` | tuwunel (`8008`) | `matrix.gatewayHost != null` | | `chat./` | `chat.` | fluffychat-web static | `matrix.gui.enable` | | `chat./config.json` | `chat.` | inline JSON (FluffyChat boot config) | `matrix.gui.enable && domain != null` | -| `auth./` | `auth.` | authelia (`9091`) | `swarm.authelia.enable` | +| `auth./` | `auth.` | authelia (`9091`) | `deploy.authelia` | | `/` | `` | swarm-ui dist (static), behind an authelia subrequest | `deploy.swarm-ui` | The authelia vhost is declared only by the host that **runs** authelia, not by every hive that uses it — a client hive knows the swarm's `authelia.url` but must not answer for a name it doesn't serve. Its server name is exactly `swarm.authelia.domain`: authelia validates `authelia_url ⊂ session cookie domain` at startup, so a near-miss is a container that refuses to boot. It carries no `auth_basic` — the login page must not sit behind the login mechanism it replaces — and sets the four `X-Forwarded-{Proto,Host,Uri,For}` headers, since authelia decides by the *original* request rather than the hop it sees. @@ -64,7 +64,7 @@ Each location carries a duplicated `auth_basic` block (separate locations don't - `` → `127.0.0.1` - `forge.` → `127.0.0.1` (when forge.behindGateway) - `chat.` → `127.0.0.1` (when matrix.gatewayHost set) -- `auth.` → `127.0.0.1` (when swarm.authelia.enable) +- `auth.` → `127.0.0.1` (when deploy.authelia) `lib.unique` de-dupes if any sub-domain happens to equal another entry. Operators with real DNS leave it off. diff --git a/docs/setup.md b/docs/setup.md index 89d3f182..4af6a569 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -44,7 +44,7 @@ echo "hunter2" | hivectl gateway create-user mara --password-stdin hivectl gateway list-users ``` -### 3 · Swarm SSO (only when `swarm.authelia.enable`) +### 3 · Swarm SSO (only when `deploy.authelia`) ⚠️ **Required to finish the install, not optional.** Authelia treats an empty user store as a fatal startup error, so until this runs the diff --git a/docs/swarm/services.md b/docs/swarm/services.md index 996a634c..8dd046ae 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -34,7 +34,7 @@ One authelia per swarm, in a `swarm-authelia` container, at provider, differentiated by roles and claims rather than by mechanism — there is one IdP and one auth path. -- **`swarm.authelia.enable`** — run the container here. Defaults from +- **`deploy.authelia`** — run the container here. Defaults from `swarm.enableRequiredServices`. - **`swarm.authelia.url`** — where clients are sent to authenticate. Present on **every** hive, defaulting to this host's own instance only diff --git a/docs/swarm/sso.md b/docs/swarm/sso.md index 53aad373..26d5c7ea 100644 --- a/docs/swarm/sso.md +++ b/docs/swarm/sso.md @@ -14,7 +14,7 @@ separate `enable` would be a second fact free to disagree with the first. authelia binds loopback only. The **gateway** on the host running it publishes it as `auth.` — vhost, dnsmasq record and TLS -name all follow `swarm.authelia.enable`, so there is nothing to turn on +name all follow `deploy.authelia`, so there is nothing to turn on separately. (Details, including why a client hive must not declare that vhost: [`../gateway.md`](../gateway.md).) diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index d60bf322..bf2e6cdb 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -53,6 +53,10 @@ in [ "services" "hyperhive" "swarm" "ui" "enable" ] [ "services" "hyperhive" "deploy" "swarm-ui" ] ) + (lib.mkRenamedOptionModule + [ "services" "hyperhive" "swarm" "authelia" "enable" ] + [ "services" "hyperhive" "deploy" "authelia" ] + ) ]; options.services.hyperhive.deploy = { @@ -97,6 +101,24 @@ in ''; }; + authelia = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Run the swarm's authelia in a `swarm-authelia` container on this + host. {option}`services.hyperhive.swarm.enableRequiredServices` + turns this on — a swarm has one SSO provider, and that says it + lives here. + + With it off, this hive is a *client*: + {option}`services.hyperhive.swarm.authelia.url` still points at + whoever runs it, and no container is created. That asymmetry is + why the two live in different namespaces — every hive needs the + client half, only one runs the server half. + ''; + }; + controller = lib.mkOption { type = lib.types.bool; default = false; diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index d0fca0f6..95ad4db6 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -22,6 +22,7 @@ let # assertion below turns into an eval failure rather than a discovery # request to `null/.well-known/...`. autheliaCfg = config.services.hyperhive.swarm.authelia; + deployCfg = config.services.hyperhive.deploy; autheliaUrl = autheliaCfg.url; autheliaDiscoveryUrl = "${toString autheliaUrl}/.well-known/openid-configuration"; @@ -29,7 +30,7 @@ let # authelia, so the secret can be moved without an operator. The other # two cases (swarm side via swarmctl, remote hive) leave # `clientSecretFile` to be set explicitly — see docs/swarm/. - ssoLocal = autheliaCfg.enable; + ssoLocal = deployCfg.authelia; # Where the plaintext lands inside the forge container. Under # /var/lib rather than /run: the forge may start before the delivery diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 3b7aa588..b51c2a20 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -25,13 +25,14 @@ let # below turns into an eval failure rather than a discovery request to # `null/.well-known/…`. autheliaCfg = config.services.hyperhive.swarm.authelia; + deployCfg = config.services.hyperhive.deploy; autheliaUrl = autheliaCfg.url; # The all-local case: this host runs BOTH the homeserver and the swarm's # authelia, so the secret can be moved without an operator. The other # two cases (swarm side, remote hive) leave `clientSecretFile` to be set # explicitly — same split the forge module documents. - ssoLocal = autheliaCfg.enable; + ssoLocal = deployCfg.authelia; # Where the plaintext lands inside the matrix container. Under /var/lib # rather than /run: the homeserver may start before the delivery unit on diff --git a/nix/host-modules/local-defaults.nix b/nix/host-modules/local-defaults.nix index d7a0aed8..4cff68d3 100644 --- a/nix/host-modules/local-defaults.nix +++ b/nix/host-modules/local-defaults.nix @@ -95,7 +95,7 @@ in # ran its first boot on — so they belong to the mode that asserts this # box is the whole deployment, not to the options' own `default`. # - # Deriving them from `swarm.nats.enable` / `swarm.authelia.enable` + # Deriving them from `swarm.nats.enable` / `deploy.authelia` # inside those defaults is the mixing this file exists to prevent: the # option would be describing a deployment shape instead of describing # itself, and "what does all-local turn on?" would stop having one diff --git a/nix/host-modules/otel.nix b/nix/host-modules/otel.nix index b1cc2d7b..56bfa2e7 100644 --- a/nix/host-modules/otel.nix +++ b/nix/host-modules/otel.nix @@ -231,9 +231,7 @@ in clientSecretFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = - if - config.services.hyperhive.swarm.authelia.enable && config.services.hyperhive.hiveName != null - then + if config.services.hyperhive.deploy.authelia && config.services.hyperhive.hiveName != null then "${config.services.hyperhive.swarm.authelia.hostClientSecretDir}/" + "${config.services.hyperhive.swarm.authelia.hiveClientPrefix}${config.services.hyperhive.hiveName}.secret" else @@ -284,6 +282,7 @@ in let otel = config.services.hyperhive.otel; autheliaCfg = config.services.hyperhive.swarm.authelia; + deployCfg = config.services.hyperhive.deploy; swarmOtelCfg = config.services.hyperhive.swarm.otel; hiveName = config.services.hyperhive.hiveName; listen = "${config.services.hyperhive.network.bridgeIp}:${toString otel.collector.port}"; @@ -580,8 +579,8 @@ in # Only when the minting container is on THIS host. Elsewhere the file # is operator-provided and there is no local unit to order against — # naming one that does not exist orders nothing, silently. - after = lib.optional autheliaCfg.enable "container@${autheliaCfg.machine}.service"; - requires = lib.optional autheliaCfg.enable "container@${autheliaCfg.machine}.service"; + after = lib.optional deployCfg.authelia "container@${autheliaCfg.machine}.service"; + requires = lib.optional deployCfg.authelia "container@${autheliaCfg.machine}.service"; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 87278455..53aea22e 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -345,22 +345,12 @@ let ''; in { + # `enable` moved to `services.hyperhive.deploy.authelia` — see + # ./deploy.nix. Whether this host runs the swarm's SSO provider is a + # deployment decision; what stays here is what authelia IS, including + # `url` and the OIDC client registry every hive needs as a *client* + # whether or not it runs the container. options.services.hyperhive.swarm.authelia = { - enable = lib.mkOption { - type = lib.types.bool; - default = false; - example = true; - description = '' - Run the swarm's authelia in a `swarm-authelia` container on this - host. `services.hyperhive.swarm.enableRequiredServices` turns - this on — a swarm has one SSO provider, and that says it lives - here. - - With it off, this hive is a *client*: `url` below still points - at whoever runs it, and no container is created. - ''; - }; - package = lib.mkOption { type = lib.types.package; default = pkgs.authelia; @@ -437,7 +427,7 @@ in url = lib.mkOption { type = lib.types.nullOr lib.types.str; - default = if cfg.enable then "https://${cfg.domain}" else null; + default = if deployCfg.authelia then "https://${cfg.domain}" else null; defaultText = lib.literalExpression ''if enable then "https://''${domain}" else null''; example = "https://auth.example.com"; description = '' @@ -820,7 +810,7 @@ in 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; + default = if deployCfg.authelia 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 @@ -839,7 +829,7 @@ in }; }; - config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) { + config = lib.mkIf (hyperhiveCfg.enable && deployCfg.authelia) { # 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 parts apart — @@ -974,7 +964,7 @@ in # Authelia's own gateway surface: the vhost that fronts it and the # name the hive resolver answers for. Both live here rather than in - # the gateway, and both are inside `cfg.enable` — that guard is the + # the gateway, and both are inside `deployCfg.authelia` — that guard is the # load-bearing part. # # ⚠️ Every hive in a swarm knows `authelia.url`, but only the host diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 0d376272..04840c5e 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -25,7 +25,7 @@ let # the right behaviour is for `swarmctl user add` to fail saying the # value is unset. A guessed path would resolve cleanly and write a file # nothing reads, which is the failure mode that costs an afternoon. - autheliaEnv = lib.optionalAttrs autheliaCfg.enable { + autheliaEnv = lib.optionalAttrs deployCfg.authelia { # 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"; @@ -451,7 +451,7 @@ in authBridgeUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; - default = if autheliaCfg.enable then autheliaCfg.bridgeUrl else null; + default = if deployCfg.authelia then autheliaCfg.bridgeUrl else null; defaultText = lib.literalExpression '' authelia's own `bridgeUrl` when this host also runs `swarm-authelia`, else null @@ -499,7 +499,7 @@ in # client list would be a second source of truth for a string whose # mismatch is an opaque 401 from the token endpoint. Same shape as the # queue's own client declaration. - services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf autheliaCfg.enable [ + services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf deployCfg.authelia [ { id = queueClientId; description = "HyperHive swarm controller"; diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 922597d5..02c7b931 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -77,7 +77,7 @@ let # The all-local case: this host runs BOTH Grafana and the swarm's authelia, # so the minted secret can be moved without an operator. Same split the # forge and matrix modules document. - ssoLocal = deployCfg.grafana && autheliaCfg.enable; + ssoLocal = deployCfg.grafana && deployCfg.authelia; autheliaUrl = toString autheliaCfg.url; # Where the plaintext lands inside the container. Under /var/lib rather diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index 1e4d0b2b..aed7c984 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -7,6 +7,7 @@ let cfg = config.services.hyperhive.swarm.nats; autheliaCfg = config.services.hyperhive.swarm.authelia; + deployCfg = config.services.hyperhive.deploy; autheliaUrl = autheliaCfg.url; networkCfg = config.services.hyperhive.network; # Read even when the controller runs on a different host: what is needed @@ -514,7 +515,7 @@ in # authorization grant 'client_credentials'`. Introspection — which # is all the responder needs today — worked throughout, which is why # nothing was visibly broken while the comment was untrue. - services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf autheliaCfg.enable [ + services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf deployCfg.authelia [ { id = cfg.clientId; description = "HyperHive swarm queue"; @@ -767,7 +768,7 @@ in # being up says nothing about whether its in-container secrets unit # has finished. The wait in the script is what actually closes it; # this only stops us spinning for the full timeout on every boot. - ++ lib.optional autheliaCfg.enable "container@${autheliaCfg.machine}.service"; + ++ lib.optional deployCfg.authelia "container@${autheliaCfg.machine}.service"; requires = lib.optional cfg.autoGenerateCallout "swarm-nats-callout-keys.service"; serviceConfig = { Type = "oneshot"; diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index e899ab24..5dbac7ea 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -517,7 +517,7 @@ in # declaration would break every hive that runs a collector and # publishes nothing. services.hyperhive.swarm.authelia.oidc.clients = - lib.mkIf (autheliaCfg.enable && cfg.publishedScrapeTargets != { }) + lib.mkIf (deployCfg.authelia && cfg.publishedScrapeTargets != { }) [ { id = cfg.clientId; @@ -555,7 +555,7 @@ in # after it. On a fresh swarm that is a permanent stall presenting as # "metrics are broken", several layers from its cause. systemd.services.swarm-otel-oidc-secret = - lib.mkIf (autheliaCfg.enable && cfg.publishedScrapeTargets != { }) + lib.mkIf (deployCfg.authelia && cfg.publishedScrapeTargets != { }) { description = "deliver the swarm collector's OIDC client secret from authelia"; after = [ "container@${autheliaCfg.machine}.service" ]; diff --git a/nix/host-modules/swarm-required-services.nix b/nix/host-modules/swarm-required-services.nix index 78ebcc09..6ad0d4e7 100644 --- a/nix/host-modules/swarm-required-services.nix +++ b/nix/host-modules/swarm-required-services.nix @@ -24,9 +24,13 @@ in description = '' Host the swarm's shared services on this hive. The services that exist once per swarm rather than once per hive and are *optional* - — the matrix homeserver, the SSO provider — have their `enable` - asserted from this, so a swarm's service host is declared in one - place. + — the matrix homeserver, the SSO provider, the queue, the metrics + and log stores — have their toggle asserted from this, so a + swarm's service host is declared in one place. + + Those toggles live in two namespaces and the split is deliberate: + {option}`services.hyperhive.deploy.*` for "does THIS host run it", + `swarm.*.enable` for the ones not yet moved. See ./deploy.nix. The forge is swarm-wide too but has nothing to assert: it is the canonical store for the meta flake and every agent's config repo, @@ -46,7 +50,6 @@ in # operator who hasn't spoken, yields to one who has. config.services.hyperhive.swarm = { matrix.enable = lib.mkDefault swarmCfg.enableRequiredServices; - authelia.enable = lib.mkDefault swarmCfg.enableRequiredServices; # The queue. Added later than the two above and missed at the time — # this file predates the `swarm-nats` container by nine days and had # not been revisited since, so its absence was sequence rather than @@ -75,16 +78,26 @@ in # this is ./otel.nix's existing per-hive option). config.services.hyperhive.otel.enable = lib.mkDefault swarmCfg.enableRequiredServices; + # The rest of the shared services, deriving from the same switch as the + # `swarm.*` ones above. They read differently only because "does THIS + # host run it" lives in `deploy.*` (./deploy.nix) — `swarm.*` has to be + # identical on every host, and these are exactly the values that must + # differ. + # + # authelia: a swarm has one SSO provider, and this says it lives here. + # With it off the hive is a *client* — `swarm.authelia.url` still points + # at whoever runs it. + config.services.hyperhive.deploy.authelia = lib.mkDefault swarmCfg.enableRequiredServices; + # The metrics pair, deriving together on purpose: a store with no UI is # unreadable and a UI with no store is empty, so there is no sensible # deployment that takes one and not the other from this switch. An # operator who wants exactly one still sets it directly, which # `mkDefault` allows. - # - # The log store derives from the same switch for the same reason as the - # rest: a hive that is not the service host is a *client* of it, not a - # second one. config.services.hyperhive.deploy.victoriametrics = lib.mkDefault swarmCfg.enableRequiredServices; - config.services.hyperhive.deploy.victorialogs = lib.mkDefault swarmCfg.enableRequiredServices; config.services.hyperhive.deploy.grafana = lib.mkDefault swarmCfg.enableRequiredServices; + + # The log store, from the same switch for the same reason as the rest: a + # hive that is not the service host is a *client* of it, not a second one. + config.services.hyperhive.deploy.victorialogs = lib.mkDefault swarmCfg.enableRequiredServices; } diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index c8ac1f58..5682ac8b 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -91,7 +91,7 @@ let # unrepresentable rather than merely detected: a default set is all or # nothing, and the assertion is then only ever about what an operator # typed. - queueLocal = swarmCfg.nats.enable && swarmCfg.authelia.enable && cfg.hiveName != null; + queueLocal = swarmCfg.nats.enable && deployCfg.authelia && cfg.hiveName != null; in { options.services.hyperhive.swarm.hives = lib.mkOption {