From 4b6214305f4fba4cdd658f1420561475a7786f53 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 19 Sep 2026 20:39:19 +0200 Subject: [PATCH 1/2] nix: address the swarm IdP by its domain, not by who runs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swarm.authelia.url` defaulted to `https://` only when this host ran the container, and to `null` otherwise — so the address a client is given was a statement about co-location rather than about the swarm. A swarm has one SSO provider; every hive addresses the same name and resolution decides which address that reaches, exactly as `swarm.otel.domain` already works. The option stays nullable: "this swarm has no IdP" is still expressible, it is just now something an operator states rather than something not running the container produces. The Grafana fixture that exercised the no-IdP refusal says it explicitly. Closes #4536 --- docs/swarm/services.md | 10 ++++---- nix/host-modules/swarm-authelia.nix | 23 ++++++++++-------- nix/module-eval/grafana.nix | 5 ++++ nix/module-eval/nats-authelia.nix | 37 +++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/docs/swarm/services.md b/docs/swarm/services.md index b3e88c19..5e39a192 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -70,10 +70,12 @@ 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. + Present on **every** hive and the same value on all of them: + `https://`, whether or not this host runs the + container. The name is what a client is given; resolution decides which + address it reaches. Set it explicitly when joining a swarm whose IdP is + under another name, or to `null` to say the swarm has no SSO — consumers + then refuse 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/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index c1c0d035..f95659c0 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -458,22 +458,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''; + 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. + + Still nullable, for a deployment that has to say "this swarm has + no IdP" explicitly; consumers refuse rather than invent an + address when it is null. ''; }; diff --git a/nix/module-eval/grafana.nix b/nix/module-eval/grafana.nix index c1008194..524d0622 100644 --- a/nix/module-eval/grafana.nix +++ b/nix/module-eval/grafana.nix @@ -73,12 +73,17 @@ let # 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. + # + # The null is now written out: `swarm.authelia.url` defaults to the swarm's + # IdP name on every hive, so "this swarm has no IdP" is a thing an operator + # states rather than a thing not running the container produces. 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"; + swarm.authelia.url = null; }; # Did ./host-modules/swarm-grafana.nix refuse this host, and for which of its 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 From 5ec0ce90fdf54e100760ccc6fd3cf8d1119c5eca Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 21 Sep 2026 18:08:56 +0200 Subject: [PATCH 2/2] nix: make swarm.authelia.url non-nullable, trim its docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review response on #4620: not having SSO is not a supported deployment, so the type should not permit it, and the docs paragraph explaining why SSO is always present is redundant once the type says so. - swarm.authelia.url drops types.nullOr. - Every consumer's null-arm is gone: two option defaults (swarm-controller's and swarm's own statusPublish.tokenEndpoint) that produced an empty/null placeholder when the URL was null now unconditionally compute the real derived URL. Five now-dead "assertion = ... != null" guards (swarm-authelia's bridge, swarm-grafana, swarm-otel, swarm-nats, hive-forge, hive-matrix) are removed as unreachable — in every case the same URL was already interpolated unconditionally a few lines below the guard. - grafanaNoSso, the module-eval fixture whose sole purpose was exercising the now-unsupported no-IdP refusal, is removed along with its dedicated test case; swarm.authelia.url = null is a type error now, not a value that reaches that assertion. - docs/swarm/services.md: cut the clause about setting the option to null and the sentence explaining why the URL is co-location- independent — both redundant now that the type enforces it. --- docs/swarm/services.md | 10 +++--- nix/host-modules/hive-forge/default.nix | 14 -------- nix/host-modules/hive-matrix.nix | 14 -------- nix/host-modules/swarm-authelia.nix | 19 ++--------- nix/host-modules/swarm-controller.nix | 13 ++++---- nix/host-modules/swarm-grafana.nix | 43 +++---------------------- nix/host-modules/swarm-nats.nix | 12 ------- nix/host-modules/swarm-otel.nix | 16 --------- nix/host-modules/swarm.nix | 4 +-- nix/module-eval/grafana.nix | 30 +---------------- 10 files changed, 21 insertions(+), 154 deletions(-) diff --git a/docs/swarm/services.md b/docs/swarm/services.md index 5e39a192..0ad85e84 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -69,13 +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: +- **`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. The name is what a client is given; resolution decides which - address it reaches. Set it explicitly when joining a swarm whose IdP is - under another name, or to `null` to say the swarm has no SSO — consumers - then refuse rather than guessing an address. + 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 f95659c0..dcdafdc3 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -457,7 +457,7 @@ in }; url = lib.mkOption { - type = lib.types.nullOr lib.types.str; + type = lib.types.str; default = "https://${cfg.domain}"; defaultText = lib.literalExpression ''"https://''${domain}"''; example = "https://auth.example.com"; @@ -474,9 +474,8 @@ in wrong, the same way {option}`services.hyperhive.swarm.otel.domain` has none. - Still nullable, for a deployment that has to say "this swarm has - no IdP" explicitly; consumers refuse rather than invent an - address when it is null. + Not having SSO is not a supported deployment: every swarm has an + IdP, so this is never `null`. ''; }; @@ -953,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 524d0622..ae825800 100644 --- a/nix/module-eval/grafana.nix +++ b/nix/module-eval/grafana.nix @@ -70,22 +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. - # - # The null is now written out: `swarm.authelia.url` defaults to the swarm's - # IdP name on every hive, so "this swarm has no IdP" is a thing an operator - # states rather than a thing not running the container produces. - 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"; - swarm.authelia.url = null; - }; - # 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 @@ -169,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