# The swarm's SSO provider: one authelia for the whole swarm, in a # `swarm-authelia` nixos-container on the host with `deploy.authelia.enable` # (`deploy.allSwarmServices` defaults it on, see # ./swarm-required-services.nix). What every hive sees of it, `url` and the # OIDC client register included, is in ./swarm-authelia-service.nix. # # 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 — the `auth_request` in front of a vhost — # lives in the module declaring that vhost. What lives here is the # `access_control` rule each of them relies on. { 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"; # `target`, for `operatorGroup` and nobody else. The `deny` is what makes # the group rule exclusive, for the reason `collectorOnly` gives: a subject # outside the group skips the first rule and would otherwise land on # `default_policy`. operatorsOnly = target: [ ( target // { policy = "one_factor"; subject = [ "group:${operatorGroup}" ]; } ) (target // { policy = "deny"; }) ]; # 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; # `target`, for the collector's client and nobody else. # # The `deny` is the protection, and it has to be a rule of its own. A rule # with a `subject` does not match a subject outside it: authelia skips it and # moves on, and with nothing after it lands on `default_policy`, which is # `one_factor` — *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. # Until a collector is registered, the `deny` is the only rule. collectorOnly = target: lib.optional collectorRegistered ( target // { policy = "one_factor"; subject = [ "oauth2:client:${collectorClientId}" ]; } ) ++ [ (target // { policy = "deny"; }) ]; # The metrics store's name, every path. Authelia is consulted there for the # collector's push route and `/metrics`, and for an operator's session on # the read API and vmui; Grafana's bearer on the read API is checked by # `swarm-metrics-auth`, never here. # # The collector's client is admitted anywhere on the name. Every other # subject is refused the collector's two paths, then operators are admitted # to the rest, then everything else is refused. Prefixes rather than exact # paths for the collector's two, so no spelling of them reaches the # operator rule. No rule takes a narrower `resources` than that, so no path # or query form falls through to `default_policy`. metricsStoreDomain = config.services.hyperhive.swarm.victoriametrics.domain; metricsStoreRules = lib.optional collectorRegistered { domain = metricsStoreDomain; policy = "one_factor"; subject = [ "oauth2:client:${collectorClientId}" ]; } ++ [ { domain = metricsStoreDomain; policy = "deny"; resources = [ "^/opentelemetry/.*" "^/metrics.*" ]; } { domain = metricsStoreDomain; policy = "one_factor"; subject = [ "group:${operatorGroup}" ]; } { domain = metricsStoreDomain; policy = "deny"; } ]; # One pair per `swarm.otel.publishedScrapeTargets` entry, read off the same # URL the collector requests and its token is minted for, so a target # cannot be published without the rules that confine it to the collector. # An entry on the metrics store's name is covered by `metricsStoreRules`. metricsRules = lib.filter (r: r.domain != metricsStoreDomain) ( lib.concatLists ( lib.mapAttrsToList ( job: url: let # The split `parsePublished` in ./swarm-otel.nix makes for the scrape # job; the two must agree on what the URL's host and path are. parts = builtins.match "https://([^/]+)(/.*)" url; in if parts == null then throw "swarm.otel.publishedScrapeTargets.${job} = \"${url}\" is not an https URL with a path; authelia's access rule for it cannot be derived." else collectorOnly { domain = builtins.elemAt parts 0; # ⚠️ Authelia matches `resources` against the cleaned path PLUS the # query string, while nginx's `= ` location ignores the query. # A bare `^$` therefore misses `?x`, which the location # still serves, and that request falls through to `default_policy`. resources = [ "^${lib.escapeRegex (builtins.elemAt parts 1)}([?].*)?$" ]; } ) config.services.hyperhive.swarm.otel.publishedScrapeTargets ) ); # Upstream's `services.authelia.instances.` derives the unit, # user, group and StateDirectory from the instance name # (`authelia` + `-`). Both are read back from `swarm.authelia.unit`, # so the instance declared below is the one that option names. unitName = lib.removeSuffix ".service" cfg.unit; instance = lib.removePrefix "authelia-" unitName; stateDir = "/var/lib/${unitName}"; atomicWriteSecret = import ./lib/atomic-write-secret.nix { }; 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. # The store's `pki` root is a runtime file no build-time option can name, so # without this bundle an https call out of here fails `UnknownIssuer`. caBundleModule = caTrust.trustBundle { inherit pkgs; name = cfg.machine; consumers = [ "swarm-authelia-bridge" ] # Go, for the swarm collector's name and authelia's own. Only where the # forwarder runs: naming it would otherwise define an empty unit. ++ lib.optional publishes "opentelemetry-collector"; }; # The container's journal forwarder pushes to its own receiver on the swarm # collector, the route `serviceProducers.swarm-authelia` in # ./swarm-otel-service.nix declares, as a client of its own. The port suffix # is elided on 443, as in ./swarm-bao.nix. forwarderClientId = cfg.otel.clientId; gatewayPortSuffix = lib.optionalString ( gatewayCfg.httpsPort != 443 ) ":${toString gatewayCfg.httpsPort}"; forwarderEndpoint = "https://${hyperhiveCfg.swarm.otel.domain}${gatewayPortSuffix}/${cfg.machine}"; # In the host netns: distinct from every other collector's there, which the # assertion below checks. forwarderTelemetryPort = 8901; # A client this container registers and mints for itself. Its secret goes # through the store like every other forwarder's: the publisher carries it # there and the forwarder reads it back under the same identity. forwarderClient = { id = forwarderClientId; description = "HyperHive authelia journal forwarder"; kind = "machine"; redirectUris = [ ]; # Its own id as its own permitted audience, the one the swarm collector's # receiver for this container checks. audience = [ forwarderClientId ]; # The receiver verifies the token offline against `/jwks.json`; # authelia's default opaque handle fails there. accessTokenSignedResponseAlg = "RS256"; }; # The secret publisher runs in this container, beside the mint whose secrets # it copies. It needs a store identity, and a reader is defined by holding a # certificate the store accepts, never by standing next to it — the rule # ./glue-queue-agent-credential.nix states in full. `deploy.bao.enable` here # would be the co-location assumption itself. publisherCfg = deployCfg.swarm-secret-publisher; publishes = publisherCfg.enable && publisherCfg.baoClientCertFile != null && publisherCfg.baoClientKeyFile != null; publisherUnit = "swarm-secret-publish"; forwarderFetchUnit = "swarm-journal-forwarder-oidc"; # The publisher's store identity, handed into the container as system # credentials (credential id → host source). The forwarder's fetch presents # it too: one identity for the whole container. storeFiles = lib.optionalAttrs publishes ( lib.filterAttrs (_: source: source != null) { publisher-bao-cert = publisherCfg.baoClientCertFile; publisher-bao-key = publisherCfg.baoClientKeyFile; publisher-bao-server-ca = deployCfg.bao.serverCaFile; } ); # nixos-container expands `extraFlags` unquoted, so a source path must be # one word. oneWord = path: builtins.match ".*[[:space:]].*" path == null; # The store options both units take, with the credentials as each loads them. storeIdentityIn = unit: let credential = id: "/run/credentials/${unit}.service/${id}"; in { package = deployCfg.bao.package; addr = "https://${hyperhiveCfg.swarm.bao.domain}:${toString hyperhiveCfg.swarm.bao.port}"; clientCertFile = credential "publisher-bao-cert"; clientKeyFile = credential "publisher-bao-key"; caFile = if storeFiles ? publisher-bao-server-ca then credential "publisher-bao-server-ca" else null; }; # 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 not 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 = [ ]; }; # `swarmctl login`: an operator's own account, from wherever they run the # CLI. The id is the one `swarmctl/src/remote.rs` sends. The audience is # the controller's API on the swarm apex, which authelia matches as a URL # prefix; the redirect matches a loopback callback on any port. swarmctlClient = { id = "swarmctl"; description = "swarmctl (swarm operator CLI)"; kind = "native"; redirectUris = [ "http://127.0.0.1/callback" ]; audience = [ "https://${swarmDomain}/api/" ]; }; # 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"; # The users database an empty user set is replaced with. authelia exits at # startup on `users: {}`, so the file always holds at least this subject, # and it can never log in: authelia answers a `disabled` user as "not # found" before checking any password, and the digest decodes but its key # is all zeros, which no known password hashes to. # # The `.` keeps the name out of agent names (`[a-z0-9-]`), and once this # file is written `swarmctl user add` refuses the name as already taken. # The writers round-trip `disabled`, so it survives every later write. placeholderUser = "swarm.placeholder"; placeholderUsersFile = pkgs.writeText "authelia-users-placeholder.yml" '' users: ${placeholderUser}: displayname: placeholder (cannot log in) email: ${placeholderUser}@hyperhive.local disabled: true password: '$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' ''; # 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} '' # A native client is public: it has no secret, so none is minted for it. + ( if c.kind == "native" then '' printf -- ' public: true\n' '' else '' 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.kind != "native" && 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' '' # What authelia 4.39.20 requires of a public client holding # `authelia.bearer.authz`; its validator refuses the client without # any one of these lines. else if c.kind == "native" then '' printf -- ' token_endpoint_auth_method: none\n' printf -- ' require_pkce: true\n' printf -- ' pkce_challenge_method: S256\n' printf -- ' require_pushed_authorization_requests: true\n' printf -- ' consent_mode: explicit\n' printf -- ' response_types: [code]\n' printf -- ' response_modes: [form_post]\n' printf -- ' grant_types: [authorization_code, refresh_token]\n' printf -- ' scopes: [offline_access, authelia.bearer.authz]\n' printf -- ' redirect_uris:\n' ${lib.concatMapStrings (u: '' printf -- ' - %s\n' ${lib.escapeShellArg u} '') c.redirectUris} '' 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} '') (lib.filter (c: c.kind != "native") 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 container: 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 two 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. When the user set is empty this module writes a single disabled placeholder subject, `${placeholderUser}`, so authelia starts and serves its login page; nobody can log in until `swarmctl user add` creates the first account. ''; }; 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 forwarderClient ] ++ lib.optional (swarmDomain != null) swarmctlClient; # 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.kind == "native") || 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.kind == "native") && c.audience == [ ]) cfg.oidc.clients ) ) + " sets bearerAuthz or kind = \"native\" 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."; } { assertion = lib.all oneWord (lib.attrValues storeFiles); message = '' The secret publisher's store identity paths must not contain whitespace: nspawn's --load-credential receives them through nixos-container's unquoted extra flags. ''; } { assertion = (hyperhiveCfg.swarm.otel.serviceProducers.${cfg.machine}.clientId or null) == forwarderClientId; message = '' services.hyperhive.swarm.otel.serviceProducers has no entry "${cfg.machine}" with clientId "${forwarderClientId}", so the swarm collector has no receiver for authelia's journal forwarder. Declare it in swarm-otel-service.nix. ''; } { assertion = !(lib.elem forwarderTelemetryPort [ hyperhiveCfg.otel.telemetryPort hyperhiveCfg.swarm.otel.telemetryPort hyperhiveCfg.swarm.bao.otel.telemetryPort hyperhiveCfg.swarm.nats.otel.telemetryPort ]); message = '' authelia's journal forwarder's telemetry port (${toString forwarderTelemetryPort}) collides with another collector's in the host netns: the hive collector's, the swarm collector's, the store forwarder's or the queue forwarder's. ''; } ]; # 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; # `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 `auth_request` here: it would put the login # page behind the login it serves. services.hyperhive.gateway.vhosts."${cfg.domain}" = { 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}"; }; # `swarm-authelia-bridge`, a separate process from authelia, also a # machine surface. The trailing slash on `proxyPass` strips the # prefix, so `/bridge/requests` reaches the bridge as `/requests`. # The bridge itself refuses every client but swarm-controller's. locations."/bridge/" = { proxyPass = "http://127.0.0.1:${toString cfg.bridgePort}/"; }; # Authelia's own metrics, for the swarm collector wherever it runs. # The listener (`metricsPort`) is loopback-only and unauthenticated; # this location is the only way in from off the machine. `= /metrics` # outranks the `/` prefix, so its auth is the one that runs. # # The collector requests exactly this URL, declared as # `publishedScrapeTargets.authelia` in ./swarm-otel-service.nix; the two # must agree, since that string is also the token's audience. locations."= /metrics" = { proxyPass = "http://127.0.0.1:${toString cfg.metricsPort}/metrics"; extraConfig = '' auth_request /__authelia_metrics_authz; ''; }; # The subrequest, same shape as the forge's `= /__forge_metrics_authz` # (./hive-forge/default.nix), and machine-facing for the same reason: no # `error_page 401 =302`, so a scraper gets the 401 rather than a login # page parsed as metrics. # # Authelia authorising a request for itself does not loop: the # subrequest lands on `/api/` above, which carries no `auth_request`. # An authelia that is down answers the subrequest 502, which # `auth_request` turns into a 500 for the scrape — closed, not open. locations."= /__authelia_metrics_authz" = { proxyPass = "https://${cfg.domain}/api/authz/auth-request"; # nixpkgs appends its OWN `Host $host` after extraConfig, which would # override verifiedProxyTo's — see hive-gateway/vhost-lib.nix. recommendedProxySettings = false; extraConfig = '' internal; ${gatewayCfg.lib.verifiedProxyTo cfg.domain} proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-URL $scheme://$http_host$request_uri; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; ''; }; }; # Order the container after the host CA generator, so the bind source # exists before nspawn sets the mount up. nspawn also fails to start it # when a credential source is missing, so where this host mints the # publisher's leaf the container pulls the minting unit in and waits for # it, as ./swarm-grafana.nix's does. The minting is local: the store need # not be up. systemd.services."container@${cfg.machine}" = lib.mkMerge [ caTrust.containerOrdering (lib.mkIf publishes { after = [ "swarm-bao-pki.service" ]; wants = [ "swarm-bao-pki.service" ]; }) ]; containers.${cfg.machine} = { autoStart = true; ephemeral = false; # No `--link-journal=host`: the journal stays in the guest, where the # container's own forwarder reads it (../container-modules/swarm-journal-forwarder.nix). extraFlags = lib.mapAttrsToList (id: source: "--load-credential=${id}:${source}") storeFiles; 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 ../container-modules/swarm-journal-forwarder.nix ../container-modules/swarm-secret-publisher.nix (import ../container-modules/swarm-container-resolver.nix { inherit (networkCfg) bridgeIp; # The forwarder dials the swarm collector and authelia by name, # and its fetch and the publisher the store. dnsConsumers = [ cfg.unit ] ++ lib.optionals publishes [ "opentelemetry-collector.service" "${forwarderFetchUnit}.service" "${publisherUnit}.service" ]; }) caBundleModule ]; services.hyperhive.swarmContainer = { inherit privateNetwork; }; # Authenticated as the forwarder's own OIDC client, its secret read # from the store with the publisher's identity: one identity for the # whole container. With no identity there is nothing to read it with, # and the forwarder is off. services.hyperhive.swarmJournalForwarder = lib.mkIf publishes { enable = true; serviceName = "authelia"; endpoint = forwarderEndpoint; clientId = forwarderClientId; tokenUrl = "${toString cfg.url}/api/oidc/token"; telemetryPort = forwarderTelemetryPort; store = storeIdentityIn forwarderFetchUnit // { secretPath = "secret/swarm/services/${forwarderClientId}/oidc/client"; }; }; systemd.services.${forwarderFetchUnit} = lib.mkIf publishes { serviceConfig.LoadCredential = lib.attrNames storeFiles; }; # Copies what the mint below leaves in `clientsDir` into the store, # under the publisher's own identity, a system credential. Nothing # orders authelia after it. services.hyperhive.swarmSecretPublisher = lib.mkIf publishes { enable = true; clientSecretDir = clientsDir; inherit (publisherCfg) secrets; store = storeIdentityIn publisherUnit; }; systemd.services.${publisherUnit} = lib.mkIf publishes { # A run before the mint would only skip every secret and wait # for the path unit. after = [ "${unitName}-secrets.service" ]; serviceConfig.LoadCredential = lib.attrNames storeFiles; }; # 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 ${atomicWriteSecret} # 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 value=$(head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n') atomic_write_secret 0600 "" "$p" "$value" echo "generated $p" fi chmod 0600 "$p" done ${oidcGenScript} # authelia exits at startup on an empty user set, so an empty # database gets the disabled placeholder subject instead. A # file holding exactly `users: {}` is an empty set too; neither # writer ever produces one, so replacing it drops no user. users=${lib.escapeShellArg deployCfg.authelia.usersFile} if [ ! -s "$users" ] || [ "$(cat "$users")" = "users: {}" ]; then install -m 0600 ${placeholderUsersFile} "$users" echo "seeded $users with the disabled placeholder ${placeholderUser}" 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 keeps no second, # private users store: a store of its own treated as canonical, # with `users.yml` written as a rendering of it, would be two # canonical stores for one physical file, and would make # `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"; # The one client the bridge answers. Every other active token # (each hive and its agents hold one) is refused, on every # request, since `/bridge/` is reachable from any host. SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID = hyperhiveCfg.swarm.controller.queueClientId; SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP = operatorGroup; }; }; 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. The collector reaches it through the # vhost's gated `= /metrics` location. 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 # `operatorsOnly` pairs below are what make the distinction # real; without them 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 rules are listed first so they cannot be # shadowed by a broader domain rule added later. A `deny` with # no `resources` ends its domain: a rule for that domain # listed after it is never reached. rules = # The collector's client where one is registered, then # `deny` — see `collectorOnly` above, which is where the # reasoning for both halves lives. metricsRules ++ metricsStoreRules # The swarm UI on the apex. Not keyed to a deploy flag: its # vhost is on the swarm controller's host, which need not # be this one. Guarded on the domain being set: without it # a null apex would render a rule matching the string "null". ++ lib.optionals (swarmDomain != null) (operatorsOnly { domain = swarmDomain; }) # 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 these rules any session passes it. ++ lib.optionals (swarmDomain != null) (operatorsOnly { domain = hyperhiveCfg.swarm.bao.ui.domain; }) # Every hive's dashboard, each on its own hive's host. ++ lib.optionals (swarmDomain != null) ( lib.concatMap (domain: operatorsOnly { inherit domain; }) ( lib.attrValues hyperhiveCfg.swarm.dashboardDomains ) ); }; # 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. # # Each hive's dashboard answers on `swarm.dashboardDomains`, # under this domain, so the cookie reaches it whatever the # hive's own domain is. session.cookies = [ { domain = cookieDomain; authelia_url = "https://${cfg.domain}"; } ]; storage.local.path = "${stateDir}/db.sqlite3"; notifier.filesystem.filename = "${stateDir}/notification.txt"; }; }; }; }; }; }