diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 2386d1d0..40015bad 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -21,6 +21,7 @@ ./hive-priv.nix ./hive-tls.nix ./otel.nix + ./swarm-authelia.nix ./swarm-ca.nix ./swarm-controller.nix ./swarm-snapshot-store.nix diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix new file mode 100644 index 00000000..f4dbcb9d --- /dev/null +++ b/nix/host-modules/swarm-authelia.nix @@ -0,0 +1,268 @@ +# 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"; + }; + }; + }; + }; + }; +}