Moves the authelia vhost out of the gateway's vhosts.nix and its `address=` rule out of dnsmasq.nix, into swarm-authelia.nix. Both land inside that module's existing `cfg.enable` guard, which 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 the 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. The kit grows a fourth member, `errorPages`, because the vhost aims its 502/503/504 at the gateway's styled sso-unavailable page. Republished rather than imported per module: a service rendering its own would drift from the rest of the gateway the first time the theme changed.
658 lines
29 KiB
Nix
658 lines
29 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 therefore written by a
|
||
# program (swarm-controller), not maintained by hand: agents are created
|
||
# and destroyed continuously, so the subject set is *dynamic*. That is
|
||
# also why the file backend is the right one here and not a placeholder
|
||
# for LDAP: what makes a directory necessary is the size of the subject
|
||
# set, and this deployment's is bounded by one swarm.
|
||
#
|
||
# Two roles: a **session** provider always, an **OIDC** provider when
|
||
# `oidc.clients` is non-empty (derived, not flagged — authelia will not
|
||
# start with a clientless provider). 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;
|
||
hyperhiveCfg = config.services.hyperhive;
|
||
gatewayCfg = hyperhiveCfg.gateway;
|
||
hyperhiveDomain = hyperhiveCfg.domain;
|
||
swarmDomain = hyperhiveCfg.swarm.domain;
|
||
uiCfg = hyperhiveCfg.swarm.ui;
|
||
|
||
# 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";
|
||
|
||
# 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}";
|
||
|
||
# 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;
|
||
|
||
# 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. An empty
|
||
# list is the default, which makes every hive that has not opted in
|
||
# byte-identical to before.
|
||
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.
|
||
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'
|
||
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).
|
||
'';
|
||
};
|
||
|
||
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-controller, not by hand: agents come and go
|
||
continuously, so the subject set is dynamic and belongs to a
|
||
program. This module only guarantees the file *exists* and is
|
||
valid YAML at first boot, so authelia starts with no subjects
|
||
rather than failing to start — a provider with nobody in it yet
|
||
is the correct state before anything has provisioned users.
|
||
'';
|
||
};
|
||
|
||
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.
|
||
'';
|
||
};
|
||
|
||
redirectUris = lib.mkOption {
|
||
type = lib.types.listOf lib.types.str;
|
||
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.
|
||
'';
|
||
};
|
||
};
|
||
}
|
||
);
|
||
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.
|
||
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.
|
||
|
||
The distinction is load-bearing: the users database is written
|
||
from the host by a program that does not live in this container,
|
||
while authelia only ever sees the inner path. Handing the wrong
|
||
one to either side yields a file nobody reads rather than an
|
||
error.
|
||
'';
|
||
};
|
||
};
|
||
|
||
config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
|
||
# 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 ];
|
||
|
||
# `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;
|
||
'';
|
||
};
|
||
};
|
||
|
||
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;
|
||
|
||
config =
|
||
{ ... }:
|
||
{
|
||
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;
|
||
# Keep the host-copied /etc/resolv.conf intact — resolvconf's
|
||
# host-tracking would regenerate it to an empty file, since
|
||
# the host's copy doesn't cross the boundary after start.
|
||
networking.resolvconf.enable = lib.mkForce false;
|
||
|
||
# authelia's own secrets, generated in-container on first
|
||
# boot. They are jwt/session/storage keys — nothing outside
|
||
# this container ever reads them, which is what makes
|
||
# in-container generation right rather than merely easier.
|
||
# (hive-matrix generates its token host-side only because
|
||
# hive-c0re has to read that one.)
|
||
#
|
||
# Same `User`/`Group`/`StateDirectory` as the authelia unit,
|
||
# so systemd creates the directory owned by the account that
|
||
# has to read the files and this unit can write nowhere else.
|
||
# No chown, no mode juggling: authelia opens these paths
|
||
# itself, as its own user, under `PrivateUsers=true`.
|
||
systemd.services."${unitName}-secrets" = {
|
||
description = "Generate authelia's secrets on first boot";
|
||
wantedBy = [ "multi-user.target" ];
|
||
before = [ "${unitName}.service" ];
|
||
requiredBy = [ "${unitName}.service" ];
|
||
# `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"
|
||
'';
|
||
};
|
||
|
||
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}";
|
||
log.level = "info";
|
||
|
||
authentication_backend.file.path = cfg.usersFile;
|
||
|
||
# ⚠️ `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";
|
||
rules = 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";
|
||
};
|
||
};
|
||
};
|
||
};
|
||
};
|
||
}
|