Watch
0
0
Fork
You've already forked hyperhive
0

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 {