Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
Author SHA1 Message Date
atlas
5ec0ce90fd nix: make swarm.authelia.url non-nullable, trim its docs
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.
2026-09-21 18:14:28 +02:00
atlas
4b6214305f nix: address the swarm IdP by its domain, not by who runs it
`swarm.authelia.url` defaulted to `https://<domain>` 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
2026-09-21 18:14:28 +02:00
11 changed files with 69 additions and 155 deletions

View file

@ -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://<swarm.authelia.domain>`, 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.

View file

@ -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 = ''

View file

@ -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.

View file

@ -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 =

View file

@ -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.
'';
}
{

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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