# The swarm's SSO provider: one authelia for the whole swarm, in a # `swarm-authelia` nixos-container. # # Two halves, and only one of them is conditional: # # - the CLIENT pointer (`url`) exists on every hive, because a hive # that doesn't run authelia still has to know where to send people. # - the CONTAINER only exists where the swarm's shared services live # (`swarm.enableRequiredServices`, itself defaulting from # `enableAllLocalDefaults`). A hive is a client by default. # # Operator and agents are both subjects of the same provider, # differentiated by roles/claims rather than by mechanism — there is one # IdP and one auth path. The users store is therefore written by a # program (swarm-controller), not maintained by hand: agents are created # and destroyed continuously, so the subject set is *dynamic*. That is # also why the file backend is the right one here and not a placeholder # for LDAP: what makes a directory necessary is the size of the subject # set, and this deployment's is bounded by one swarm. # # Per-service integration — putting authelia's `auth_request` in front # of the gateway's existing `auth_basic` locations — is deliberately NOT # here. Standing an SSO provider up is reversible; cutting every # operator-facing vhost over to it is not. { pkgs, lib, config, ... }: let cfg = config.services.hyperhive.swarm.authelia; hyperhiveCfg = config.services.hyperhive; swarmCfg = config.services.hyperhive.swarm; hyperhiveDomain = hyperhiveCfg.domain; # Upstream's `services.authelia.instances.` derives the unit, # user, group and StateDirectory from the instance name # (`authelia` + `-`). Naming them here rather than repeating the # literal keeps the generator unit below and the module in step. instance = "swarm"; unitName = "authelia-${instance}"; stateDir = "/var/lib/${unitName}"; # Total on a null hive domain for the same reason the option defaults # below are: the required-domain assertion in hive-network.nix should # be what an operator sees, not a coercion error from here. cookieDomain = if hyperhiveDomain == null then "invalid" else hyperhiveDomain; in { options.services.hyperhive.swarm.authelia = { enable = lib.mkOption { type = lib.types.bool; default = swarmCfg.enableRequiredServices; defaultText = lib.literalExpression "services.hyperhive.swarm.enableRequiredServices"; example = true; description = '' Run the swarm's authelia in a `swarm-authelia` container on this host. Defaults from `services.hyperhive.swarm.enableRequiredServices` — a swarm has one SSO provider, and this 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; defaultText = lib.literalExpression "pkgs.authelia"; description = '' authelia package to run in the container. Defaults to nixpkgs's; override to pin a specific upstream. ''; }; port = lib.mkOption { type = lib.types.port; default = 9091; description = '' TCP port authelia listens on. 9091 is upstream's default and sits outside hyperhive's claimed ranges (dashboard 7000, forge 3000, matrix 8008, every agent in 8100..8999 via FNV-1a hash). ''; }; domain = lib.mkOption { type = lib.types.str; # Total on a null hive domain so the required-domain assertion in # hive-network.nix is the thing that fires; see the comment there. default = if hyperhiveDomain == null then "auth.invalid" else "auth.${hyperhiveDomain}"; defaultText = lib.literalExpression ''"auth.''${services.hyperhive.domain}"''; example = "login.example.com"; description = '' Public hostname for the SSO provider — the sub-domain shape the forge and matrix already use. Doubles as the cookie domain's host, so it must be the name browsers actually visit. ''; }; url = lib.mkOption { type = lib.types.nullOr lib.types.str; default = if cfg.enable then "https://${cfg.domain}" else null; defaultText = lib.literalExpression ''if enable then "https://''${domain}" else null''; 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. ''; }; usersFile = lib.mkOption { type = lib.types.str; default = "${stateDir}/users.yml"; defaultText = lib.literalExpression ''"/var/lib/authelia-swarm/users.yml"''; description = '' Path (inside the container) of authelia's file users database. Written by swarm-controller, not by hand: agents come and go continuously, so the subject set is dynamic and belongs to a program. This module only guarantees the file *exists* and is valid YAML at first boot, so authelia starts with no subjects rather than failing to start — a provider with nobody in it yet is the correct state before anything has provisioned users. ''; }; }; config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) { containers.swarm-authelia = { autoStart = true; ephemeral = false; # Shared host netns, like the forge and matrix containers: the # gateway reaches authelia at 127.0.0.1:. privateNetwork = false; config = { ... }: { system.stateVersion = "26.05"; # This container shares the host netns, so its own # firewall.service would rewrite the HOST ruleset at every # boot. The host firewall owns all filtering. networking.firewall.enable = false; # Keep the host-copied /etc/resolv.conf intact — resolvconf's # host-tracking would regenerate it to an empty file, since # the host's copy doesn't cross the boundary after start. networking.resolvconf.enable = lib.mkForce false; # authelia's own secrets, generated in-container on first # boot. They are jwt/session/storage keys — nothing outside # this container ever reads them, which is what makes # in-container generation right rather than merely easier. # (hive-matrix generates its token host-side only because # hive-c0re has to read that one.) # # Same `User`/`Group`/`StateDirectory` as the authelia unit, # so systemd creates the directory owned by the account that # has to read the files and this unit can write nowhere else. # No chown, no mode juggling: authelia opens these paths # itself, as its own user, under `PrivateUsers=true`. systemd.services."${unitName}-secrets" = { description = "Generate authelia's secrets on first boot"; wantedBy = [ "multi-user.target" ]; before = [ "${unitName}.service" ]; requiredBy = [ "${unitName}.service" ]; path = [ pkgs.coreutils ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; User = unitName; Group = unitName; StateDirectory = unitName; StateDirectoryMode = "0700"; UMask = "0077"; SyslogIdentifier = "${unitName}-secrets"; }; script = '' set -euo pipefail # Each is generated once and never rotated here: the # session and storage keys are load-bearing for data # already written (sessions, the encrypted store), so # replacing one is an operator action, not a boot action. for f in jwt session storage-encryption; do p=${lib.escapeShellArg stateDir}/"$f".key if [ ! -s "$p" ]; then head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$p" echo "generated $p" fi chmod 0600 "$p" done # A users database that exists and parses, with nobody in # it. authelia refuses to start without one, and the # alternative to an empty file is a placeholder account — # which is a credential nobody meant to create. users=${lib.escapeShellArg cfg.usersFile} if [ ! -s "$users" ]; then echo "users: {}" > "$users" echo "seeded empty users database at $users" fi chmod 0600 "$users" ''; }; services.authelia.instances.${instance} = { enable = true; package = cfg.package; secrets = { jwtSecretFile = "${stateDir}/jwt.key"; sessionSecretFile = "${stateDir}/session.key"; storageEncryptionKeyFile = "${stateDir}/storage-encryption.key"; }; # Small-deployment defaults, and the scope is the # justification: one swarm, one authelia, no replicas. # - file users backend, written by swarm-controller # - local sqlite storage: redis buys shared session state # across replicas, and there is one instance # - filesystem notifier: SMTP is for mailing humans, and # provisioning is programmatic; a file is honest about # where those messages go instead of implying a mail path settings = { theme = "dark"; server.address = "tcp://127.0.0.1:${toString cfg.port}"; log.level = "info"; authentication_backend.file.path = cfg.usersFile; access_control.default_policy = "one_factor"; # The cookie domain is the hive's domain, NOT authelia's # own host: the session cookie has to be sent to the apps # being protected (``, `forge.`, # `matrix.`), and a cookie scoped to `auth.` # reaches none of them. authelia enforces the relationship # from the other side too — `authelia_url` must be a # sub-domain of `domain`, so setting both to the same host # fails validation at startup rather than at first login. session.cookies = [ { domain = cookieDomain; authelia_url = "https://${cfg.domain}"; } ]; storage.local.path = "${stateDir}/db.sqlite3"; notifier.filesystem.filename = "${stateDir}/notification.txt"; }; }; }; }; }; }