Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/host-modules/swarm-authelia.nix

1403 lines
67 KiB
Nix

# 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 <this>` 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 (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";
}
];
# The log store's ingest routes: the collector's client, nobody else. A
# prefix rather than the one route the vhost serves, so no spelling of it,
# and no route added under `/insert/`, reaches `default_policy`. The rest of
# the name (read API, vmui) stays open to any authenticated caller; its
# `/metrics` is covered by `metricsRules`.
logsStoreRules = collectorOnly {
domain = config.services.hyperhive.swarm.victorialogs.domain;
resources = [ "^/insert/.*" ];
};
# 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 `= <path>` location ignores the query.
# A bare `^<path>$` therefore misses `<path>?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.<name>` derives the unit,
# user, group and StateDirectory from the instance name
# (`authelia` + `-<name>`). 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.<swarm>`, `chat.<swarm>`, `auth.<swarm>`). 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-<name>-agent`) rather than taking a
# prefix of its own (`agent-<name>`), because that reads as *the agent called
# `<name>`* — 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:<port>.
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-<name>` 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 "<path>" }}`) 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
++ logsStoreRules
# 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.<swarm>`, `chat.<swarm>`), and a
# cookie scoped to `auth.<swarm>` 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";
};
};
};
};
};
}