diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 2ac05277..23db1bde 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -22,6 +22,43 @@ let # it is rebuilt from the current CA every boot rather than going stale. matrixCaBundle = "/run/hive-matrix-ca/ca-bundle.crt"; swarmDomain = config.services.hyperhive.swarm.domain; + + # `url` is the half of the authelia module that exists on EVERY hive — + # null when no SSO provider is configured anywhere, which the assertion + # below turns into an eval failure rather than a discovery request to + # `null/.well-known/…`. + autheliaCfg = config.services.hyperhive.swarm.authelia; + 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 = cfg.sso.enable && autheliaCfg.enable; + + # Where the plaintext lands inside the matrix container. Under /var/lib + # rather than /run: the homeserver may start before the delivery unit on + # a later boot, and a secret that evaporates on reboot turns a working + # login into an intermittent one. + matrixSecretPath = "/var/lib/tuwunel-oidc/${cfg.sso.clientId}.secret"; + + # ⚠️ tuwunel does NOT read the path above directly, and this indirection + # is not ceremony. Upstream's own words: "under systemd the path must be + # visible to the service after sandboxing (ReadWritePaths / ProtectHome), + # typically by placing the file under /etc/tuwunel/" — which this + # container has no writable etc for. `LoadCredential` is the answer + # already in use two units below for the registration token, and for the + # same reason: it keeps `DynamicUser=true` + `PrivateUsers=true` intact + # with no host-side chown or GID pinning. + matrixSecretCredential = "/run/credentials/tuwunel.service/oidc_client_secret"; + + # Format-locked by tuwunel, not chosen here: the callback host must point + # directly at the matrix server and the path is fixed at + # `/_matrix/client/unstable/login/sso/callback/`. Built once + # and read twice — by the homeserver's own config and by the client entry + # handed to authelia — because a redirect-URI mismatch is a rejected + # login with no error text worth reading. + ssoCallbackUrl = "https://${toString cfg.gatewayHost}/_matrix/client/unstable/login/sso/callback/${cfg.sso.clientId}"; # Falls back to the SWARM domain: a swarm runs one homeserver, so its # identifier belongs to the swarm rather than to whichever hive happens # to host it — otherwise moving the container between hives would look @@ -401,7 +438,15 @@ in default = false; description = '' Let this homeserver delegate login to the swarm's authelia, - as an OIDC relying party ("matrix next-gen auth"). + as an OIDC relying party — matrix SSO (`m.login.sso`), an + extra flow offered alongside password login. + + ⚠️ Not to be confused with tuwunel's `oidc_*` settings, which + point the other way: those make this homeserver an + *authorization server* for matrix clients. This option makes + it a *client* of an external identity provider. The two + families share the protocol's name and answer opposite + questions. This **adds** a way in. Password login keeps working: an identity provider that can take the homeserver offline when @@ -550,8 +595,80 @@ in "matrix.example.com" or "homeserver.internal". ''; } + { + # Fail at EVAL, not at boot. tuwunel reads its identity providers + # from the config file, so a half-configured one does not hide a + # login button — it can stop the homeserver from starting at all. + assertion = !cfg.sso.enable || cfg.sso.clientSecretFile != null; + message = '' + services.hyperhive.swarm.matrix.sso.enable requires + sso.clientSecretFile — the path (inside the matrix container) + holding the OIDC client secret's plaintext. + + On a hive that also runs the swarm's authelia this is wired up + for you. Set it explicitly when authelia lives on another + 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 = !cfg.sso.enable || autheliaUrl != null; + message = '' + services.hyperhive.swarm.matrix.sso.enable 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. + assertion = !cfg.sso.enable || cfg.gatewayHost != null; + message = '' + services.hyperhive.swarm.matrix.sso.enable requires + services.hyperhive.swarm.matrix.gatewayHost. + + tuwunel's SSO callback URL is format-locked to + `/_matrix/client/unstable/login/sso/callback/`, + and the identity provider redirects a browser to it — so it + has to be a name the browser can reach, which is exactly what + `gatewayHost` is. With it null the homeserver is direct on + httpPort and has no such name. + ''; + } ]; + # One declaration, two readers. The homeserver knows its own callback + # URL; making the operator restate it in authelia's client list would + # be a second source of truth for a string whose mismatch is a silent + # rejected login. + services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf ssoLocal [ + { + id = cfg.sso.clientId; + description = "HyperHive matrix"; + redirectUris = [ ssoCallbackUrl ]; + } + ]; + + # Same case, same reasoning: this host minted the secret, so it can say + # where the homeserver will find it. + services.hyperhive.swarm.matrix.sso.clientSecretFile = lib.mkIf ssoLocal ( + lib.mkDefault matrixSecretPath + ); + + # ⚠️ Deliberately NO `networking.hosts` entry for authelia's name, and + # the difference from hive-forge (which needs one) is worth stating: + # that container resolves through the host's resolvers, where the swarm + # domain has no records. This one resolves through the hive's dnsmasq + # at `bridgeIp` (see the static resolv.conf below), and every + # `gateway.localNames` entry — authelia's domain among them — is + # already mapped there. Adding a loopback override would only create a + # second answer that can disagree with the first. + # Activation-time token generation — without this the bind-mount # would hand tuwunel an empty file on first boot and break every # registration until restart. Idempotent; @@ -663,6 +780,34 @@ in # Tuwunel's default suffix is " 💕" — suppress it so agent # display names are clean (just the agent name, no emoji). new_user_displayname_suffix = ""; + } + # `optionalAttrs`, not a key set to `[]`: with SSO off the + # rendered settings must be *exactly* what they were before + # this option existed, and an empty list is still a key. + // lib.optionalAttrs cfg.sso.enable { + # tuwunel's OIDC server and this list are the two ends of one + # pipe: `oidc_native_auth` stays false (its default), which + # upstream defines as "the OIDC server runs only to broker + # for a configured identity_provider". So the client-facing + # half needs no configuration — only the upstream half does. + identity_provider = [ + { + # A free, case-insensitive string, not an enum: a + # recognised brand gets defaults and provider-specific + # workarounds, an unrecognised one simply gets neither. + # Which is why `issuer_url` below is not optional for us + # — the pre-supplied issuers cover public providers only. + brand = "authelia"; + client_id = cfg.sso.clientId; + client_secret_file = matrixSecretCredential; + issuer_url = toString autheliaUrl; + callback_url = ssoCallbackUrl; + # Explicit, though a lone provider is auto-defaulted: + # relying on that logs a warning every startup, and a + # recurring warning that is expected is one nobody reads. + default = true; + } + ]; }; }; # Keeps DynamicUser=true + PrivateUsers=true intact — no @@ -670,7 +815,11 @@ in # See `man systemd.exec` → LoadCredential. systemd.services.tuwunel.serviceConfig.LoadCredential = [ "registration_token:${toString cfg.registrationTokenFile}" - ]; + ] + # Same mechanism, second secret. tuwunel re-reads this file on + # every OAuth exchange, not just at startup, so it has to outlive + # the unit's start — a credentials path does. + ++ lib.optional cfg.sso.enable "oidc_client_secret:${toString cfg.sso.clientSecretFile}"; # Federation TLS against a peer whose cert chains to the swarm # root: tuwunel's outbound client is reqwest with the `rustls`