deploy: move the SSO provider toggle

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.
This commit is contained in:
atlas 2026-08-30 03:25:42 +02:00 committed by mara
commit 37ca7676d6
16 changed files with 77 additions and 50 deletions

View file

@ -14,7 +14,7 @@ Single nginx in front of every hyperhive web surface. Runs on the **host**, next
| `chat.<swarm>/_matrix/*` | `chat.<swarm>` | tuwunel (`8008`) | `matrix.gatewayHost != null` |
| `chat.<swarm>/` | `chat.<swarm>` | fluffychat-web static | `matrix.gui.enable` |
| `chat.<swarm>/config.json` | `chat.<swarm>` | inline JSON (FluffyChat boot config) | `matrix.gui.enable && domain != null` |
| `auth.<swarm>/` | `auth.<swarm>` | authelia (`9091`) | `swarm.authelia.enable` |
| `auth.<swarm>/` | `auth.<swarm>` | authelia (`9091`) | `deploy.authelia` |
| `<swarm>/` | `<swarm>` | 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
- `<hive-domain>``127.0.0.1`
- `forge.<swarm>``127.0.0.1` (when forge.behindGateway)
- `chat.<swarm>``127.0.0.1` (when matrix.gatewayHost set)
- `auth.<swarm>``127.0.0.1` (when swarm.authelia.enable)
- `auth.<swarm>``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.

View file

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

View file

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

View file

@ -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.<swarm.domain>` — 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).)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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" ];

View file

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

View file

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