journaldUnits named "authelia"; upstream derives the unit from the instance name, so the running unit is authelia-swarm and that entry matched nothing. The receiver's units list is an allowlist, so a name matching no journal entry is silently absent -- it reads as a service with nothing to say rather than as a misconfiguration, and it excluded the busiest source on the box. Use the unitName binding the rest of this module already uses, so the allowlist cannot drift from the unit again.
1411 lines
66 KiB
Nix
1411 lines
66 KiB
Nix
# The swarm's SSO provider: one authelia for the whole swarm, in a
|
||
# `swarm-authelia` nixos-container.
|
||
#
|
||
# Two halves, and only one of them is conditional:
|
||
#
|
||
# - the CLIENT pointer (`url`) exists on every hive, because a hive
|
||
# that doesn't run authelia still has to know where to send people.
|
||
# - the CONTAINER only exists where the swarm's shared services live.
|
||
# `swarm.enableRequiredServices` asserts this module's `enable`
|
||
# (see ./swarm-required-services.nix); a hive is a client by default.
|
||
#
|
||
# Operator and agents are both subjects of the same provider,
|
||
# differentiated by roles/claims rather than by mechanism — there is one
|
||
# IdP and one auth path. The users store is written by a program
|
||
# (`swarm-authelia-bridge`, see that option's doc comment), not
|
||
# maintained by hand: agents are created and destroyed continuously, so
|
||
# the subject set is *dynamic*. That is also why the file backend is
|
||
# right here, not a placeholder for LDAP: what makes a directory
|
||
# necessary is the size of the subject set, bounded by one swarm.
|
||
#
|
||
# Two roles: a **session** provider always, an **OIDC** provider when
|
||
# `oidc.clients` is non-empty (derived, not flagged). In practice OIDC
|
||
# is always on now: the bridge needs its own machine-client identity for
|
||
# its introspection calls, contributed unconditionally, not behind
|
||
# `oidc.hiveIdentities`. Secrets map: docs/swarm/sso.md.
|
||
#
|
||
# Per-service integration — putting authelia's `auth_request` in front
|
||
# of the gateway's existing `auth_basic` locations — is deliberately NOT
|
||
# here. Standing an SSO provider up is reversible; cutting every
|
||
# operator-facing vhost over to it is not.
|
||
{
|
||
pkgs,
|
||
lib,
|
||
config,
|
||
...
|
||
}:
|
||
let
|
||
cfg = config.services.hyperhive.swarm.authelia;
|
||
networkCfg = config.services.hyperhive.network;
|
||
hyperhiveCfg = config.services.hyperhive;
|
||
gatewayCfg = hyperhiveCfg.gateway;
|
||
hyperhiveDomain = hyperhiveCfg.domain;
|
||
swarmDomain = hyperhiveCfg.swarm.domain;
|
||
uiCfg = hyperhiveCfg.swarm.ui;
|
||
forgeCfg = hyperhiveCfg.swarm.forge;
|
||
|
||
# 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/setup.md` and
|
||
# `docs/swarm/sso.md` have been telling every operator to create
|
||
# exactly that group since the bootstrap step existed. This is the
|
||
# first rule that CONSUMES a group name; picking a different one would
|
||
# have meant every account created by following the guide silently
|
||
# failing the check it was supposed to pass.
|
||
operatorGroup = "admins";
|
||
|
||
# Whether the swarm collector's client is actually registered here.
|
||
#
|
||
# Asked of the client list rather than re-derived from the conditions
|
||
# that produce it (`otel.enable`, a non-empty audience set, authelia
|
||
# being co-located). A second copy of that predicate is a second thing
|
||
# to keep in step, and the two drifting is not a build failure: a rule
|
||
# naming an unregistered client is refused by authelia's *startup*
|
||
# validator, so the whole SSO service fails to restart.
|
||
#
|
||
# Reading the registration itself also makes the rule correct for a
|
||
# registrar this module has never heard of — an operator registering
|
||
# the collector by hand against a provider that is not co-located with
|
||
# it gets the same rule, from the same expression.
|
||
# ⚠️ `swarm.otel` named in full, not through a let binding: there are two
|
||
# otel options one word apart, and only this one is the swarm's collector.
|
||
collectorClientId = config.services.hyperhive.swarm.otel.clientId;
|
||
collectorRegistered = lib.any (c: c.id == collectorClientId) cfg.oidc.clients;
|
||
|
||
# The forge's metrics endpoint: deny until a collector exists to allow.
|
||
#
|
||
# `deny` is not a placeholder, it is the protection. The endpoint is
|
||
# always served (a swarm-integrated forge always has metrics) and
|
||
# `default_policy` is `one_factor`, which means *any* authenticated
|
||
# subject — every operator today, every agent once they hold authelia
|
||
# accounts. Nor does the audience stand between a browser session and
|
||
# this data: `authn_strategies` on the authz endpoint also accepts
|
||
# `CookieSession`, and a cookie carries no audience at all.
|
||
metricsRule = {
|
||
domain = forgeCfg.domain;
|
||
resources = [ "^/metrics$" ];
|
||
}
|
||
// (
|
||
if collectorRegistered then
|
||
{
|
||
policy = "one_factor";
|
||
subject = [ "oauth2:client:${collectorClientId}" ];
|
||
}
|
||
else
|
||
{ policy = "deny"; }
|
||
);
|
||
|
||
# Upstream's `services.authelia.instances.<name>` derives the unit,
|
||
# user, group and StateDirectory from the instance name
|
||
# (`authelia` + `-<name>`). Naming them here rather than repeating the
|
||
# literal keeps the generator unit below and the module in step.
|
||
instance = "swarm";
|
||
unitName = "authelia-${instance}";
|
||
stateDir = "/var/lib/${unitName}";
|
||
|
||
tlsCfg = hyperhiveCfg.tls;
|
||
caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
|
||
# `swarm-authelia-bridge` verifies the gateway when it introspects by name.
|
||
# Nothing in this container trusted the swarm CA, which is a runtime file no
|
||
# build-time option can name — so an https call out of here could only ever
|
||
# fail `UnknownIssuer`. Same defect the queue's responder hit.
|
||
caBundleModule = caTrust.trustBundle {
|
||
inherit pkgs;
|
||
name = cfg.machine;
|
||
consumers = [ "swarm-authelia-bridge" ];
|
||
};
|
||
|
||
# The SWARM's domain, because that is where the protected apps now live
|
||
# (`forge.<swarm>`, `chat.<swarm>`, `auth.<swarm>`). It moves in the
|
||
# same commit as `domain` below 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
|
||
# below 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. A hive's identity belongs
|
||
# to the DIRECTORY, not to whichever service happens to consume it:
|
||
# the rule is that a hive's credentials all derive from the SAME
|
||
# identity, so one hive holds ONE client and mints a different token
|
||
# per service from it. Were the queue to declare this list, the next
|
||
# consumer would collide on the same client id — and only at the
|
||
# moment it landed.
|
||
#
|
||
# 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.
|
||
#
|
||
# `audience` is the hive's own client id rather than a second per-hive
|
||
# string invented here. A swarm service that has to tell hives apart
|
||
# needs one name per hive that both sides already agree on, and the
|
||
# client id is that name — published as `hiveClientPrefix` for exactly
|
||
# this reason. Minting a parallel naming scheme would be a second thing
|
||
# to keep in step, and the one that drifts is the one nobody tests.
|
||
#
|
||
# `RS256` because a resource server that cannot call this provider back
|
||
# is a real case here: the swarm's telemetry collector verifies tokens
|
||
# offline against `/jwks.json`, and an opaque token gives it nothing to
|
||
# verify. The queue's auth-callout responder introspects instead, which
|
||
# is a different question asked of the same token.
|
||
hiveClients = lib.mapAttrsToList (name: _: {
|
||
id = "${cfg.hiveClientPrefix}${name}";
|
||
description = "HyperHive hive ${name}";
|
||
kind = "machine";
|
||
redirectUris = [ ];
|
||
audience = [ "${cfg.hiveClientPrefix}${name}" ];
|
||
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 = [ ];
|
||
};
|
||
|
||
# authelia refuses to start with an OIDC provider that has no clients,
|
||
# so the provider is derived from the client list rather than carrying
|
||
# its own `enable`: one fact, and it cannot contradict itself.
|
||
#
|
||
# ⚠️ In practice this is now unconditionally `true` whenever the module
|
||
# is enabled: `bridgeClient` above is an unconditional definition of
|
||
# `oidc.clients` (see the `config` block), so the list is never empty.
|
||
# Kept as a derived boolean rather than simplified to a literal `true`
|
||
# so the OIDC-gated code below stays self-documenting about WHY it is
|
||
# conditional, not just that it happens to always be on today.
|
||
oidcEnabled = cfg.oidc.clients != [ ];
|
||
|
||
# Secrets that are 64 random bytes of hex and nothing more. The OIDC
|
||
# hmac key joins them; the issuer key does not (see below — it is RSA).
|
||
randomKeys = [
|
||
"jwt"
|
||
"session"
|
||
"storage-encryption"
|
||
]
|
||
++ lib.optional oidcEnabled "oidc-hmac";
|
||
|
||
# Per-client material lives beside the rest of authelia's state, one
|
||
# file per half: the relying party needs the PLAINTEXT, authelia keeps
|
||
# only a DIGEST. Splitting them is what lets the clients file below be
|
||
# re-rendered on every boot from a secret that was minted once.
|
||
clientsDir = "${stateDir}/oidc-clients";
|
||
clientsFile = "${stateDir}/oidc-clients.yml";
|
||
|
||
# Rendered at RUNTIME, not evaluated: the digest is read from disk by
|
||
# the script, so nothing secret ever enters a nix expression (and
|
||
# therefore the store). Everything else here is public metadata that
|
||
# nix is the right place for.
|
||
# A machine client is not an interactive one with the redirect list left
|
||
# empty: authelia derives the permitted grant from what is declared, and an
|
||
# omitted `grant_types` means authorization-code ONLY. Measured against
|
||
# authelia 4.39.20 — a client rendered without it answers a
|
||
# `client_credentials` request with
|
||
# unauthorized_client: The OAuth 2.0 Client is not allowed to use
|
||
# authorization grant 'client_credentials'
|
||
# so the two shapes have to be told apart here rather than inferred from an
|
||
# empty list.
|
||
#
|
||
# `openid` is deliberately absent from a machine client's scopes: authelia
|
||
# REFUSES the combination outright ("the values 'openid' are not allowed"
|
||
# with `client_credentials`), because a daemon receives an access token and
|
||
# never an id-token. There is no user to identify.
|
||
renderClient =
|
||
c:
|
||
''
|
||
printf -- ' - client_id: %s\n' ${lib.escapeShellArg c.id}
|
||
printf -- ' client_name: %s\n' ${lib.escapeShellArg c.description}
|
||
printf -- " client_secret: '%s'\n" "$(cat ${lib.escapeShellArg "${clientsDir}/${c.id}.digest"})"
|
||
printf -- ' authorization_policy: one_factor\n'
|
||
''
|
||
# Read plainly, and that is a property of the list rather than of
|
||
# this line: every entry reaching here is a definition of
|
||
# `oidc.clients`, so the module system has applied the submodule and
|
||
# each option's default is present. A derived entry that names only
|
||
# the fields it cares about still arrives with the rest filled in.
|
||
+ lib.optionalString (c.tokenEndpointAuthMethod != null) ''
|
||
printf -- ' token_endpoint_auth_method: %s\n' ${lib.escapeShellArg c.tokenEndpointAuthMethod}
|
||
''
|
||
# Flow-style YAML, matching `scopes` below. The values are client ids
|
||
# and hive names, which `Ident` already constrains to `[a-z0-9-]` — no
|
||
# character in that set needs quoting in a YAML flow sequence.
|
||
+ lib.optionalString (c.audience != [ ]) ''
|
||
printf -- ' audience: [%s]\n' ${lib.escapeShellArg (lib.concatStringsSep ", " c.audience)}
|
||
''
|
||
+ lib.optionalString (c.accessTokenSignedResponseAlg != null) ''
|
||
printf -- ' access_token_signed_response_alg: %s\n' ${lib.escapeShellArg c.accessTokenSignedResponseAlg}
|
||
''
|
||
+ (
|
||
if c.kind == "machine" then
|
||
''
|
||
printf -- ' grant_types: ["client_credentials"]\n'
|
||
printf -- ' scopes: [${lib.optionalString c.bearerAuthz "authelia.bearer.authz"}]\n'
|
||
''
|
||
else
|
||
''
|
||
printf -- ' scopes: [openid, profile, email, groups]\n'
|
||
printf -- ' redirect_uris:\n'
|
||
${lib.concatMapStrings (u: ''
|
||
printf -- ' - %s\n' ${lib.escapeShellArg u}
|
||
'') c.redirectUris}
|
||
''
|
||
);
|
||
|
||
# The OIDC half of the first-boot generator, kept out of the script
|
||
# body so neither is read through the other's indentation.
|
||
oidcGenScript = lib.optionalString oidcEnabled ''
|
||
# The issuer key is the one secret here that is NOT interchangeable
|
||
# with a random blob: it *signs* id tokens, and every relying party
|
||
# verifies them against the public half served at `/jwks.json`. A
|
||
# symmetric secret cannot do that, so this one is an RSA pair.
|
||
#
|
||
# Rotating it invalidates every token already issued, which is why
|
||
# it is generated once and left alone — same reason as the session
|
||
# and storage keys above.
|
||
issuer=${lib.escapeShellArg stateDir}/oidc-issuer.key
|
||
if [ ! -s "$issuer" ]; then
|
||
openssl genrsa -out "$issuer" 4096
|
||
echo "generated $issuer"
|
||
fi
|
||
chmod 0600 "$issuer"
|
||
|
||
# Each client's secret, minted once and kept as two files: the
|
||
# plaintext its relying party authenticates with, and the digest
|
||
# authelia compares against. The two live in different containers,
|
||
# so neither side can generate it alone — this is the only place
|
||
# that sees both.
|
||
#
|
||
# `--random` is why no plaintext ever reaches an argv: authelia
|
||
# generates the password itself and prints it beside its digest,
|
||
# so nothing has to be handed to a second process on a command
|
||
# line.
|
||
clients=${lib.escapeShellArg clientsDir}
|
||
mkdir -p "$clients"
|
||
chmod 0700 "$clients"
|
||
|
||
mint() {
|
||
sec="$clients/$1.secret"
|
||
dig="$clients/$1.digest"
|
||
if [ -s "$sec" ] && [ -s "$dig" ]; then
|
||
chmod 0600 "$sec" "$dig"
|
||
return
|
||
fi
|
||
out=$(authelia crypto hash generate pbkdf2 --variant sha512 --random)
|
||
printf '%s' "$out" | sed -n 's/^Random Password: *//p' > "$sec"
|
||
printf '%s' "$out" | sed -n 's/^Digest: *//p' > "$dig"
|
||
chmod 0600 "$sec" "$dig"
|
||
# Fail closed. An empty secret is a client that can never
|
||
# authenticate, and it surfaces three layers away as an opaque
|
||
# 401 from the token endpoint — refusing to start is by far the
|
||
# cheaper failure to diagnose.
|
||
if [ ! -s "$sec" ] || [ ! -s "$dig" ]; then
|
||
echo "authelia crypto hash generate produced no secret/digest for $1" >&2
|
||
exit 1
|
||
fi
|
||
echo "minted client secret for $1"
|
||
}
|
||
|
||
${lib.concatMapStrings (c: ''
|
||
mint ${lib.escapeShellArg c.id}
|
||
'') cfg.oidc.clients}
|
||
|
||
# Re-rendered every boot, deliberately: the secret is minted once,
|
||
# but the metadata around it (a new redirect URI, a renamed client)
|
||
# comes from nix and has to be able to change without disturbing
|
||
# the secret. Written through a temp file so a crash mid-write
|
||
# cannot leave authelia half a file to parse.
|
||
{
|
||
printf -- 'identity_providers:\n'
|
||
printf -- ' oidc:\n'
|
||
printf -- ' clients:\n'
|
||
${lib.concatMapStrings renderClient cfg.oidc.clients}
|
||
} > ${lib.escapeShellArg "${clientsFile}.tmp"}
|
||
chmod 0600 ${lib.escapeShellArg "${clientsFile}.tmp"}
|
||
mv ${lib.escapeShellArg "${clientsFile}.tmp"} ${lib.escapeShellArg clientsFile}
|
||
'';
|
||
in
|
||
{
|
||
options.services.hyperhive.swarm.authelia = {
|
||
enable = lib.mkOption {
|
||
type = lib.types.bool;
|
||
default = false;
|
||
example = true;
|
||
description = ''
|
||
Run the swarm's authelia in a `swarm-authelia` container on this
|
||
host. `services.hyperhive.swarm.enableRequiredServices` turns
|
||
this on — a swarm has one SSO provider, and that says it lives
|
||
here.
|
||
|
||
With it off, this hive is a *client*: `url` below still points
|
||
at whoever runs it, and no container is created.
|
||
'';
|
||
};
|
||
|
||
package = lib.mkOption {
|
||
type = lib.types.package;
|
||
default = pkgs.authelia;
|
||
defaultText = lib.literalExpression "pkgs.authelia";
|
||
description = ''
|
||
authelia package to run in the container. Defaults to
|
||
nixpkgs's; override to pin a specific upstream.
|
||
'';
|
||
};
|
||
|
||
port = lib.mkOption {
|
||
type = lib.types.port;
|
||
default = 9091;
|
||
description = ''
|
||
TCP port authelia listens on. 9091 is upstream's default and
|
||
sits outside hyperhive's claimed ranges (dashboard 7000, forge
|
||
3000, matrix 8008, every agent in 8100..8999 via FNV-1a hash).
|
||
'';
|
||
};
|
||
|
||
metricsPort = lib.mkOption {
|
||
type = lib.types.port;
|
||
default = 9959;
|
||
description = ''
|
||
TCP port authelia serves its Prometheus metrics on, bound to
|
||
loopback. Upstream's default, kept so an operator reading
|
||
authelia's documentation finds what they expect.
|
||
|
||
A separate port from {option}`port` because it is a separate
|
||
listener with a different audience: the main one is proxied by
|
||
the gateway and reachable from the swarm, this one is scraped by
|
||
the collector on this host and by nothing else.
|
||
|
||
⚠️ Every swarm container shares the host network namespace, so
|
||
two services defaulting to the same port do not conflict at build
|
||
time — one simply loses at runtime, with nothing in any log. Check
|
||
a new value against the others before changing this.
|
||
|
||
::: {.note}
|
||
Loopback means this endpoint is only reachable by a collector on
|
||
the *same host*, so the scrape target is declared only when one is
|
||
enabled here. Run the swarm's collector elsewhere and authelia's
|
||
metrics are simply not collected — no error, and nothing in a log
|
||
to say so. Making them reachable across hosts is a different piece
|
||
of work: the endpoint would have to be published under a name,
|
||
with a certificate and an audience.
|
||
:::
|
||
'';
|
||
};
|
||
|
||
domain = lib.mkOption {
|
||
type = lib.types.str;
|
||
# Under the SWARM domain, like the forge and matrix: a swarm has one
|
||
# SSO provider, and the session cookie has to reach the swarm's
|
||
# services.
|
||
#
|
||
# Total on a null swarm domain so the required-domain assertion in
|
||
# hive-network.nix is the thing that fires; see the comment there.
|
||
default = if swarmDomain == null then "auth.invalid" else "auth.${swarmDomain}";
|
||
defaultText = lib.literalExpression ''"auth.''${services.hyperhive.swarm.domain}"'';
|
||
example = "login.example.com";
|
||
description = ''
|
||
Public hostname for the SSO provider — the sub-domain shape the
|
||
forge and matrix already use, under the swarm's domain because a
|
||
swarm has **one** SSO provider. Must be the name browsers
|
||
actually visit: it is the `authelia_url` the session cookie is
|
||
validated against.
|
||
|
||
⚠️ Unlike the forge and matrix names, this one carries **no
|
||
migration pin**: nothing depends on the previous
|
||
`auth.''${services.hyperhive.domain}` yet, so it moves outright.
|
||
'';
|
||
};
|
||
|
||
url = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
default = if cfg.enable then "https://${cfg.domain}" else null;
|
||
defaultText = lib.literalExpression ''if enable then "https://''${domain}" else null'';
|
||
example = "https://auth.example.com";
|
||
description = ''
|
||
Base URL clients are sent to for authentication — the half of
|
||
this module that exists on **every** hive, not just the one
|
||
running the container.
|
||
|
||
Defaults to this host's own instance **only when this module is
|
||
the thing running it**; in that case the URL is not a guess, it
|
||
is where this module just put the container. Otherwise `null`,
|
||
and a hive that federates with a swarm sets it explicitly to
|
||
wherever the swarm's authelia lives. Null means "no SSO
|
||
configured" and consumers say so rather than inventing an
|
||
address — an endpoint baked in as a fallback is one that
|
||
resolves cleanly and points at the wrong machine.
|
||
'';
|
||
};
|
||
|
||
usersFile = lib.mkOption {
|
||
type = lib.types.str;
|
||
default = "${stateDir}/users.yml";
|
||
defaultText = lib.literalExpression ''"/var/lib/authelia-swarm/users.yml"'';
|
||
description = ''
|
||
Path (inside the container) of authelia's file users database.
|
||
|
||
Written by `swarm-authelia-bridge`, not by hand: agents come and
|
||
go continuously, so the subject set is dynamic and belongs to a
|
||
program. `swarm-controller` cannot write this file itself — a
|
||
different uid owns it — so the bridge is the only writer,
|
||
running inside this same container as this file's actual owner.
|
||
This module only guarantees the file *exists* and is valid YAML
|
||
at first boot, so authelia starts with no subjects rather than
|
||
failing to start — a provider with nobody in it yet is the
|
||
correct state before anything has provisioned users.
|
||
'';
|
||
};
|
||
|
||
oidc.hiveIdentities = lib.mkOption {
|
||
type = lib.types.bool;
|
||
default = hyperhiveCfg.swarm.nats.enable;
|
||
defaultText = lib.literalExpression "services.hyperhive.swarm.nats.enable";
|
||
description = ''
|
||
Mint one machine client per hive in
|
||
{option}`services.hyperhive.swarm.hives`, so each hive can
|
||
authenticate to swarm services as itself.
|
||
|
||
Defaults to whether the swarm message queue is enabled, because
|
||
that is the first service that needs a hive to prove who it is.
|
||
It is an option rather than a hard-coded condition so a second
|
||
consumer — the swarm telemetry collector — can turn it on
|
||
without the queue, and so a swarm that wants the identities
|
||
provisioned ahead of either can say so.
|
||
|
||
The clients are inert until something authenticates with them:
|
||
each is a client id and a secret sitting on this host. What
|
||
delivers a secret to a hive that is not this host is a separate
|
||
problem and deliberately not solved here.
|
||
'';
|
||
};
|
||
|
||
oidc.clients = lib.mkOption {
|
||
type = lib.types.listOf (
|
||
lib.types.submodule {
|
||
options = {
|
||
id = lib.mkOption {
|
||
type = lib.types.str;
|
||
example = "forgejo";
|
||
description = ''
|
||
OAuth2 client id, as the relying party knows itself.
|
||
'';
|
||
};
|
||
|
||
description = lib.mkOption {
|
||
type = lib.types.str;
|
||
example = "HyperHive forge";
|
||
description = ''
|
||
Human-readable name, shown on authelia's consent screen.
|
||
This is the string a person reads when deciding whether
|
||
to hand an application their identity, so it should name
|
||
the application rather than the protocol.
|
||
'';
|
||
};
|
||
|
||
kind = lib.mkOption {
|
||
type = lib.types.enum [
|
||
"interactive"
|
||
"machine"
|
||
];
|
||
default = "interactive";
|
||
example = "machine";
|
||
description = ''
|
||
Whether a human logs in through this client, or a daemon
|
||
authenticates as itself.
|
||
|
||
`interactive` is the authorization-code flow: a browser is
|
||
redirected, a person authenticates, the client receives an
|
||
id-token. `machine` is `client_credentials`: there is
|
||
nobody to redirect and no identity to assert but the
|
||
client's own, so it receives an access token and no
|
||
id-token.
|
||
|
||
This is declared rather than inferred from an empty
|
||
`redirectUris`, because authelia permits only the grants a
|
||
client names — omitting `grant_types` yields
|
||
authorization-code alone, and a daemon then fails at the
|
||
token endpoint with `unauthorized_client` rather than at
|
||
evaluation.
|
||
'';
|
||
};
|
||
|
||
redirectUris = lib.mkOption {
|
||
type = lib.types.listOf lib.types.str;
|
||
default = [ ];
|
||
example = [ "https://forge.example.com/user/oauth2/authelia/callback" ];
|
||
description = ''
|
||
Exact callback URLs the provider will redirect to.
|
||
Matched literally by authelia — a trailing-slash
|
||
difference is a rejected login, not a warning.
|
||
|
||
Meaningless for `kind = "machine"`, which is asserted
|
||
rather than silently ignored.
|
||
'';
|
||
};
|
||
|
||
tokenEndpointAuthMethod = lib.mkOption {
|
||
type = lib.types.nullOr (
|
||
lib.types.enum [
|
||
"client_secret_basic"
|
||
"client_secret_post"
|
||
"client_secret_jwt"
|
||
"private_key_jwt"
|
||
"none"
|
||
]
|
||
);
|
||
default = null;
|
||
example = "client_secret_post";
|
||
description = ''
|
||
How this client proves its identity at the token
|
||
endpoint. `null` leaves authelia on its own default
|
||
(`client_secret_basic`), which is what every client that
|
||
does not say otherwise gets.
|
||
|
||
Set it when the relying party's implementation differs,
|
||
because authelia enforces the registered method rather
|
||
than accepting whatever arrives. tuwunel sends
|
||
`client_secret_post`, and against a client registered for
|
||
basic the result is a 401 from `/api/oidc/token` **after
|
||
a successful consent** — the login looks like it worked
|
||
right up to the last hop, and neither the redirect nor
|
||
the secret is at fault.
|
||
|
||
⚠️ `null` is NOT accepted on a client with `bearerAuthz`.
|
||
Measured against authelia 4.39.20: under that scope the
|
||
method must be *stated*: omitting it is refused with
|
||
`must be configured as 'client_secret_basic', … but it's
|
||
configured as` an empty string. The sentence above is
|
||
true of an ordinary client and false of that one, which
|
||
is exactly how a reviewer reads this option and concludes
|
||
the assertion below is wrong.
|
||
'';
|
||
};
|
||
|
||
audience = lib.mkOption {
|
||
type = lib.types.listOf lib.types.str;
|
||
default = [ ];
|
||
example = [ "hive-alpha" ];
|
||
description = ''
|
||
Audiences (`aud`) this client is permitted to request a
|
||
token for. Empty means it asks for none, which is the
|
||
right answer for a client whose resource server does not
|
||
distinguish callers.
|
||
|
||
⚠️ Registering an audience only *permits* it — the value
|
||
lands in a token when the client **asks** for it at the
|
||
token endpoint, and a client that does not send
|
||
`audience=` receives a token with `aud: []` however
|
||
complete this list looks. Measured against authelia
|
||
4.39.20: the config reads exactly right and the resource
|
||
server rejects every token, because a config that grants
|
||
and a request that claims are two separate acts.
|
||
|
||
Requesting an audience that is *not* listed here is
|
||
refused with `invalid_target`, which is what makes this
|
||
usable as a boundary rather than a label: a client cannot
|
||
mint a token for a resource slot that is not its own.
|
||
'';
|
||
};
|
||
|
||
bearerAuthz = lib.mkOption {
|
||
type = lib.types.bool;
|
||
default = false;
|
||
example = true;
|
||
description = ''
|
||
Grant this client the `authelia.bearer.authz` scope, so it
|
||
may present its access token to authelia's authz endpoint
|
||
and be authorised by an `access_control` rule — how a
|
||
scraper reaches a service published behind the gateway.
|
||
|
||
A named capability rather than a free-form `scopes` list,
|
||
for the same reason `kind` derives the rest: authelia
|
||
refuses some scope/grant combinations outright (`openid`
|
||
with `client_credentials` among them), and a list would
|
||
make those combinations expressible again. This admits the
|
||
one value that is legal here and nothing else.
|
||
|
||
::: {.note}
|
||
Setting this obliges two other options, and the assertions
|
||
below enforce it. Authelia checks the same thing, but only
|
||
in its `preStart` validator — which means a violation
|
||
builds and deploys cleanly and then fails to restart,
|
||
taking swarm SSO down. The assertions move that to
|
||
evaluation, where a wrong value costs nothing.
|
||
:::
|
||
'';
|
||
};
|
||
|
||
accessTokenSignedResponseAlg = lib.mkOption {
|
||
type = lib.types.nullOr (
|
||
lib.types.enum [
|
||
"none"
|
||
"RS256"
|
||
]
|
||
);
|
||
default = null;
|
||
example = "RS256";
|
||
description = ''
|
||
Signing algorithm for this client's **access** tokens.
|
||
`null` leaves authelia on its default, which issues an
|
||
opaque token (`authelia_at_…`) — a database handle that
|
||
carries no claims and means nothing to anyone but this
|
||
provider.
|
||
|
||
Set `RS256` when the resource server verifies the token
|
||
*itself* rather than asking this provider about it: that
|
||
yields an RFC 9068 JWT (`at+jwt`) carrying `aud`, `iss`
|
||
and `client_id`, verifiable against `/jwks.json` with no
|
||
round trip.
|
||
|
||
⚠️ This is what makes a token readable by an
|
||
OIDC-verifying consumer at all. A resource server given
|
||
an opaque token is not *misconfigured* — it is
|
||
structurally unable to verify it, and says so in terms
|
||
that point at the verifier rather than at the token's
|
||
format.
|
||
'';
|
||
};
|
||
};
|
||
}
|
||
);
|
||
default = [ ];
|
||
description = ''
|
||
OIDC relying parties this provider will issue tokens to.
|
||
Declaring one turns the provider on; the default empty list
|
||
leaves this module exactly as it was — a session provider and
|
||
nothing else.
|
||
|
||
⚠️ **There is deliberately no secret here.** A client secret has
|
||
two holders in two containers (authelia keeps a *hash*, the
|
||
relying party the *plaintext*), and
|
||
`services.authelia.instances.<n>.settings` is rendered into the
|
||
**nix store**, which is world-readable and permanent. So this
|
||
option carries only the parts that are safe to evaluate: the
|
||
secret is minted on first boot and never passes through a nix
|
||
expression. See `docs/swarm/` for what goes where.
|
||
'';
|
||
};
|
||
|
||
# Derived facts, exposed for consumers that have to act on this
|
||
# container **from outside it** — `swarmctl` is the first, and it
|
||
# needs all three. Read-only options rather than literals repeated at
|
||
# the call site: the machine and unit names are derived from
|
||
# `instance` here, so a second copy elsewhere is a second thing to
|
||
# keep in step, and the one that drifts is the one nobody tests.
|
||
hiveClientPrefix = lib.mkOption {
|
||
type = lib.types.str;
|
||
readOnly = true;
|
||
default = "hive-";
|
||
description = ''
|
||
Prefix of the OAuth2 client id minted for each hive in
|
||
`services.hyperhive.swarm.hives` — the client for hive `alpha` is
|
||
`${config.services.hyperhive.swarm.authelia.hiveClientPrefix}alpha`.
|
||
Read-only for the same reason as `machine` and `unit`: it is what
|
||
this module produces, published so a consumer does not carry a
|
||
second copy.
|
||
|
||
The consumer that matters is the queue's auth-callout responder,
|
||
which decides *which hive* a connection is by stripping this
|
||
prefix off the introspected client id. Split the two spellings and
|
||
every hive is denied — as a timeout, indistinguishable from a hive
|
||
that simply has not reported.
|
||
'';
|
||
};
|
||
|
||
machine = lib.mkOption {
|
||
type = lib.types.str;
|
||
readOnly = true;
|
||
default = "swarm-authelia";
|
||
description = ''
|
||
Name of the nixos-container authelia runs in. Read-only: it is
|
||
what this module declares, published so callers of
|
||
`systemctl -M` and `/var/lib/nixos-containers/<name>` do not
|
||
have to hardcode it.
|
||
'';
|
||
};
|
||
|
||
unit = lib.mkOption {
|
||
type = lib.types.str;
|
||
readOnly = true;
|
||
default = "${unitName}.service";
|
||
description = ''
|
||
authelia's systemd unit *inside* the container. Read-only, and
|
||
derived from the instance name exactly like the unit itself.
|
||
'';
|
||
};
|
||
|
||
hostClientSecretDir = lib.mkOption {
|
||
type = lib.types.str;
|
||
readOnly = true;
|
||
default = "/var/lib/nixos-containers/${cfg.machine}${clientsDir}";
|
||
description = ''
|
||
Where the minted client secrets sit **as seen from the host** —
|
||
`<id>.secret` holds a plaintext, `<id>.digest` the hash authelia
|
||
itself reads.
|
||
|
||
Published for the same reason as `hostUsersFile`: the plaintext's
|
||
other reader lives in a **different container**, and containers
|
||
that share this host's network namespace still have separate
|
||
filesystem roots. The host is the only place both trees are
|
||
addressable, so the host is where a delivery step has to run.
|
||
|
||
⚠️ Nothing here exists until authelia's **first boot** has run.
|
||
A consumer must wait for it — it cannot be a `bindMounts` source,
|
||
because nixos-container refuses to start when a bind source is
|
||
missing, and that turns a fresh hive into a boot-order deadlock.
|
||
'';
|
||
};
|
||
|
||
hostUsersFile = lib.mkOption {
|
||
type = lib.types.str;
|
||
readOnly = true;
|
||
default = "/var/lib/nixos-containers/${cfg.machine}${cfg.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.
|
||
'';
|
||
};
|
||
|
||
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.
|
||
'';
|
||
};
|
||
|
||
bridgePort = lib.mkOption {
|
||
type = lib.types.port;
|
||
default = 9092;
|
||
description = ''
|
||
TCP port `swarm-authelia-bridge` listens on, loopback-bound
|
||
(`127.0.0.1:''${bridgePort}`) — one above authelia's own default
|
||
`port` (9091), outside hyperhive's other claimed ranges.
|
||
|
||
Reachable directly from this host's other processes (this
|
||
container shares the host netns, same as authelia's own `port`)
|
||
without going through the gateway — this is an internal
|
||
service-to-service endpoint, not something meant to be exposed
|
||
publicly.
|
||
'';
|
||
};
|
||
|
||
bridgeUrl = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
readOnly = true;
|
||
default = if cfg.enable then "http://127.0.0.1:${toString cfg.bridgePort}" else null;
|
||
defaultText = lib.literalExpression ''if enable then "http://127.0.0.1:''${bridgePort}" else null'';
|
||
description = ''
|
||
Where `swarm-authelia-bridge` answers, **as seen from this
|
||
host** — correct only when a caller (`swarm-controller`) also
|
||
runs on this host, the same co-location assumption
|
||
`swarm.nix`'s `clientSecretFile` documents for its own
|
||
cross-host case. `null` when this host doesn't run
|
||
`swarm-authelia` at all.
|
||
|
||
A split-host swarm has no automated delivery for this address:
|
||
the operator points `swarm-controller`'s own option at wherever
|
||
this host has made the bridge reachable (a firewall rule, a
|
||
different bind address), the same manual-copy shape used
|
||
throughout this codebase's other cross-host cases.
|
||
'';
|
||
};
|
||
};
|
||
|
||
config = lib.mkIf (hyperhiveCfg.enable && cfg.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
|
||
++ [ bridgeClient ];
|
||
|
||
# A redirect URI on a machine client is not harmless-but-unused: it
|
||
# means whoever wrote it believes a browser is involved. Failing here
|
||
# is how that belief gets corrected at the point it was expressed,
|
||
# rather than at a token endpoint months later.
|
||
assertions =
|
||
map (c: {
|
||
assertion = c.kind != "machine" || c.redirectUris == [ ];
|
||
message =
|
||
"services.hyperhive.swarm.authelia.oidc.clients: client '${c.id}' is "
|
||
+ "kind = \"machine\" but declares redirectUris. A client_credentials "
|
||
+ "client has nobody to redirect; drop the URIs or make it "
|
||
+ "kind = \"interactive\".";
|
||
}) cfg.oidc.clients
|
||
# Two clients sharing an id renders two YAML entries under one name.
|
||
# Newly reachable now that part of the list is DERIVED: a hive called
|
||
# `x` and a service client called `hive-x` never met before. Authelia
|
||
# would reject it, but three layers away and at boot — naming both
|
||
# sources here is the cheaper failure.
|
||
++ [
|
||
{
|
||
# The bridge introspects by name, so a null URL becomes a nix
|
||
# coercion error several files from its cause. Only reachable by
|
||
# enabling authelia and clearing `url` by hand — an assertion
|
||
# rather than a fallback, because a guessed URL that evaluates
|
||
# cleanly is worse than a refused build.
|
||
assertion = cfg.url != null;
|
||
message =
|
||
"services.hyperhive.swarm.authelia.url must not be null when authelia "
|
||
+ "is enabled: swarm-authelia-bridge introspects at "
|
||
+ "`\${url}/api/oidc/introspection` from inside its container.";
|
||
}
|
||
{
|
||
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.";
|
||
}
|
||
# The two obligations `authelia.bearer.authz` carries. Authelia
|
||
# enforces both itself — but in its `preStart` validator, so a
|
||
# violation produces a green `nixos-rebuild switch` and an authelia
|
||
# that then refuses to come back up, taking swarm SSO with it.
|
||
# Asserting here moves the same failure to evaluation, where a
|
||
# wrong value costs a build and nothing else.
|
||
{
|
||
assertion = lib.all (c: !c.bearerAuthz || c.audience != [ ]) cfg.oidc.clients;
|
||
message =
|
||
"services.hyperhive.swarm.authelia.oidc.clients: "
|
||
+ lib.concatStringsSep ", " (
|
||
map (c: "client '${c.id}'") (lib.filter (c: c.bearerAuthz && c.audience == [ ]) cfg.oidc.clients)
|
||
)
|
||
+ " sets bearerAuthz but declares no audience. The audience is "
|
||
+ "what authorises a bearer token at a given URL, so without "
|
||
+ "one the scope grants access to nothing and authelia refuses "
|
||
+ "the configuration outright.";
|
||
}
|
||
{
|
||
assertion = lib.all (
|
||
c:
|
||
!c.bearerAuthz
|
||
|| lib.elem c.tokenEndpointAuthMethod [
|
||
"client_secret_basic"
|
||
"client_secret_jwt"
|
||
"private_key_jwt"
|
||
]
|
||
) cfg.oidc.clients;
|
||
message =
|
||
"services.hyperhive.swarm.authelia.oidc.clients: "
|
||
+ lib.concatStringsSep ", " (
|
||
map (c: "client '${c.id}' (tokenEndpointAuthMethod = ${toString c.tokenEndpointAuthMethod})") (
|
||
lib.filter (
|
||
c:
|
||
c.bearerAuthz
|
||
&& !lib.elem c.tokenEndpointAuthMethod [
|
||
"client_secret_basic"
|
||
"client_secret_jwt"
|
||
"private_key_jwt"
|
||
]
|
||
) cfg.oidc.clients
|
||
)
|
||
)
|
||
+ ". A confidential client carrying authelia.bearer.authz must "
|
||
+ "authenticate with client_secret_basic, client_secret_jwt or "
|
||
+ "private_key_jwt. Notably client_secret_post is refused, and "
|
||
+ "it is what an OAuth2 client library may reach for first. "
|
||
+ "Leaving it null is refused too: authelia requires the method "
|
||
+ "to be STATED under this scope rather than defaulted.";
|
||
}
|
||
# Nothing else stops `bearerAuthz` on an interactive client, and it
|
||
# would silently do nothing: `renderClient` reads the flag only in
|
||
# the `machine` branch, so the scope is simply never emitted and the
|
||
# client authenticates fine while being authorised for nothing.
|
||
#
|
||
# That is this module's own failure mode one level up — a green
|
||
# build and a grant that does not exist — and `kind` defaults to
|
||
# `interactive`, so it is reached by FORGETTING a field rather than
|
||
# by writing a wrong one.
|
||
{
|
||
assertion = lib.all (c: !c.bearerAuthz || c.kind == "machine") cfg.oidc.clients;
|
||
message =
|
||
"services.hyperhive.swarm.authelia.oidc.clients: "
|
||
+ lib.concatStringsSep ", " (
|
||
map (c: "client '${c.id}'") (lib.filter (c: c.bearerAuthz && c.kind != "machine") cfg.oidc.clients)
|
||
)
|
||
+ " sets bearerAuthz with kind != \"machine\". The scope is only "
|
||
+ "emitted for machine clients, so this grants nothing while "
|
||
+ "evaluating and deploying cleanly. A browser client has a user "
|
||
+ "to authorise and does not need it.";
|
||
}
|
||
];
|
||
|
||
# Authelia's own gateway surface: the vhost that fronts it and the
|
||
# name the hive resolver answers for. Both live here rather than in
|
||
# the gateway, and both are inside `cfg.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 ];
|
||
|
||
# The bridge as well as authelia: it is the half that writes the identity
|
||
# store, and its refusals are returned to callers as a bare 401.
|
||
#
|
||
# `unitName`, never a literal: upstream derives the unit from the instance
|
||
# name, so the running unit is `authelia-<instance>` and a hardcoded
|
||
# "authelia" matches nothing. Nothing reports that — the receiver's `units`
|
||
# is an allowlist, so a name that matches no journal entry is silently
|
||
# absent and reads exactly like a service with nothing to say.
|
||
services.hyperhive.swarm.otel.journaldUnits = [
|
||
unitName
|
||
"swarm-authelia-bridge"
|
||
];
|
||
|
||
# Declared here rather than in the collector's module, per the option's
|
||
# own rule: an entry exists only where the service that named it runs.
|
||
#
|
||
# Gated on the collector's `enable` as well, and that second condition is
|
||
# what makes the loopback address honest. Both services default from
|
||
# `swarm.enableRequiredServices` — but `mkDefault` is an invitation to
|
||
# override, not a guarantee, so "they are on the same host" is a property
|
||
# of the auto-deployed topology rather than of the module. Without this
|
||
# gate, a host running authelia and no collector would declare a target
|
||
# nothing can read, and the absence would be silent: no error, no metrics,
|
||
# nothing to notice.
|
||
#
|
||
# It does not make authelia scrapeable from ANOTHER host — that needs the
|
||
# endpoint published under a name with a cert and an audience, which is a
|
||
# different piece of work. This only stops the config asserting a
|
||
# collection that is not happening.
|
||
# ⚠️ `swarm.otel` and not `hyperhive.otel` — two different collectors one
|
||
# word apart. This one is the swarm's; `hyperhive.otel` is the per-hive
|
||
# tier that ships telemetry upstream and never reads `scrapeTargets`.
|
||
# Written out in full rather than through a `let` binding so the gate and
|
||
# the option it gates are visibly the same path: gating the wrong one is
|
||
# not a build error, it is a target that is always declared or never is.
|
||
services.hyperhive.swarm.otel.scrapeTargets = lib.mkIf config.services.hyperhive.swarm.otel.enable {
|
||
authelia = "127.0.0.1:${toString cfg.metricsPort}";
|
||
};
|
||
|
||
# This swarm-ui quick-links entry, same guard as the vhost/DNS name
|
||
# above (only the host actually running the container claims it —
|
||
# see `services.hyperhive.swarm.controller.links`'s description for
|
||
# the contribute-your-own-entry idiom).
|
||
services.hyperhive.swarm.controller.links = [
|
||
{
|
||
label = "Authelia";
|
||
icon = "🔑";
|
||
url = "https://${cfg.domain}/";
|
||
}
|
||
];
|
||
|
||
# `server_name = authelia.domain`, all of `/` → authelia.
|
||
#
|
||
# ⚠️ The server name must be exactly `cfg.domain`, not a near-miss:
|
||
# authelia validates `authelia_url ⊂ session cookie domain` at
|
||
# STARTUP, so a mismatch is a container that refuses to boot rather
|
||
# than a login that misbehaves.
|
||
#
|
||
# ⚠️ And deliberately NO `dashboardAuth` here. That block is the
|
||
# gateway's `auth_basic`; applying it to the SSO provider would put
|
||
# the login page behind the login mechanism it exists to replace.
|
||
services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
|
||
listen = gatewayCfg.lib.listen;
|
||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||
locations."/" = {
|
||
proxyPass = "http://127.0.0.1:${toString cfg.port}/";
|
||
proxyWebsockets = true;
|
||
extraConfig = ''
|
||
proxy_buffering off;
|
||
# authelia decides by the ORIGINAL request, not by the hop it
|
||
# sees — the login redirect and the session cookie's domain
|
||
# both derive from these. Without them every request looks
|
||
# like it arrived at 127.0.0.1 over plain http.
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
proxy_set_header X-Forwarded-Host $host;
|
||
proxy_set_header X-Forwarded-Uri $request_uri;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
# A dead upstream here means "not bootstrapped" far more often
|
||
# than "misconfigured proxy", and a bare 502 says the opposite.
|
||
proxy_intercept_errors on;
|
||
error_page 502 503 504 = /__hive_sso_unavailable;
|
||
'';
|
||
};
|
||
locations."= /__hive_sso_unavailable" = {
|
||
extraConfig = ''
|
||
internal;
|
||
alias ${gatewayCfg.lib.errorPages.ssoUnavailable};
|
||
default_type text/html;
|
||
'';
|
||
};
|
||
};
|
||
|
||
# Order the container after the host CA generator, so the bind source
|
||
# exists before nspawn sets the mount up.
|
||
systemd.services."container@${cfg.machine}" = caTrust.containerOrdering;
|
||
|
||
containers.${cfg.machine} = {
|
||
autoStart = true;
|
||
ephemeral = false;
|
||
# Shared host netns, like the forge and matrix containers: the
|
||
# gateway reaches authelia at 127.0.0.1:<port>.
|
||
privateNetwork = false;
|
||
# 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 = [
|
||
(import ./swarm-container-resolver.nix {
|
||
inherit (networkCfg) bridgeIp;
|
||
dnsConsumers = [ "authelia-${instance}.service" ];
|
||
})
|
||
caBundleModule
|
||
];
|
||
|
||
system.stateVersion = "26.05";
|
||
|
||
# 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 = [ cfg.package ];
|
||
|
||
# This container shares the host netns, so its own
|
||
# firewall.service would rewrite the HOST ruleset at every
|
||
# boot. The host firewall owns all filtering.
|
||
networking.firewall.enable = false;
|
||
# resolvconf stays off because the resolver unit imported above
|
||
# owns /etc/resolv.conf. Leaving it on would let host-tracking
|
||
# regenerate the file empty, since the host's copy doesn't
|
||
# cross the boundary after start.
|
||
networking.resolvconf.enable = lib.mkForce false;
|
||
|
||
# authelia's own secrets, generated in-container on first
|
||
# boot. They are jwt/session/storage keys — nothing outside
|
||
# this container ever reads them, which is what makes
|
||
# in-container generation right rather than merely easier.
|
||
# (hive-matrix generates its token host-side only because
|
||
# hive-c0re has to read that one.)
|
||
#
|
||
# Same `User`/`Group`/`StateDirectory` as the authelia unit,
|
||
# so systemd creates the directory owned by the account that
|
||
# has to read the files and this unit can write nowhere else.
|
||
# No chown, no mode juggling: authelia opens these paths
|
||
# itself, as its own user, under `PrivateUsers=true`.
|
||
systemd.services."${unitName}-secrets" = {
|
||
description = "Generate authelia's secrets on first boot";
|
||
wantedBy = [ "multi-user.target" ];
|
||
before = [ "${unitName}.service" ];
|
||
requiredBy = [ "${unitName}.service" ];
|
||
# `cfg.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
|
||
cfg.package
|
||
];
|
||
serviceConfig = {
|
||
Type = "oneshot";
|
||
RemainAfterExit = true;
|
||
User = unitName;
|
||
Group = unitName;
|
||
StateDirectory = unitName;
|
||
StateDirectoryMode = "0700";
|
||
UMask = "0077";
|
||
SyslogIdentifier = "${unitName}-secrets";
|
||
};
|
||
script = ''
|
||
set -euo pipefail
|
||
|
||
# Each is generated once and never rotated here: the
|
||
# session and storage keys are load-bearing for data
|
||
# already written (sessions, the encrypted store), so
|
||
# replacing one is an operator action, not a boot action.
|
||
for f in ${lib.concatStringsSep " " randomKeys}; do
|
||
p=${lib.escapeShellArg stateDir}/"$f".key
|
||
if [ ! -s "$p" ]; then
|
||
head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$p"
|
||
echo "generated $p"
|
||
fi
|
||
chmod 0600 "$p"
|
||
done
|
||
${oidcGenScript}
|
||
|
||
# A users database that exists and parses, with nobody in
|
||
# it. authelia refuses to start without one, and the
|
||
# alternative to an empty file is a placeholder account —
|
||
# which is a credential nobody meant to create.
|
||
users=${lib.escapeShellArg cfg.usersFile}
|
||
if [ ! -s "$users" ]; then
|
||
echo "users: {}" > "$users"
|
||
echo "seeded empty users database at $users"
|
||
fi
|
||
chmod 0600 "$users"
|
||
'';
|
||
};
|
||
|
||
# The only process allowed to write `cfg.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 = "${cfg.bridgePackage}/bin/swarm-authelia-bridge";
|
||
User = unitName;
|
||
Group = unitName;
|
||
Restart = "on-failure";
|
||
RestartSec = "5s";
|
||
};
|
||
environment = {
|
||
SWARM_AUTHELIA_BRIDGE_BIND = "127.0.0.1:${toString cfg.bridgePort}";
|
||
# ONE file, and this is it. The bridge used to carry a
|
||
# second, private `…-users.json` it treated as canonical
|
||
# while writing `users.yml` as a rendering of it — two
|
||
# canonical stores for one physical file, which is what
|
||
# made `swarm agent create` refuse to start on a hive whose
|
||
# `users.yml` already held users.
|
||
SWARM_AUTHELIA_BRIDGE_USERS_FILE = cfg.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 = "${cfg.package}/bin/authelia";
|
||
# By name through the gateway, not loopback. A loopback
|
||
# literal encodes "authelia is in my netns" at the call site,
|
||
# and authelia's OIDC endpoints are https-only in effect —
|
||
# reached directly they answer 400, because the forwarded
|
||
# headers nginx injects for every other consumer are what let
|
||
# it determine its own issuer. Same URL `swarm-nats-auth`
|
||
# uses, so there is one idiom rather than two.
|
||
SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL = "${cfg.url}/api/oidc/introspection";
|
||
SWARM_AUTHELIA_BRIDGE_CLIENT_ID = bridgeClientId;
|
||
# Minted by `${unitName}-secrets`'s `mint` loop (it iterates
|
||
# every entry in `cfg.oidc.clients`, which now always
|
||
# includes `bridgeClient`) — same file this container's own
|
||
# `renderClient` reads the digest half of.
|
||
SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE = "${clientsDir}/${bridgeClientId}.secret";
|
||
};
|
||
};
|
||
|
||
services.authelia.instances.${instance} = {
|
||
enable = true;
|
||
package = cfg.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.
|
||
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 = cfg.usersFile;
|
||
watch = true;
|
||
};
|
||
|
||
# ⚠️ `one_factor` as the DEFAULT means "any authenticated
|
||
# user", which is authentication, not authorisation. The
|
||
# swarm UI is operator-only, and agents are getting
|
||
# authelia accounts of their own — so the day that lands,
|
||
# a session alone would be enough to open it. The rule
|
||
# below is what makes the distinction real; without it
|
||
# the vhost's `auth_request` is a check nobody fails.
|
||
#
|
||
# The group is a constant rather than an option: it is the
|
||
# value an operator types into `swarmctl user add --group`,
|
||
# and a configurable name is one more way for the rule and
|
||
# the account to disagree silently.
|
||
access_control = {
|
||
default_policy = "one_factor";
|
||
# ⚠️ ORDER MATTERS — authelia takes the FIRST matching rule.
|
||
# The metrics rule is listed first so it cannot be shadowed
|
||
# by a broader domain rule added later.
|
||
rules =
|
||
# Denied or client-scoped depending on whether a
|
||
# collector is registered — see `metricsRule` above,
|
||
# which is where the reasoning for both halves lives.
|
||
lib.optional forgeCfg.behindGateway metricsRule
|
||
++ lib.optional uiCfg.enable {
|
||
domain = uiCfg.domain;
|
||
subject = [ "group:${operatorGroup}" ];
|
||
policy = "one_factor";
|
||
};
|
||
};
|
||
|
||
# The cookie domain is the SWARM's domain, NOT authelia's
|
||
# own host: the session cookie has to be sent to the apps
|
||
# being protected (`forge.<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.
|
||
#
|
||
# ⚠️ Known and accepted consequence while a hive keeps a
|
||
# domain outside the swarm's tree: this cookie is NOT sent
|
||
# to that hive's own surfaces (its dashboard), so SSO
|
||
# covers the swarm's services and not the hive's. It
|
||
# resolves when the hive domain moves under the swarm
|
||
# domain; until then it is a scope limit, not a bug to
|
||
# chase.
|
||
session.cookies = [
|
||
{
|
||
domain = cookieDomain;
|
||
authelia_url = "https://${cfg.domain}";
|
||
}
|
||
];
|
||
|
||
storage.local.path = "${stateDir}/db.sqlite3";
|
||
notifier.filesystem.filename = "${stateDir}/notification.txt";
|
||
};
|
||
};
|
||
};
|
||
};
|
||
};
|
||
}
|