# 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. # `deploy.allSwarmServices` asserts this module's `enable` # (see ./swarm-required-services.nix); a hive is a client by default. # # The file backend is the right shape, not a placeholder for LDAP: what # makes a directory necessary is the size of the subject set, and this # one is bounded by a swarm. Who writes that store, and why it is a # program rather than a config file, is in `docs/swarm/sso.md`. # # `docs/swarm/sso.md` owns the role model — session provider always, OIDC # provider derived from the client list — and the secrets map. # # 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; networkCfg = config.services.hyperhive.network; hyperhiveCfg = config.services.hyperhive; gatewayCfg = hyperhiveCfg.gateway; swarmDomain = hyperhiveCfg.swarm.domain; deployCfg = hyperhiveCfg.deploy; # Group an account must hold to reach operator-only surfaces. Named # here because this module writes the rule that enforces it and # `swarmctl user add --group ` is what grants it — the two must # agree, and one constant is how they stay agreeing. # # ⚠️ `admins` and not a new word, because `docs/getting-started/setup.md` and # `docs/swarm/sso.md` have been telling every operator to create # exactly that group since the bootstrap step existed. This is the # first rule that CONSUMES a group name; picking a different one would # have meant every account created by following the guide silently # failing the check it was supposed to pass. operatorGroup = "admins"; # Whether the swarm collector's client is actually registered here. # # Asked of the client list rather than re-derived from the conditions # that produce it (`otel.enable`, a non-empty audience set, authelia # being co-located). A second copy of that predicate is a second thing # to keep in step, and the two drifting is not a build failure: a rule # naming an unregistered client is refused by authelia's *startup* # validator, so the whole SSO service fails to restart. # # Reading the registration itself also makes the rule correct for a # registrar this module has never heard of — an operator registering # the collector by hand against a provider that is not co-located with # it gets the same rule, from the same expression. # ⚠️ `swarm.otel` named in full, not through a let binding: there are two # otel options one word apart, and only this one is the swarm's collector. collectorClientId = config.services.hyperhive.swarm.otel.clientId; collectorRegistered = lib.any (c: c.id == collectorClientId) cfg.oidc.clients; # The forge's metrics endpoint: deny until a collector exists to allow. # # `deny` is not a placeholder, it is the protection. The endpoint is # always served (a swarm-integrated forge always has metrics) and # `default_policy` is `one_factor`, which means *any* authenticated # subject — every operator today, every agent once they hold authelia # accounts. Nor does the audience stand between a browser session and # this data: `authn_strategies` on the authz endpoint also accepts # `CookieSession`, and a cookie carries no audience at all. metricsRule = { domain = hyperhiveCfg.swarm.forge.domain; resources = [ "^/metrics$" ]; } // ( if collectorRegistered then { policy = "one_factor"; subject = [ "oauth2:client:${collectorClientId}" ]; } else { policy = "deny"; } ); # 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. # ./swarm-authelia-service.nix binds the same two names for `unit`; # the two must agree. instance = "swarm"; unitName = "authelia-${instance}"; stateDir = "/var/lib/${unitName}"; caTrust = import ./lib/hive-ca-trust.nix { inherit lib gatewayCfg; tlsCfg = deployCfg.hive-controller.tls; }; # `swarm-authelia-bridge` verifies the gateway when it introspects by name. # Nothing in this container trusted the swarm CA, which is a runtime file no # build-time option can name — so an https call out of here could only ever # fail `UnknownIssuer`. Same defect the queue's responder hit. caBundleModule = caTrust.trustBundle { inherit pkgs; name = cfg.machine; consumers = [ "swarm-authelia-bridge" ]; }; # The SWARM's domain, because that is where the protected apps now live # (`forge.`, `chat.`, `auth.`). It moves in the # same commit as `domain` (./swarm-authelia-service.nix) and cannot lag # it: authelia validates `authelia_url ⊂ cookie domain` at STARTUP, so a # half-move does not misbehave at login — it refuses to boot. # # Total on a null swarm domain for the same reason the option defaults in # ./swarm-authelia-service.nix are: the required-domain assertion in # hive-network.nix should be what an operator sees, not a coercion error # from here. cookieDomain = if swarmDomain == null then "invalid" else swarmDomain; # One machine client per hive in the roster. The model — why identity is # per hive rather than per service, why `audience` is the client id, and # why these tokens are signed rather than opaque — is in # `docs/swarm/sso.md`, § Machine clients. # # Fed to the option as a DEFINITION in the config block below, rather # than appended to the declared list downstream. That is what puts it # through the submodule: one list, one type, every option's default # present. Appending a raw attrset instead left the list half-typed — # and a field later added to the submodule then existed on the # declared entries and not on these, which is an eval error reachable # only once hive identities are on. hiveClients = lib.mapAttrsToList (name: _: { id = "${cfg.hiveClientPrefix}${name}"; description = "HyperHive hive ${name}"; kind = "machine"; redirectUris = [ ]; audience = [ "${cfg.hiveClientPrefix}${name}" ]; accessTokenSignedResponseAlg = "RS256"; }) hyperhiveCfg.swarm.hives; # The one URL an agent's token has to be authorised at beyond the queue: # the log store's machine query route. Read off `swarm.victorialogs.domain` # — an unconditional option, declared whether or not this host runs the # store — rather than restated, because it is also the string `swarm-logs` # requests as its audience and the string `hive-c0re/environment.nix` hands # an agent as its endpoint. Three readers, one formula; two spellings # present as a valid token refused at the store. logsQueryUrl = "https://${hyperhiveCfg.swarm.victorialogs.domain}/select/logsql/query"; # The identity an agent container presents to the swarm's queue. One per # HIVE, not per agent, and that is the load-bearing choice rather than a # shortcut: agents are created at **runtime**, so anything minted per agent # would make creating one a config change plus an authelia reload. Keyed on # the hive, this list's length tracks the roster above it — deploy-time, like # every other entry here. # # ⚠️ The cost, stated rather than discovered later: every agent on a hive # presents the SAME client id, so the broker can tell *hives* apart and not # *agents*. Deliberate and deferred — closing it requires the client list to # stop being static, which is the same problem the users-database writer # already solves for identities. # # 🏷️ The id EXTENDS the hive's own (`hive--agent`) rather than taking a # prefix of its own (`agent-`), because that reads as *the agent called # ``* — which is the one thing this identity does not carry. The cost is # that the two ids are no longer distinguishable by prefix, so the responder's # agent rule must be tried BEFORE its hive rule; `policy.rs` says so at the # match site, and the assertion below covers the case that ordering cannot. # # Mirrors `hiveClients` field for field so the two stay comparable. The # signing algorithm is not load-bearing here — this client's consumers are # the queue's auth-callout responder, which *introspects* rather than # verifying offline, and authelia's own authz endpoint — but it matches its # sibling rather than inventing a second answer to a question nobody asked. agentClients = lib.mapAttrsToList (name: _: { id = "${cfg.hiveClientPrefix}${name}${cfg.agentClientSuffix}"; description = "HyperHive agents on hive ${name}"; kind = "machine"; redirectUris = [ ]; # ⚠️ **Two** audiences, where `hiveClients` has one, because an agent # presents this token at two different kinds of consumer. The queue # introspects it and reads the client id; the log store's gateway # location runs `auth_request` against authelia, which authorises a # bearer token **by the URL being requested** and refuses one whose # audience does not name it. Measured, not assumed: without the URL # registered here the token endpoint answers `invalid_target` for it, # and a token minted with no audience at all is answered by the gateway # with a bare 401 that names no cause. audience = [ "${cfg.hiveClientPrefix}${name}${cfg.agentClientSuffix}" logsQueryUrl ]; # Without it the authz endpoint refuses an otherwise valid token and # blames the token rather than the missing grant — the same note # `glue-swarm-otel-oidc-client.nix` carries over the collector's client. bearerAuthz = true; # Stated rather than left on authelia's default, for # `glue-swarm-otel-oidc-client.nix`'s reason: authelia permits only # basic / JWT methods for a confidential client holding this scope, and # enforces it in the startup validator. `swarm-queue-client` sends the # credential as basic auth, so this is also what the minter already does. tokenEndpointAuthMethod = "client_secret_basic"; accessTokenSignedResponseAlg = "RS256"; }) hyperhiveCfg.swarm.hives; # `swarm-authelia-bridge`'s own identity — distinct from # `swarm-controller`'s (`swarm-controller.nix`'s `queueClientId`). A # resource server introspecting a token proves its OWN identity to the # IdP (RFC 7662), separately from whichever principal's token it is # checking, so the bridge needs a client even though it never presents # a token itself. Contributed unconditionally below (not gated behind # `oidc.hiveIdentities`/an operator-declared `oidc.clients` entry): the # bridge is a core, always-present part of this module, not an opt-in # consumer — see `usersFile`'s doc comment. bridgeClientId = "swarm-authelia-bridge"; bridgeClient = { id = bridgeClientId; description = "HyperHive swarm-authelia-bridge (users-database writer)"; kind = "machine"; redirectUris = [ ]; }; # Derived from the client list rather than carrying its own `enable`; # `docs/swarm/sso.md` says why that is one fact instead of two. # # ⚠️ In practice this is now unconditionally `true` whenever the module # is enabled: `bridgeClient` above is an unconditional definition of # `oidc.clients` (see the `config` block), so the list is never empty. # Kept as a derived boolean rather than simplified to a literal `true` # so the OIDC-gated code below stays self-documenting about WHY it is # conditional, not just that it happens to always be on today. oidcEnabled = cfg.oidc.clients != [ ]; # Secrets that are 64 random bytes of hex and nothing more. The OIDC # hmac key joins them; the issuer key does not (see below — it is RSA). randomKeys = [ "jwt" "session" "storage-encryption" ] ++ lib.optional oidcEnabled "oidc-hmac"; # Per-client material lives beside the rest of authelia's state, one # file per half: the relying party needs the PLAINTEXT, authelia keeps # only a DIGEST. Splitting them is what lets the clients file below be # re-rendered on every boot from a secret that was minted once. clientsDir = "${stateDir}/oidc-clients"; clientsFile = "${stateDir}/oidc-clients.yml"; # Rendered at RUNTIME, not evaluated: the digest is read from disk by # the script, so nothing secret ever enters a nix expression (and # therefore the store). Everything else here is public metadata that # nix is the right place for. # A machine client is not an interactive one with the redirect list left # empty: authelia derives the permitted grant from what is declared, and an # omitted `grant_types` means authorization-code ONLY. Measured against # authelia 4.39.20 — a client rendered without it answers a # `client_credentials` request with # unauthorized_client: The OAuth 2.0 Client is not allowed to use # authorization grant 'client_credentials' # so the two shapes have to be told apart here rather than inferred from an # empty list. # # `openid` is deliberately absent from a machine client's scopes: authelia # REFUSES the combination outright ("the values 'openid' are not allowed" # with `client_credentials`), because a daemon receives an access token and # never an id-token. There is no user to identify. renderClient = c: '' printf -- ' - client_id: %s\n' ${lib.escapeShellArg c.id} printf -- ' client_name: %s\n' ${lib.escapeShellArg c.description} printf -- " client_secret: '%s'\n" "$(cat ${lib.escapeShellArg "${clientsDir}/${c.id}.digest"})" printf -- ' authorization_policy: one_factor\n' '' # Read plainly, and that is a property of the list rather than of # this line: every entry reaching here is a definition of # `oidc.clients`, so the module system has applied the submodule and # each option's default is present. A derived entry that names only # the fields it cares about still arrives with the rest filled in. + lib.optionalString (c.tokenEndpointAuthMethod != null) '' printf -- ' token_endpoint_auth_method: %s\n' ${lib.escapeShellArg c.tokenEndpointAuthMethod} '' # Flow-style YAML, matching `scopes` below. The values are client ids # and hive names, which `Ident` already constrains to `[a-z0-9-]` — no # character in that set needs quoting in a YAML flow sequence. + lib.optionalString (c.audience != [ ]) '' printf -- ' audience: [%s]\n' ${lib.escapeShellArg (lib.concatStringsSep ", " c.audience)} '' + lib.optionalString (c.accessTokenSignedResponseAlg != null) '' printf -- ' access_token_signed_response_alg: %s\n' ${lib.escapeShellArg c.accessTokenSignedResponseAlg} '' + ( if c.kind == "machine" then '' printf -- ' grant_types: ["client_credentials"]\n' printf -- ' scopes: [${lib.optionalString c.bearerAuthz "authelia.bearer.authz"}]\n' '' else '' printf -- ' scopes: [openid, profile, email, groups]\n' printf -- ' redirect_uris:\n' ${lib.concatMapStrings (u: '' printf -- ' - %s\n' ${lib.escapeShellArg u} '') c.redirectUris} '' ); # The OIDC half of the first-boot generator, kept out of the script # body so neither is read through the other's indentation. oidcGenScript = lib.optionalString oidcEnabled '' # The issuer key is the one secret here that is NOT interchangeable # with a random blob: it *signs* id tokens, and every relying party # verifies them against the public half served at `/jwks.json`. A # symmetric secret cannot do that, so this one is an RSA pair. # # Rotating it invalidates every token already issued, which is why # it is generated once and left alone — same reason as the session # and storage keys above. issuer=${lib.escapeShellArg stateDir}/oidc-issuer.key if [ ! -s "$issuer" ]; then openssl genrsa -out "$issuer" 4096 echo "generated $issuer" fi chmod 0600 "$issuer" # Each client's secret, minted once and kept as two files: the # plaintext its relying party authenticates with, and the digest # authelia compares against. The two live in different containers, # so neither side can generate it alone — this is the only place # that sees both. # # `--random` is why no plaintext ever reaches an argv: authelia # generates the password itself and prints it beside its digest, # so nothing has to be handed to a second process on a command # line. clients=${lib.escapeShellArg clientsDir} mkdir -p "$clients" chmod 0700 "$clients" mint() { sec="$clients/$1.secret" dig="$clients/$1.digest" if [ -s "$sec" ] && [ -s "$dig" ]; then chmod 0600 "$sec" "$dig" return fi out=$(authelia crypto hash generate pbkdf2 --variant sha512 --random) printf '%s' "$out" | sed -n 's/^Random Password: *//p' > "$sec" printf '%s' "$out" | sed -n 's/^Digest: *//p' > "$dig" chmod 0600 "$sec" "$dig" # Fail closed. An empty secret is a client that can never # authenticate, and it surfaces three layers away as an opaque # 401 from the token endpoint — refusing to start is by far the # cheaper failure to diagnose. if [ ! -s "$sec" ] || [ ! -s "$dig" ]; then echo "authelia crypto hash generate produced no secret/digest for $1" >&2 exit 1 fi echo "minted client secret for $1" } ${lib.concatMapStrings (c: '' mint ${lib.escapeShellArg c.id} '') cfg.oidc.clients} # Re-rendered every boot, deliberately: the secret is minted once, # but the metadata around it (a new redirect URI, a renamed client) # comes from nix and has to be able to change without disturbing # the secret. Written through a temp file so a crash mid-write # cannot leave authelia half a file to parse. { printf -- 'identity_providers:\n' printf -- ' oidc:\n' printf -- ' clients:\n' ${lib.concatMapStrings renderClient cfg.oidc.clients} } > ${lib.escapeShellArg "${clientsFile}.tmp"} chmod 0600 ${lib.escapeShellArg "${clientsFile}.tmp"} mv ${lib.escapeShellArg "${clientsFile}.tmp"} ${lib.escapeShellArg clientsFile} ''; # Shared host netns, like the forge and matrix containers: the # gateway reaches authelia at 127.0.0.1:. privateNetwork = false; in { # What authelia IS to every hive, where it answers (`url`), the OIDC # register every service checks itself against and its port, is # `swarm.authelia` in ./swarm-authelia-service.nix. What the host running # the container decides is here — which two builds it # runs, and three filesystem paths that only exist on the machine running # `swarm-authelia`. A hive that does not run it has nothing at any of those # paths. `enable` already lives in ./deploy.nix, which also carries the # renames. # # ⚠️ `usersFile` is a path *inside* the container and still belongs here: # a path's scope is the scope of the filesystem it names, and that # filesystem is this host's container root. options.services.hyperhive.deploy.authelia = { 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. ''; }; bridgePackage = lib.mkOption { type = lib.types.package; defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-authelia-bridge"; description = '' `swarm-authelia-bridge` package — the only process allowed to write `usersFile`. Wired by default from this flake's own package set (see `flake.nix`); override to run a different build. ''; }; 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-authelia-bridge`, not by hand: agents come and go continuously, so the subject set is dynamic and belongs to a program. `swarm-controller` cannot write this file itself — a different uid owns it — so the bridge is the only writer, running inside this same container as this file's actual owner. 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. ''; }; hostClientSecretDir = lib.mkOption { type = lib.types.str; readOnly = true; default = "/var/lib/nixos-containers/${cfg.machine}${clientsDir}"; description = '' Where the minted client secrets sit **as seen from the host** — `.secret` holds a plaintext, `.digest` the hash authelia itself reads. Published for the same reason as `hostUsersFile`: the plaintext's other reader lives in a **different container**, and containers that share this host's network namespace still have separate filesystem roots. The host is the only place both trees are addressable, so the host is where a delivery step has to run. ⚠️ Nothing here exists until authelia's **first boot** has run. A consumer must wait for it — it cannot be a `bindMounts` source, because nixos-container refuses to start when a bind source is missing, and that turns a fresh hive into a boot-order deadlock. ''; }; hostUsersFile = lib.mkOption { type = lib.types.str; readOnly = true; default = "/var/lib/nixos-containers/${cfg.machine}${deployCfg.authelia.usersFile}"; description = '' `usersFile` as seen from the **host** — the container's root prefixed onto the path authelia sees. Published for callers that only ever need to *read* the file (e.g. an operator diagnosing a bad entry). `swarm-authelia-bridge` itself never uses this path — it runs inside the container, as the file's own owner, and writes the in-container path directly. ''; }; }; config = lib.mkIf deployCfg.authelia.enable { # 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 — # including the assertions below, which is why a hive named `x` # colliding with a declared `hive-x` is caught rather than rendered # twice. `bridgeClient` is unconditional (plain list concatenation, # not `mkIf`-gated like `hiveClients`): the bridge is always present # wherever this module is, so `oidc.clients` is never actually empty # — see `oidcEnabled`'s comment above. services.hyperhive.swarm.authelia.oidc.clients = lib.optionals cfg.oidc.hiveIdentities (hiveClients ++ agentClients) ++ [ bridgeClient ]; # A redirect URI on a machine client is not harmless-but-unused: it # means whoever wrote it believes a browser is involved. Failing here # is how that belief gets corrected at the point it was expressed, # rather than at a token endpoint months later. assertions = map (c: { assertion = c.kind != "machine" || c.redirectUris == [ ]; message = "services.hyperhive.swarm.authelia.oidc.clients: client '${c.id}' is " + "kind = \"machine\" but declares redirectUris. A client_credentials " + "client has nobody to redirect; drop the URIs or make it " + "kind = \"interactive\"."; }) cfg.oidc.clients # Two clients sharing an id renders two YAML entries under one name. # Newly reachable now that part of the list is DERIVED: a hive called # `x` and a service client called `hive-x` never met before. Authelia # would reject it, but three layers away and at boot — naming both # sources here is the cheaper failure. ++ [ { assertion = lib.length (lib.unique (map (c: c.id) cfg.oidc.clients)) == lib.length cfg.oidc.clients; message = "services.hyperhive.swarm.authelia: duplicate OIDC client id(s): " + lib.concatStringsSep ", " ( lib.unique ( lib.filter (id: lib.count (x: x == id) (map (c: c.id) cfg.oidc.clients) > 1) ( map (c: c.id) cfg.oidc.clients ) ) ) + ". Hive identities are named `hive-` from " + "services.hyperhive.swarm.hives; rename the hive or the " + "colliding client."; } { # A hive whose name ENDS with the agent suffix mints an id the # queue's responder reads as somebody else's agents: # `hive-foo-agent` parses as *the agents of hive foo* before it # parses as *the hive foo-agent*, because the agent rule is the # more specific one and therefore runs first. # # ⚠️ NOT covered by the duplicate-id assertion above, and the gap is # the interesting half. That one fires only when BOTH `foo` and # `foo-agent` are on the roster, because only then are two clients # actually named the same string. With `foo-agent` alone there is no # duplicate and nothing to see — the hive simply receives its # agents' grant instead of its own, and a NATS denial arrives as a # timeout, so the symptom names nothing. # # Checkable here and nowhere downstream: the responder runs in a # container with no view of the roster (`policy.rs`'s `hive_name` # says why that is deliberate), so this is the last place that knows # both the naming scheme and the set of names. assertion = !cfg.oidc.hiveIdentities || !lib.any (h: lib.hasSuffix cfg.agentClientSuffix h) (lib.attrNames hyperhiveCfg.swarm.hives); message = "services.hyperhive.swarm.hives contains " + lib.concatMapStringsSep ", " (h: "'${h}'") ( lib.filter (h: lib.hasSuffix cfg.agentClientSuffix h) (lib.attrNames hyperhiveCfg.swarm.hives) ) + " — names ending with '${cfg.agentClientSuffix}', the suffix that " + "marks a hive's agent containers. Such a hive's own client id is " + "indistinguishable from another hive's agent client, and the " + "queue resolves it as the agents. Rename the hive."; } # The two obligations `authelia.bearer.authz` carries. Authelia # enforces both itself — but in its `preStart` validator, so a # violation produces a green `nixos-rebuild switch` and an authelia # that then refuses to come back up, taking swarm SSO with it. # Asserting here moves the same failure to evaluation, where a # wrong value costs a build and nothing else. { assertion = lib.all (c: !c.bearerAuthz || c.audience != [ ]) cfg.oidc.clients; message = "services.hyperhive.swarm.authelia.oidc.clients: " + lib.concatStringsSep ", " ( map (c: "client '${c.id}'") (lib.filter (c: c.bearerAuthz && c.audience == [ ]) cfg.oidc.clients) ) + " sets bearerAuthz but declares no audience. The audience is " + "what authorises a bearer token at a given URL, so without " + "one the scope grants access to nothing and authelia refuses " + "the configuration outright."; } { assertion = lib.all ( c: !c.bearerAuthz || lib.elem c.tokenEndpointAuthMethod [ "client_secret_basic" "client_secret_jwt" "private_key_jwt" ] ) cfg.oidc.clients; message = "services.hyperhive.swarm.authelia.oidc.clients: " + lib.concatStringsSep ", " ( map (c: "client '${c.id}' (tokenEndpointAuthMethod = ${toString c.tokenEndpointAuthMethod})") ( lib.filter ( c: c.bearerAuthz && !lib.elem c.tokenEndpointAuthMethod [ "client_secret_basic" "client_secret_jwt" "private_key_jwt" ] ) cfg.oidc.clients ) ) + ". A confidential client carrying authelia.bearer.authz must " + "authenticate with client_secret_basic, client_secret_jwt or " + "private_key_jwt. Notably client_secret_post is refused, and " + "it is what an OAuth2 client library may reach for first. " + "Leaving it null is refused too: authelia requires the method " + "to be STATED under this scope rather than defaulted."; } # Nothing else stops `bearerAuthz` on an interactive client, and it # would silently do nothing: `renderClient` reads the flag only in # the `machine` branch, so the scope is simply never emitted and the # client authenticates fine while being authorised for nothing. # # That is this module's own failure mode one level up — a green # build and a grant that does not exist — and `kind` defaults to # `interactive`, so it is reached by FORGETTING a field rather than # by writing a wrong one. { assertion = lib.all (c: !c.bearerAuthz || c.kind == "machine") cfg.oidc.clients; message = "services.hyperhive.swarm.authelia.oidc.clients: " + lib.concatStringsSep ", " ( map (c: "client '${c.id}'") (lib.filter (c: c.bearerAuthz && c.kind != "machine") cfg.oidc.clients) ) + " sets bearerAuthz with kind != \"machine\". The scope is only " + "emitted for machine clients, so this grants nothing while " + "evaluating and deploying cleanly. A browser client has a user " + "to authorise and does not need it."; } ]; # 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 `deployCfg.authelia.enable` — that guard is the # load-bearing part. # # ⚠️ Every hive in a swarm knows `authelia.url`, but only the host # that RUNS the container may claim the name. A client hive # declaring this vhost would answer for a service it does not run, # and publishing the DNS record would point every agent on its # bridge at that wrong answer. services.hyperhive.gateway.localNames = [ cfg.domain ]; # This host serves the vhost, and the container behind it resolves # through the hive's dnsmasq. services.hyperhive.gateway.enable = lib.mkDefault true; services.hyperhive.gateway.dns.enable = lib.mkDefault true; # Declared here rather than in the collector's module, per the option's # own rule: an entry exists only where the service that named it runs. # # Gated on the collector's `enable` as well, and that second condition is # what makes the loopback address honest. Both services default from # `deploy.allSwarmServices` — but `mkDefault` is an invitation to # override, not a guarantee, so "they are on the same host" is a property # of the auto-deployed topology rather than of the module. Without this # gate, a host running authelia and no collector would declare a target # nothing can read, and the absence would be silent: no error, no metrics, # nothing to notice. # # It does not make authelia scrapeable from ANOTHER host — that needs the # endpoint published under a name with a cert and an audience, which is a # different piece of work. This only stops the config asserting a # collection that is not happening. # ⚠️ `swarm.otel` and not `hyperhive.otel` — two different collectors one # word apart. This one is the swarm's; `hyperhive.otel` is the per-hive # tier that ships telemetry upstream and never reads `scrapeTargets`. # Written out in full rather than through a `let` binding so the gate and # the option it gates are visibly the same path: gating the wrong one is # not a build error, it is a target that is always declared or never is. services.hyperhive.swarm.otel.scrapeTargets = lib.mkIf config.services.hyperhive.deploy.swarm-otel.enable { authelia = "127.0.0.1:${toString cfg.metricsPort}"; }; # This swarm-ui quick-links entry, same guard as the vhost/DNS name # above (only the host actually running the container claims it — # see `services.hyperhive.swarm.controller.links`'s description for # the contribute-your-own-entry idiom). services.hyperhive.swarm.controller.links = [ { label = "Authelia"; icon = "🔑"; url = "https://${cfg.domain}/"; } ]; # `server_name = authelia.domain`, all of `/` → authelia. # # ⚠️ The server name must be exactly `cfg.domain`, not a near-miss: # authelia validates `authelia_url ⊂ session cookie domain` at # STARTUP, so a mismatch is a container that refuses to boot rather # than a login that misbehaves. # # ⚠️ And deliberately NO `dashboardAuth` here. That block is the # gateway's `auth_basic`; applying it to the SSO provider would put # the login page behind the login mechanism it exists to replace. services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { listen = gatewayCfg.lib.listen; extraConfig = gatewayCfg.lib.securityHeaders; locations."/" = { proxyPass = "http://127.0.0.1:${toString cfg.port}/"; proxyWebsockets = true; extraConfig = '' proxy_buffering off; # authelia decides by the ORIGINAL request, not by the hop it # sees — the login redirect and the session cookie's domain # both derive from these. Without them every request looks # like it arrived at 127.0.0.1 over plain http. proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Uri $request_uri; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # A dead upstream here means "not bootstrapped" far more often # than "misconfigured proxy", and a bare 502 says the opposite. proxy_intercept_errors on; error_page 502 503 504 = /__hive_sso_unavailable; ''; }; locations."= /__hive_sso_unavailable" = { extraConfig = '' internal; alias ${gatewayCfg.lib.errorPages.ssoUnavailable}; default_type text/html; ''; }; # Authelia's MACHINE surface, routed without the error interception # above. A longer prefix wins over `/` in nginx, and # `proxy_intercept_errors` / `error_page` are set inside that # location rather than at server level, so they do not reach here. # # ⚠️ THIS IS A SECURITY BOUNDARY, not a tidy-up. Do not fold it back # into `/`: the friendly error page answers **200**, and # `auth_request` reads any 2xx as ACCESS GRANTED. Full consequence # list in `docs/swarm/sso.md`, § Machine callers must fail closed. # # The split is by AUDIENCE, not by path list — including the login # page's own XHR. Enumerating individual endpoints would leave the # next one added silently intercepted. locations."/api/" = { proxyPass = "http://127.0.0.1:${toString cfg.port}"; }; locations."/.well-known/" = { proxyPass = "http://127.0.0.1:${toString cfg.port}"; }; }; # Order the container after the host CA generator, so the bind source # exists before nspawn sets the mount up. systemd.services."container@${cfg.machine}" = caTrust.containerOrdering; containers.${cfg.machine} = { autoStart = true; ephemeral = false; # Journal files on the host, not inside the container: nixpkgs hardcodes # --link-journal=try-guest, and EXTRA_NSPAWN_FLAGS expands after it. extraFlags = [ "--link-journal=host" ]; inherit privateNetwork; # Public trust bundle only, read-only. Empty when the gateway is not # self-signed, so the whole trust path drops out cleanly. bindMounts = caTrust.bindMount; config = { ... }: { imports = [ ../container-modules/swarm-container.nix (import ../container-modules/swarm-container-resolver.nix { inherit (networkCfg) bridgeIp; dnsConsumers = [ "authelia-${instance}.service" ]; }) caBundleModule ]; services.hyperhive.swarmContainer = { inherit privateNetwork; }; # The authelia binary itself, so an operator who gets a shell # in here can run `authelia crypto hash generate` to make a # password for the users file. Without it the container runs # authelia and cannot invoke it: the unit's ExecStart resolves # through the store path, and nothing puts the CLI on PATH. environment.systemPackages = [ deployCfg.authelia.package ]; # 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" ]; # `deployCfg.authelia.package` is here for its CLI, not its # daemon: the client secrets are minted with `authelia crypto # hash generate`, which is the only way to produce a digest in # the exact form authelia will later verify. path = [ pkgs.coreutils ] ++ lib.optionals oidcEnabled [ pkgs.openssl deployCfg.authelia.package ]; 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 ${lib.concatStringsSep " " randomKeys}; 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 ${oidcGenScript} # 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 deployCfg.authelia.usersFile} if [ ! -s "$users" ]; then echo "users: {}" > "$users" echo "seeded empty users database at $users" fi chmod 0600 "$users" ''; }; # The only process allowed to write `deployCfg.authelia.usersFile` — see that # option's doc comment, and the crate's own README for the full # "why does an unprivileged swarm-controller need a bridge at # all" reasoning. Runs as `unitName` (`authelia-swarm`) — THE # point of this whole unit: it is that same account, so it # owns the file it writes and needs no elevated privilege. # Ordered after authelia's own secrets generator (needs its # own client secret, minted by that unit's `mint` loop) and # after authelia itself (introspects against its local # `/api/oidc/introspection`, so needs it answering — not # load-bearing at start, since nothing calls the bridge yet at # boot, but a clean dependency order beats a would-be-transient # failure on the first real request). systemd.services.swarm-authelia-bridge = { description = "swarm-authelia-bridge: the only writer of authelia's users database"; wantedBy = [ "multi-user.target" ]; after = [ "${unitName}-secrets.service" "${unitName}.service" ]; wants = [ "${unitName}-secrets.service" "${unitName}.service" ]; serviceConfig = { ExecStart = "${deployCfg.authelia.bridgePackage}/bin/swarm-authelia-bridge"; User = unitName; Group = unitName; Restart = "on-failure"; RestartSec = "5s"; }; environment = { SWARM_AUTHELIA_BRIDGE_BIND = "127.0.0.1:${toString cfg.bridgePort}"; # ONE file, and this is it. The bridge used to carry a # second, private `…-users.json` it treated as canonical # while writing `users.yml` as a rendering of it — two # canonical stores for one physical file, which is what # made `swarm agent create` refuse to start on a hive whose # `users.yml` already held users. SWARM_AUTHELIA_BRIDGE_USERS_FILE = deployCfg.authelia.usersFile; # The CONFIGURED authelia, not whatever is on `PATH`: the # argon2 parameters baked into a hash have to match the # verifier's — same reasoning as `swarmctl`'s own # `SWARMCTL_AUTHELIA_BIN`. SWARM_AUTHELIA_BRIDGE_AUTHELIA_BIN = "${deployCfg.authelia.package}/bin/authelia"; # By name through the gateway, not loopback. A loopback # literal encodes "authelia is in my netns" at the call site, # and authelia's OIDC endpoints are https-only in effect — # reached directly they answer 400, because the forwarded # headers nginx injects for every other consumer are what let # it determine its own issuer. Same URL `swarm-nats-auth` # uses, so there is one idiom rather than two. SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL = "${cfg.url}/api/oidc/introspection"; SWARM_AUTHELIA_BRIDGE_CLIENT_ID = bridgeClientId; # Minted by `${unitName}-secrets`'s `mint` loop (it iterates # every entry in `cfg.oidc.clients`, which now always # includes `bridgeClient`) — same file this container's own # `renderClient` reads the digest half of. SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE = "${clientsDir}/${bridgeClientId}.secret"; }; }; services.authelia.instances.${instance} = { enable = true; package = deployCfg.authelia.package; # Merged at RUNTIME alongside the nix-generated config, which # is the whole reason the client digests can exist at all: # `settings` below is rendered into the world-readable nix # store, and a client secret's digest may not go there. # Empty (and inert) on a hive with no clients declared. settingsFiles = lib.optional oidcEnabled clientsFile; secrets = { jwtSecretFile = "${stateDir}/jwt.key"; sessionSecretFile = "${stateDir}/session.key"; storageEncryptionKeyFile = "${stateDir}/storage-encryption.key"; } // lib.optionalAttrs oidcEnabled { # Both are `LoadCredential`-delivered by upstream's module, # so they reach authelia as `AUTHELIA_*_FILE` env and never # as values. Same by-path discipline as the three above — # which is what lets the provider's own secrets stay # in-container: nothing outside this container reads them. # # ⚠️ The issuer key is NOT passed as the deprecated # `issuer_private_key`: on 4.38+ the config key is the # `jwks` *list*, and a list element cannot take the # `_FILE` env treatment. Upstream's module bridges that # by generating a small settings file that templates the # PEM in (`{{ secret "" }}`) and prepending it to # `settingsFiles`. So handing it a path here is the # modern shape, not the legacy one — checked against the # pin (nixpkgs `services/security/authelia.nix`), because # the option name alone reads like the old key. oidcHmacSecretFile = "${stateDir}/oidc-hmac.key"; oidcIssuerPrivateKeyFile = "${stateDir}/oidc-issuer.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}"; # Let a machine present an OAuth2 access token to the same # `auth_request` endpoint browsers use, so a scraper can be # authenticated by the gateway instead of every service # growing its own static bearer. # # ⚠️ `authn_strategies` REPLACES the defaults rather than # adding to them, so `CookieSession` is listed explicitly. # Dropping it does not fail to evaluate and does not fail to # start — it silently ends every operator session on the # swarm UI, which rides this same endpoint. # # Unconditional, and not keyed to whichever service is # currently scraped: this only makes a *scheme* available. # Authorisation is the audience — authelia refuses a token # that carries no audience for the requested URL, and a # client may only be issued audiences it is registered for. # So enabling the scheme grants nobody anything until a # client is registered for a specific URL. server.endpoints.authz.auth-request = { implementation = "AuthRequest"; authn_strategies = [ { name = "HeaderAuthorization"; schemes = [ "Bearer" ]; } { name = "CookieSession"; } ]; }; log.level = "info"; # Prometheus exposition for the swarm collector to scrape. # Loopback only, like the main listener above and for a # stronger reason: this endpoint has no authentication of its # own, and it reports request volumes and outcomes for every # SSO login on the swarm. telemetry.metrics = { enabled = true; address = "tcp://127.0.0.1:${toString cfg.metricsPort}"; }; # `watch` is load-bearing, not a convenience: authelia reads # this file once at startup, and `swarm-authelia-bridge` writes # it to create agent identities while being unable to restart # authelia — running unprivileged is the whole reason it may # write the file at all. Without this, an identity it creates is # real on disk and invisible until something unrelated restarts. authentication_backend.file = { path = deployCfg.authelia.usersFile; watch = true; }; # ⚠️ `one_factor` as the DEFAULT means "any authenticated # user", which is authentication, not authorisation. The # swarm UI is operator-only, and agents are getting # authelia accounts of their own — so the day that lands, # a session alone would be enough to open it. The rule # below is what makes the distinction real; without it # the vhost's `auth_request` is a check nobody fails. # # The group is a constant rather than an option: it is the # value an operator types into `swarmctl user add --group`, # and a configurable name is one more way for the rule and # the account to disagree silently. access_control = { default_policy = "one_factor"; # ⚠️ ORDER MATTERS — authelia takes the FIRST matching rule. # The metrics rule is listed first so it cannot be shadowed # by a broader domain rule added later. rules = # Denied or client-scoped depending on whether a # collector is registered — see `metricsRule` above, # which is where the reasoning for both halves lives. lib.optional deployCfg.forgejo.behindGateway metricsRule # Also guarded on the domain being set: without it a null # apex would render a rule matching the string "null". ++ lib.optional (deployCfg.swarm-ui.enable && swarmDomain != null) { domain = swarmDomain; subject = [ "group:${operatorGroup}" ]; policy = "one_factor"; } # The store's browser UI. Not keyed to a deploy flag: its # vhost is on the store's host, which need not be this # one, and without this rule any session passes it. ++ lib.optional (swarmDomain != null) { domain = hyperhiveCfg.swarm.bao.ui.domain; subject = [ "group:${operatorGroup}" ]; policy = "one_factor"; }; }; # The cookie domain is the SWARM's domain, NOT authelia's # own host: the session cookie has to be sent to the apps # being protected (`forge.`, `chat.`), 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. # # ⚠️ Known and accepted consequence while a hive keeps a # domain outside the swarm's tree: this cookie is NOT sent # to that hive's own surfaces (its dashboard), so SSO # covers the swarm's services and not the hive's. It # resolves when the hive domain moves under the swarm # domain; until then it is a scope limit, not a bug to # chase. session.cookies = [ { domain = cookieDomain; authelia_url = "https://${cfg.domain}"; } ]; storage.local.path = "${stateDir}/db.sqlite3"; notifier.filesystem.filename = "${stateDir}/notification.txt"; }; }; }; }; }; }