diff --git a/docs/swarm/services.md b/docs/swarm/services.md index 0ad85e84..b3e88c19 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -69,11 +69,11 @@ provider, differentiated by roles and claims rather than by mechanism — there is one IdP and one auth path. - **`deploy.authelia`** — run the container here. -- **`swarm.authelia.url`** — where clients go to authenticate. Present - on **every** hive and the same value on all of them: - `https://`, whether or not this host runs the - container. Set it explicitly when joining a swarm whose IdP is under - another name. +- **`swarm.authelia.url`** — where clients go to authenticate. + Present on **every** hive, defaulting to this host's own instance only + when this module is the thing running it; otherwise `null`, and a hive + joining someone else's swarm sets it explicitly. Null means "no SSO + configured", and consumers say so rather than guessing an address. swarm-controller writes the users database, not by hand: hive-c0re creates and destroys agents continuously, so the subject set is dynamic. diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 4a7f29de..1b22d878 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -573,6 +573,20 @@ in host: see docs/swarm/ for which secret goes where. ''; } + { + # Without a provider URL there is nothing to discover against, + # and the rendered unit would ask `null/.well-known/…`. + assertion = autheliaUrl != null; + message = '' + The forge's SSO login source requires + services.hyperhive.swarm.authelia.url — the base URL of the + swarm's SSO provider. + + It defaults to this host's own instance only when this host + runs authelia. A hive that federates with a swarm sets it + explicitly to wherever that provider lives. + ''; + } { assertion = cfg.rootUrl == null || lib.hasSuffix "/" cfg.rootUrl; message = '' diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 0f4fc3cb..1304d9b2 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -968,6 +968,20 @@ in host: see docs/swarm/ for which secret goes where. ''; } + { + # Without a provider URL there is nothing to discover against, and + # the rendered config would name `null` as its issuer. + assertion = autheliaUrl != null; + message = '' + This homeserver's SSO login flow requires + services.hyperhive.swarm.authelia.url — the base URL of the + swarm's SSO provider. + + It defaults to this host's own instance only when this host + runs authelia. A hive that federates with a swarm sets it + explicitly to wherever that provider lives. + ''; + } { # The callback URL must name the homeserver itself, and with no # gateway vhost there is no public name for it to be built from. diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index dcdafdc3..c1c0d035 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -457,25 +457,23 @@ in }; url = lib.mkOption { - type = lib.types.str; - default = "https://${cfg.domain}"; - defaultText = lib.literalExpression ''"https://''${domain}"''; + type = lib.types.nullOr lib.types.str; + default = if deployCfg.authelia.enable then "https://${cfg.domain}" else null; + defaultText = lib.literalExpression ''if enable then "https://''${domain}" else null''; example = "https://auth.example.com"; description = '' Base URL clients are sent to for authentication — the half of this module that exists on **every** hive, not just the one running the container. - Names {option}`domain`, and does **not** ask whether this host - runs the container: a swarm has one SSO provider, so every hive - addresses the same name and resolution decides where it is — - dnsmasq locally on the host serving the vhost, the real network - anywhere else. There is no loopback-vs-remote branch to get - wrong, the same way {option}`services.hyperhive.swarm.otel.domain` - has none. - - Not having SSO is not a supported deployment: every swarm has an - IdP, so this is never `null`. + Defaults to this host's own instance **only when this module is + the thing running it**; in that case the URL is not a guess, it + is where this module just put the container. Otherwise `null`, + and a hive that federates with a swarm sets it explicitly to + wherever the swarm's authelia lives. Null means "no SSO + configured" and consumers say so rather than inventing an + address — an endpoint baked in as a fallback is one that + resolves cleanly and points at the wrong machine. ''; }; @@ -952,6 +950,18 @@ in # would reject it, but three layers away and at boot — naming both # sources here is the cheaper failure. ++ [ + { + # The bridge introspects by name, so a null URL becomes a nix + # coercion error several files from its cause. Only reachable by + # enabling authelia and clearing `url` by hand — an assertion + # rather than a fallback, because a guessed URL that evaluates + # cleanly is worse than a refused build. + assertion = cfg.url != null; + message = + "services.hyperhive.swarm.authelia.url must not be null when authelia " + + "is enabled: swarm-authelia-bridge introspects at " + + "`\${url}/api/oidc/introspection` from inside its container."; + } { assertion = lib.length (lib.unique (map (c: c.id) cfg.oidc.clients)) == lib.length cfg.oidc.clients; message = diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index fb11ad80..383e768e 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -382,7 +382,7 @@ in tokenEndpoint = lib.mkOption { type = lib.types.str; - default = "${autheliaCfg.url}/api/oidc/token"; + default = lib.optionalString (autheliaCfg.url != null) "${autheliaCfg.url}/api/oidc/token"; defaultText = lib.literalExpression ''"''${swarm.authelia.url}/api/oidc/token"''; description = '' The OIDC token endpoint the controller mints its own access @@ -773,13 +773,12 @@ in { assertion = cfg.queue.tokenEndpoint != ""; message = '' - services.hyperhive.swarm.controller.queue.tokenEndpoint is unset. + services.hyperhive.swarm.controller.queue.tokenEndpoint is unset, + which means services.hyperhive.swarm.authelia.url is null. - It defaults from services.hyperhive.swarm.authelia.url, so this - only happens when it has been cleared by hand. The controller - mints its own access token before it may connect to the queue, - so it needs to know where the swarm's identity provider lives — - set this endpoint directly. + The controller mints its own access token before it may connect + to the queue, so it needs to know where the swarm's identity + provider lives — set that URL, or set this endpoint directly. ''; } { diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 006e3abf..5e6ac674 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -109,6 +109,18 @@ let # operator sees, not a coercion error from here. domainBase = if swarmDomain == null then "invalid" else swarmDomain; + # Is SSO configured for this SWARM. Swarm-wide by construction — `swarm.*` is + # identical on every host — and the option's own description is what makes + # this the right question to ask: a null URL means "no SSO configured". + # + # 🩸 A subject of an assertion below, NOT a gate. Dropping the OIDC block when + # this is false looks conservative and is the outage: `disable_login_form` is + # unconditional a few hundred lines down, so a Grafana with no OIDC settings + # is a Grafana with no login of any kind, arrived at silently. SSO is a + # requirement of running this service, so an unconfigured swarm fails to + # build and says which option to set. + ssoConfigured = hyperhiveCfg.swarm.authelia.url != null; + # A reader of the store is defined by holding a certificate the store # accepts, never by standing next to it — the rule # ./glue-matrix-bao-token.nix states in full. @@ -436,11 +448,34 @@ in # host that runs authelia, and this whole block is gated on the host that # runs Grafana. ./glue-grafana-oidc-client.nix is where it moved to. - # This arm fires only where Grafana runs — this whole block is under - # `deploy.grafana.enable`. SSO itself needs no assertion any more: - # `swarm.authelia.url` is non-nullable, so every swarm has one and the - # OIDC block below always names a real endpoint. + # Both arms are what used to be a silent gate, and both fire only where + # Grafana runs — this whole block is under `deploy.grafana.enable`. The + # binding itself is asserted rather than a copy of its formula, the way + # ./swarm-nats.nix's own `autheliaUrl` arm does it: two spellings of one + # boolean is two places for a future edit to land in only one. assertions = [ + { + # SSO is not optional for this service, and the reason is a hundred + # lines below in `auth.disable_login_form = true`: Grafana ships an + # `admin`/`admin` account on a public vhost, so the password box is + # off whatever the topology. Emitting no OIDC block when the swarm + # names no IdP therefore produces a container with no way in at all — + # a state no log names, since nothing failed. Failing the build and + # naming the option is the only report that reaches anyone. + assertion = ssoConfigured; + message = '' + services.hyperhive.deploy.grafana.enable requires + services.hyperhive.swarm.authelia.url — Grafana's only login is SSO, + because its local login form is disabled unconditionally (it ships + an admin/admin account and its vhost is on the public gateway). + + It defaults to this host's own instance only when this host runs + authelia. A hive that federates with a swarm sets it explicitly to + wherever that provider lives. Grafana exchanges its authorization + code at `''${url}/api/oidc/token` from inside its container, so a + null URL leaves no endpoint to name. + ''; + } { # The other half of one login: the OIDC block names a `$__file{}` that # `swarm-bao-grafana-oidc.service` below writes, and that unit reads diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index c86a3c62..a13974c3 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -511,6 +511,18 @@ in to have this host mint all four. ''; } + { + assertion = autheliaUrl != null; + message = '' + services.hyperhive.deploy.nats.enable requires + services.hyperhive.swarm.authelia.url — the queue authenticates + clients by validating tokens that authelia issued. + + It defaults to this host's own instance only when this host + runs authelia. A hive that federates with a swarm sets it + explicitly to wherever that provider lives. + ''; + } ]; # One declaration, two readers. The queue knows which client id it diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index 868df8b7..945f79ea 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -1027,6 +1027,22 @@ in should not be described as both. ''; } + { + # A hive proves who it is with a token this provider mints, so + # there is no version of this collector that runs without one. + # Stated as an assertion rather than a fallback because a guessed + # issuer URL evaluates cleanly and refuses every hive at runtime. + assertion = hyperhiveCfg.swarm.authelia.url != null; + message = '' + services.hyperhive.deploy.swarm-otel.enable is true but + services.hyperhive.swarm.authelia.url is null: every hive + authenticates to this collector as itself, and the token comes + from the swarm's identity provider. + + Point authelia.url at the swarm's provider, or enable + services.hyperhive.swarm.authelia on the host that runs it. + ''; + } { # A port collision between two listeners on one host is a runtime # coin toss with nothing in any log — the failure this whole diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index d0e36dcd..fd1fe9c8 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -500,8 +500,8 @@ in options.services.hyperhive.swarm.statusPublish = { tokenEndpoint = lib.mkOption { type = lib.types.nullOr lib.types.str; - default = "${swarmCfg.authelia.url}/api/oidc/token"; - defaultText = lib.literalExpression ''"''${swarm.authelia.url}/api/oidc/token"''; + default = if swarmCfg.authelia.url != null then "${swarmCfg.authelia.url}/api/oidc/token" else null; + defaultText = lib.literalExpression ''"''${swarm.authelia.url}/api/oidc/token" when that URL is set, else null''; example = "https://auth.example.com/api/oidc/token"; description = '' The swarm IdP's OAuth2 token endpoint. This hive mints a diff --git a/nix/module-eval/grafana.nix b/nix/module-eval/grafana.nix index ae825800..c1008194 100644 --- a/nix/module-eval/grafana.nix +++ b/nix/module-eval/grafana.nix @@ -70,6 +70,17 @@ let swarm.authelia.url = "https://auth.example.invalid"; }; + # The mirror image: the identity is placed, and the swarm names no IdP. The + # other half of "SSO must always be configured", and isolated the same way — + # exactly one thing wrong, so the arm reads one refusal. + grafanaNoSso = hive { + deploy.grafana.enable = true; + deploy.grafana.plugins = [ ]; + deploy.grafana.package = pkgs.emptyDirectory; + deploy.bao.clientCertFile = "/etc/pki/bao-client.pem"; + deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem"; + }; + # Did ./host-modules/swarm-grafana.nix refuse this host, and for which of its # two reasons. An assertion is a config VALUE until something forces it — # `.config` never throws — so a fixture in a state the module refuses is @@ -153,7 +164,19 @@ let && !(grafanaRefusedFor grafanaNoIdentity "swarm.authelia.url"); } { - # Without this the arm above proves nothing: a refusal that fires on + # "SSO must always be configured", as an eval-time refusal rather than a + # gate. A null URL used to drop the OIDC block silently, and + # `disable_login_form` is unconditional a hundred lines below it, so that + # combination produced a Grafana with no SSO and no password box — an + # outage whose cause is a boolean that evaluated to false at build time + # and left no trace. Same isolation as the arm above, mirrored. + name = "a grafana host in a swarm with no IdP is refused, naming swarm.authelia.url"; + ok = + grafanaRefusedFor grafanaNoSso "services.hyperhive.swarm.authelia.url" + && !(grafanaRefusedFor grafanaNoSso "deploy.bao.clientCertFile"); + } + { + # Without this the two arms above prove nothing: a refusal that fires on # every host is not a check, and both of these are hosts a swarm is # expected to have. Read through the same helper, so a message that # stopped naming its option would fail the arms above rather than pass diff --git a/nix/module-eval/nats-authelia.nix b/nix/module-eval/nats-authelia.nix index cd72aa1b..4f343c04 100644 --- a/nix/module-eval/nats-authelia.nix +++ b/nix/module-eval/nats-authelia.nix @@ -59,15 +59,6 @@ let swarm.nats.calloutUserSeedFile = "/run/secrets/nats-user.seed"; swarm.nats.calloutIssuerSeedFile = "/run/secrets/nats-issuer.seed"; }; - - # A hive running NOTHING of the swarm's own services — no IdP here, no - # `swarm.authelia.url` set by hand. The whole point of the fixture is what it - # does *not* say: it is the shape whose IdP address used to be null, and - # before that a co-location-derived guess. - autheliaNotColocated = hive { }; - # The same swarm's IdP host, for the arm that the two agree. - autheliaColocated = hive { deploy.authelia.enable = true; }; - cases = [ { # Reads the RENDERED settings, not the option: `calloutBlocks {…} // { @@ -125,34 +116,6 @@ let 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; } - { - # Config NAMES the IdP; it never computes where the IdP is. Both arms - # matter together: the address is the swarm's name on a hive that runs - # nothing, and it is the SAME string on the hive that serves the vhost — - # so a re-introduced co-location branch shows up as the two disagreeing - # rather than as a value that merely looks plausible on one of them. - name = "the swarm IdP address is its domain on every hive, co-located or not"; - ok = - autheliaNotColocated.services.hyperhive.swarm.authelia.url - == "https://${autheliaNotColocated.services.hyperhive.swarm.authelia.domain}" - && - autheliaColocated.services.hyperhive.swarm.authelia.url - == autheliaNotColocated.services.hyperhive.swarm.authelia.url; - } - { - # …and the name it resolves through is a DOMAIN, not a host this config - # picked. `127.0.0.1` is what the old default rendered on the IdP host, - # the bridge address is what the queue's defaults still render, and - # neither is an address a client may be handed: the domain is allowed to - # resolve differently in different places, which is the whole property. - name = "the swarm IdP address names no host address"; - ok = - let - url = autheliaNotColocated.services.hyperhive.swarm.authelia.url; - bridgeIp = autheliaNotColocated.services.hyperhive.network.bridgeIp; - in - !(lib.hasInfix "127.0.0.1" url) && !(lib.hasInfix "localhost" url) && !(lib.hasInfix bridgeIp url); - } ]; in runGroup "nats-authelia" cases