diff --git a/docs/swarm/services.md b/docs/swarm/services.md index b3e88c19..0ad85e84 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, 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.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-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 1b22d878..4a7f29de 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -573,20 +573,6 @@ 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 1304d9b2..0f4fc3cb 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -968,20 +968,6 @@ 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 c1c0d035..dcdafdc3 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -457,23 +457,25 @@ in }; url = lib.mkOption { - 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''; + type = lib.types.str; + default = "https://${cfg.domain}"; + defaultText = lib.literalExpression ''"https://''${domain}"''; 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. - 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. + 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`. ''; }; @@ -950,18 +952,6 @@ 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 383e768e..fb11ad80 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 = lib.optionalString (autheliaCfg.url != null) "${autheliaCfg.url}/api/oidc/token"; + default = "${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,12 +773,13 @@ in { assertion = cfg.queue.tokenEndpoint != ""; message = '' - services.hyperhive.swarm.controller.queue.tokenEndpoint is unset, - which means services.hyperhive.swarm.authelia.url is null. + services.hyperhive.swarm.controller.queue.tokenEndpoint is unset. - 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. + 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. ''; } { diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 5e6ac674..006e3abf 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -109,18 +109,6 @@ 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. @@ -448,34 +436,11 @@ 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. - # 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. + # 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. 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 a13974c3..c86a3c62 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -511,18 +511,6 @@ 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 945f79ea..868df8b7 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -1027,22 +1027,6 @@ 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 fd1fe9c8..d0e36dcd 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 = 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''; + default = "${swarmCfg.authelia.url}/api/oidc/token"; + defaultText = lib.literalExpression ''"''${swarm.authelia.url}/api/oidc/token"''; 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 c1008194..ae825800 100644 --- a/nix/module-eval/grafana.nix +++ b/nix/module-eval/grafana.nix @@ -70,17 +70,6 @@ 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 @@ -164,19 +153,7 @@ let && !(grafanaRefusedFor grafanaNoIdentity "swarm.authelia.url"); } { - # "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 + # Without this the arm above proves 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 4f343c04..cd72aa1b 100644 --- a/nix/module-eval/nats-authelia.nix +++ b/nix/module-eval/nats-authelia.nix @@ -59,6 +59,15 @@ 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 {…} // { @@ -116,6 +125,34 @@ 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