authelia added

This commit is contained in:
2026-10-08 15:30:05 +03:00
parent 75433e2af7
commit 3c9c5100a8
6 changed files with 312 additions and 46 deletions
+214
View File
@@ -0,0 +1,214 @@
{
config,
lib,
pkgs,
...
}:
# Authelia — SSO reverse-proxy (single-factor password login) for protected
# vhosts. Uses nixpkgs' services.authelia module (a native systemd unit with
# hard sandboxing) instead of a podman container — Authelia is a Go binary,
# not a foreign distro, so a container adds nothing but surface area.
#
# Wiring:
# - The internal API listens on 127.0.0.1:9091 only (overrides the nixpkgs
# default `tcp://:9091/` which would bind all interfaces).
# - Three secrets (jwt, storage encryption key, users_database) come from
# modules/server/secrets/authelia.yaml via sops-nix, materialised at
# /run/secrets/<name> by the time authelia.service starts.
# - nginx is the only ingress: authelia.zeroq.su vhosts the login UI and
# every protected vhost (currently vtimeline.zeroq.su) does
# `auth_request /authelia` against 127.0.0.1:9091 (see nginx.nix).
# - users_database.yml is symlinked into /var/lib/authelia/ so the path
# configured in `settings.authentication_backend.file.path` resolves
# to the sops materialised file. The symlink target is created by
# sops-nix before this unit starts, so no race.
#
# Version: pinned transitively via flake inputs.nixpkgs → pkgs.authelia.
# `nix flake update` will roll it forward; no overlay needed.
#
# Secrets layout in modules/server/secrets/authelia.yaml (sops-encrypted):
# jwt_secret -> /run/secrets/authelia-jwt-secret
# storage_encryption_key -> /run/secrets/authelia-storage-encryption-key
# users_database -> /run/secrets/authelia-users-database
# (multiline YAML string, written verbatim by
# sops-nix and consumed as a settingsFile)
#
# Guarded by `builtins.pathExists` so a missing sops file does NOT break
# `nixos-rebuild switch` — the flake evaluates, Authelia stays disabled
# until the secret file is created and encrypted.
let
cfg = config.host.authelia;
sopsReady = builtins.pathExists ./secrets/authelia.yaml;
# sops-nix materialises each `sops.secrets.<attr-name>` at
# /run/secrets/<attr-name> by default. Hardcoding the path here keeps
# the module independent of how the secret attr is named; rename only
# the sops block below if a different path is needed.
sopsPath = name: "/run/secrets/${name}";
in
{
options.host.authelia = {
enable = lib.mkEnableOption ''
Authelia SSO reverse-proxy. When enabled, exposes the internal API on
127.0.0.1:9091 (loopback only — nginx is the only ingress). Activating
this option requires modules/server/secrets/authelia.yaml to exist
and decrypt successfully; otherwise the toplevel build fails on a
missing sopsFile.
'';
cookieDomain = lib.mkOption {
type = lib.types.str;
default = "zeroq.su";
description = ''
Domain scope for Authelia session cookies and the login-UI vhost
(`authelia.<cookieDomain>`). All protected vhosts must be subdomains
of this value for the session cookie to flow through nginx's
auth_request handshake.
'';
};
autheliaFqdn = lib.mkOption {
type = lib.types.str;
default = "authelia.${cfg.cookieDomain}";
description = ''
Public FQDN where the Authelia login UI is served by nginx. Set this
to override the default (authelia.<cookieDomain>) when a CNAME or a
different deployment shape requires it.
'';
};
};
config = lib.mkIf cfg.enable {
services.authelia.instances."" = {
enable = true;
# Default is already pkgs.authelia; pinned here for clarity and to
# give an obvious handle for future overrides (e.g. an overlay to
# hold a specific upstream version during CVE windows).
package = pkgs.authelia;
secrets = lib.mkIf sopsReady {
jwtSecretFile = sopsPath "authelia-jwt-secret";
storageEncryptionKeyFile = sopsPath "authelia-storage-encryption-key";
};
settings = {
# Override the nixpkgs default (`tcp://:9091/`) — binding all
# interfaces would expose the API to the LAN. nginx is the only
# allowed ingress, talking to 127.0.0.1:9091.
server.address = "tcp://127.0.0.1:9091";
log = {
level = "info";
format = "text";
};
authentication_backend.file = {
# Read directly from the sops materialised file at /run/secrets/.
# Authelia does NOT validate-config this path — it only opens it
# when verifying a user password (lazy read). Putting the same
# file into settingsFiles would force viper to parse it as
# configuration, and the `users:` top-level key would fail the
# schema check (users.* is schema-foreign).
path = sopsPath "authelia-users-database";
password = {
algorithm = "argon2id";
iterations = 3;
salt_length = 16;
parallelism = 4;
memory = 65536;
};
};
storage.local.path = "/var/lib/authelia/db.sqlite3";
session = {
expiration = "1h";
inactivity = "5m";
cookies = [
{
domain = cfg.cookieDomain;
authelia_url = "https://${cfg.autheliaFqdn}/";
# NOTE: Authelia 4.x has no per-cookie `secure` knob;
# `Set-Cookie`'s Secure flag is auto-determined from the
# inbound request's effective scheme at runtime (it does
# trust X-Forwarded-Proto when it sees it). The HTTP
# loopback binding (server.address = 127.0.0.1:9091)
# means this only works because nginx sets
# X-Forwarded-Proto $scheme, both via
# recommendedProxySettings and explicitly on the
# /authelia subrequest in nginx.nix. Don't be tempted
# to re-add `secure: "always"` here — validate-config
# rejects it as an unknown key.
}
];
};
access_control = {
default_policy = "deny";
rules = [
{
# Wildcard against every *.zeroq.su vhost that adds an
# `auth_request /authelia` to its nginx config (currently
# vtimeline). A bare `domain: "*"` is schema-invalid in
# Authelia and silently falls through to default_policy,
# which is why the first attempt landed on 403 with no
# redirect. Adding a new protected vhost under this domain
# requires no change here — the wildcard does the work.
domain = "*.${cfg.cookieDomain}";
policy = "one_factor";
}
];
};
notifier = {
disable_startup_check = true;
filesystem.filename = "/var/lib/authelia/notifier.txt";
};
};
# No `settingsFiles` — the users_database file is read directly
# via `settings.authentication_backend.file.path` above. Adding
# it here would put `users:` under viper's config schema check
# (validate-config), which rejects it as an unknown top-level key.
};
# Wait for sops-nix to materialise the secrets before Authelia starts.
# Without this, authelia can race ahead of sops and read an empty
# /run/secrets on the very first boot after a switch. Existing boots
# (when /run/secrets is already populated) skip the wait instantly.
# `sops-nix.service` is the systemd service that the sops-nix module
# creates to deploy credentials; depending on its name avoids the
# "race between tmpfiles-setup and the sops materialiser" that the
# previous tmpfiles-symlink design implicitly relied on.
systemd.services.authelia.after = [ "sops-nix.service" ];
systemd.services.authelia.wants = [ "sops-nix.service" ];
# sops wiring. Guarded by builtins.pathExists so the flake still
# evaluates when ./secrets/authelia.yaml hasn't been created yet —
# a clean checkout would otherwise fail every nixos-rebuild switch.
# Once the file exists and is encrypted, this condition becomes true
# and the three secrets are wired in.
sops.secrets = lib.optionalAttrs sopsReady {
"authelia-jwt-secret" = {
format = "yaml";
key = "jwt_secret";
sopsFile = ./secrets/authelia.yaml;
owner = "authelia";
group = "authelia";
mode = "0400";
};
"authelia-storage-encryption-key" = {
format = "yaml";
key = "storage_encryption_key";
sopsFile = ./secrets/authelia.yaml;
owner = "authelia";
group = "authelia";
mode = "0400";
};
# users_database is a multiline YAML string in the sops file
# (top-level `users_database: |` block with `users: <name>: ...`
# beneath). sops-nix writes the decoded block verbatim to
# /run/secrets/authelia-users-database, where the symlink rule
# above makes it appear at /var/lib/authelia/users_database.yml.
"authelia-users-database" = {
format = "yaml";
key = "users_database";
sopsFile = ./secrets/authelia.yaml;
owner = "authelia";
group = "authelia";
mode = "0400";
};
};
};
}
+1
View File
@@ -10,6 +10,7 @@
zeroq.su:53 {
hosts {
109.248.161.5 x.zeroq.su
192.168.1.20 authelia.zeroq.su
192.168.1.20 calibre.zeroq.su
192.168.1.20 dns.zeroq.su
192.168.1.20 flux.zeroq.su
+8
View File
@@ -10,6 +10,7 @@
../containers/tape-rotation.nix
../pkgs/beets.nix
./acme.nix
./authelia.nix
./bentopdf.nix
./builder.nix
./calibre-web.nix
@@ -52,6 +53,13 @@
# case 3x-ui is later reconfigured to terminate TLS itself (e.g. for
# direct node-API access); nginx doesn't have to use it.
host."3x-ui".certDomain = "x.zeroq.su";
# Authelia SSO — currently protects vtimeline.zeroq.su (replaces the
# previous nginx auth_basic htpasswd). Cookie domain is .zeroq.su so a
# single Authelia session covers every protected vhost under the zone.
host.authelia = {
enable = true;
cookieDomain = "zeroq.su";
};
systemd.tmpfiles.rules = [
(xlib.helpers.mkTmpfile "d" "/mnt" "0755" "root" "root")
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root")
+68 -19
View File
@@ -123,21 +123,78 @@ in
forceSSL = true;
enableACME = true;
};
# vtimeline.zeroq.su — static site behind HTTP basic auth.
# vtimeline.zeroq.su — static site behind Authelia forward-auth.
# Files live under /home/oqyude/External/Git/VeeamTimelineView/public_html,
# which is bind-mounted to /var/lib/vtimeline (see systemd.mounts below)
# because /home/oqyude is mode 700 and the nginx user (uid 60) cannot
# traverse it. Credentials are pulled from sops; see the sops.secrets
# block at the bottom of this file.
# traverse it. Authentication is delegated to Authelia via
# auth_request: nginx sub-requests /authelia on every hit, Authelia
# returns 2xx if the session cookie is valid or 401 (which nginx
# converts into a 401 to the client; Authelia's response headers
# carry the redirect target). The login UI itself is served by the
# authelia.zeroq.su vhost below — same Authelia container, different
# vhost.
"vtimeline.zeroq.su" = {
forceSSL = true;
enableACME = true;
root = "/var/lib/vtimeline";
locations = {
"/" = {
extraConfig = ''
auth_basic "vtimeline";
auth_basic_user_file ${config.sops.secrets.vtimeline-htpasswd.path};
auth_request /authelia;
auth_request_set $authelia_user $upstream_http_remote_user;
# Authelia for an anonymous user on a `one_factor`-protected
# vhost returns 302 + Location to the login UI by default
# (because nginx forwards Accept: text/html). nginx's
# auth_request only treats 2xx/4xx as pass/deny, so a bare
# 302 surfaces to the client as 500 ("auth request
# unexpected status"). Converting 401 → 302 to the
# login page handles that case. Authelia returns 401 only
# when the subrequest advertises Accept: application/json
# (see the /authelia block below).
# `$request_uri` is the URI path only — Authelia would
# then resolve `rd` as relative to its own `authelia_url`
# and send the user back to `authelia.zeroq.su/<path>`,
# not `vtimeline.zeroq.su/<path>`, after a successful
# login. Pass the full origin (scheme + host + path) so
# Authelia constructs an absolute redirect back to the
# original vhost.
error_page 401 =302 https://authelia.zeroq.su/?rd=$scheme://$host$request_uri;
'';
};
"= /authelia" = {
extraConfig = ''
internal;
proxy_pass http://127.0.0.1:9091/api/authz/forward-auth;
proxy_set_header X-Original-URL $request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Method $request_method;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-For $remote_addr;
# Force Authelia to respond with 401 (not 302 + Location) so
# the error_page 401 =302 rule above can take over. With the
# default Accept: text/html Authelia sends a 302 with an
# absolute Location, which auth_request surfaces to the client
# as 500 ("unexpected status").
proxy_set_header Accept "application/json";
'';
};
};
};
# Authelia login UI — same podman container on 127.0.0.1:9091 as the
# forward-auth endpoint above, just exposed on a separate vhost so
# Authelia has a stable absolute URL to redirect users to. Authelia
# generates internal links against $session.cookies[0].authelia_url,
# which is set to https://${autheliaFqdn}/ in modules/server/authelia.nix.
"authelia.zeroq.su" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://127.0.0.1:9091";
proxyWebsockets = true;
};
};
"pdf.private" = {
forceSSL = false;
enableACME = false;
@@ -281,18 +338,10 @@ in
(xlib.helpers.mkTmpfile "d" "/var/lib/vtimeline" "0755" "nginx" "nginx")
];
# htpasswd file for vtimeline.zeroq.su basic auth.
# Source layout (per modules/server/secrets/vtimeline-htpasswd.yaml):
# passwords: |
# <user>:<bcrypt-or-apr1-hash>
# sops-nix extracts the `passwords` key as the only decrypted content.
# The resulting file is consumed by nginx via auth_basic_user_file.
sops.secrets.vtimeline-htpasswd = {
format = "yaml";
key = "passwords";
sopsFile = ./secrets/vtimeline-htpasswd.yaml;
owner = "nginx";
group = "nginx";
mode = "0640";
};
# Note: the previous vtimeline-htpasswd sops declaration lived here. It
# was removed when authelia replaced nginx's auth_basic (see the vtimeline
# vhost above). The encrypted file modules/server/secrets/vtimeline-htpasswd.yaml
# itself was kept untouched per the repo policy of not modifying secrets
# without explicit owner sign-off; delete it with `sops --version` and
# `rm` once the cutover is verified.
}
+19
View File
@@ -0,0 +1,19 @@
jwt_secret: ENC[AES256_GCM,data:Sq3SR3GF2PKU/EmtosEIgkVBGx0ycQfYBy3SmNB46bflTX3HvjY7gXSXt13khLRNjYwqnLQ/VWnhSF7QmjO+QA==,iv:L1c/Tb1nb1JNY9FdU7SoZih9CdRO0sDErA+OBmCe1nY=,tag:aYS8NoMW1OqVT/q60nVU6A==,type:str]
session_secret: ENC[AES256_GCM,data:gJnvSc8IvfJEemptMvq72Tiv6O19E63fSpzPtzKy4u1a9HdwUGJADz9nzQbLTqk5lP4OSQnKEgZyiDvCpFNE6A==,iv:oWG/ht5McEGqYXdls4BsDSLMF4G8lX957urZL3vlyxY=,tag:SoY4DvzdPrVftKOQQcgmfQ==,type:str]
storage_encryption_key: ENC[AES256_GCM,data:1uoto0pqv1ahIxc00XzY+X0I0Eb3po/FPWOsmyFlcOhtpzK3od0+4a2jL8PicqbIf6F0hNp1gYUc11ofO7Mj1g==,iv:IFw4Y/cL3Hqa0fIPeKhN3SdrlOLFFJiJLe1MTNue+gk=,tag:eSAFNxpkaYWkyFVoWzSuMA==,type:str]
users_database: ENC[AES256_GCM,data:KCbWYaXNk33ybfYrE7oJPREBLSQDlQfdzxzbCqYTX4ZTjb3R3yRPzRCqLzjy3UP165q5MjOcsXmluJ4U0Apd8+sKJj5+XkEL3GMIhxExB2JpVjriyObX4iayuoUlYXD7JVTBVVIZIfXWH5ySTbLw35sN0LmbBaRPSE5YNliOtUe91W82nbifP3qYvOaYFYFe5MaBLc2XpaWJcv+Gio3iQ5X5mYhTgwNMlCZK5/KqlS3oNQswkuvSuzWNG3+/ev+Byt8KY6ec/bnM74ebTs7obJK3,iv:mEFmiDdzw+s7jusN4GggZ7FO5C2IUUKGAJ8PIBMC/KY=,tag:MwlfPGs3egq0B4RxfAQ+Jw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhYjh3WjVTN2JXdjJXYXY3
UnhveDN2WkxsRThQWVhpTlBHL0FiSEF4dVZRCnRrQlZiM3hxU0FzUHM3YVpkOXpF
alowVnpENWUzdzRYQ1R5cW5CVDVEcTQKLS0tIDU1MFJCQUFIdUoyWHBQSkwweUMv
SFhPQmFOdmY5QUpLVUdpUGRWL25aTE0K5BL3mIZI6Wl4TZIKQPUJ7PxvrNe+1t+S
ra8EJtInXy4HeNWye9sdR3BHD7QWYT46RAHBSdn+4SxtV6LOHiXBgg==
-----END AGE ENCRYPTED FILE-----
recipient: age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm
lastmodified: "2026-10-08T00:38:38Z"
mac: ENC[AES256_GCM,data:EERhYt8a9ElV9RvcvqcM7lxLE9lwx0juW9RQgZD1rNaLs8wx+/1hTt7HmVRyiuFwMOmmL3lrwxC7cBV7GUkF6hbnWz+tuDO1EXBq3ooN/KIikFnjia6A95TinZzRKYmv3K/CdISGu5yGpZ9bVSqaq0YdB0MB32e6Q0iEXVxda4o=,iv:UZxgFjGs6FqavbTk4XYAs1MmCLgV3JktOrmVcy/nM3Q=,tag:EP7z+S1vyRSn8vchc0tgRw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3
@@ -1,25 +0,0 @@
#ENC[AES256_GCM,data:UW49BNUTjSgrBCXW4f5/7lJPUqXZp1U1iFHEvR4QOGm9KWPwAqYTg+i4I2dWeMTP4PqkFA8ry+TtFUHV+UTyEJxMbTZPSaqXJmQAh7k07Trv17snVEtvotzTHn5yjZwAVvPi,iv:/H/FXmF0n86xlE4wA/oBioEYJkc14+mLzSL61qJk1z8=,tag:kbCFXytipQ+FTP1OiHfO8w==,type:comment]
#ENC[AES256_GCM,data:OWEZavcPrsSst1YUaspDqXeYx1HTLsw2zT9j2ao8mmxrgjgVJ2teclWQT6R8D9OxMwVh/Cbd25XtwwsgWpwMzQChSAD4oFPofvujpFHhndwuuyA12kA/rGRp6oLF5ZVavFR/4oTjm6xDE6AlwgKsT64=,iv:15ZKD0tvJFbRLaX/KclDaUc7TstivGItyTxmN81HVCQ=,tag:9871kdKv+GhH1/Gyy/oYPg==,type:comment]
#
#ENC[AES256_GCM,data:6P4BRz2PQ76929PaDFp9KHXKgVx9C6FncoQBx7ia/GfeR86O/ZCtf9k=,iv:SNSpMmo4LqxHl3WE0VeW9gyJLfTwhrlLgbpHNgM/zuI=,tag:M2b8hGlAUOro8Pk79YV3mA==,type:comment]
#ENC[AES256_GCM,data:1uTKcKavRm0dgeTKk4ReXSzxd6hUfQ2NxvKbtME1kJRWeyUuS/H2DYRXYWLun6O/xXA=,iv:1Fo5dTDuJXRkfTjPvCNRLzPgVcusZXB/S5TVimqPQb4=,tag:jfdXqAfsE2DCyfRdJFS70w==,type:comment]
#ENC[AES256_GCM,data:OIyr3AEEKrU73nHK3X5vJ6+YkBCvDVBnXYiEkI/yutRMASnP6ZBc1DmLxV+bWKS7d1aJxA2k3S+/IKN9UhwSa6AfR8iuvLSFnkMZlgOyulTb+I1Qdg==,iv:ZjLfBkAjJT99k3c1qtRglQuwoA8eVGtoHjJJNtHsqpQ=,tag:RYdq29YgqoFzzEwU0UQ5Vw==,type:comment]
#
#ENC[AES256_GCM,data:WGLSnMuM1Kj6V/bUSZKQVdu3M3SmR7ADrQK0WuGufRmg1Z2q/I8eeP3mKFkk9EobYV7fHab34B7CCDMtQemcvV92lF4+e59ggfxP53nrVsLh8ZWIxJycneY=,iv:UUQZkq+WP8muG0XUN1TJ4fz6Hen54JO+4of/YiFamEE=,tag:Zac0l31mULvA/2p2GJBfGg==,type:comment]
#ENC[AES256_GCM,data:sXr+q5pUauY1atCv5H0X1C53b8pnO0yKwb6EbizJkTqmoajaSu/rp4vtfZ1eWOffyNwrUZoGsB4kauT75H/kpEJ3V+g0Hizu7JozxLji9hUdugfbQKFFqbD/cm+ctj70GpY=,iv:YclhEQ+sX6ae+y8KVgut53oQlVCKAm9T8J3xMM9r174=,tag:/tZucqVbVLPeCi4re3x4ZQ==,type:comment]
passwords: ENC[AES256_GCM,data:s49DRPQO5DcFeZQGwBQ01Eh7mgSVCrPl2u3On3msqPmm/VRBst4RigDFS6xKzPPSHbkinLMGxkOhXPuegGPzRgs=,iv:XBuodKnec/qNsPXXDKCsVgTl39EgZZvWsyRPZ6lN9m0=,tag:nO9MWneybijH9ZIeXGPQVw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDWXliaFM4RkFmZE1yM0N1
blJnTmp3Q2p2ZHM3THlyUGpQckNEcC9EZ2c0CkFxU0pUTmVHNDZXYS9STDVTamIr
M3A4VlAzdzFEYTUydGF2T01DNFkxT0EKLS0tIDlSS2s1YjF4TmkveHd4LzBRbTI4
anhpeUZ1VUFXYWVObTU2YVpCaTFXN00KwMHeXtaKxMpdLPRANabj+Vpxx5WLsyPW
T9npuQcI52YaXuNpUy+MtWNASwSXvmA7nl4KJNLCWAhgGKrd48Hwxw==
-----END AGE ENCRYPTED FILE-----
recipient: age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm
lastmodified: "2026-10-07T14:31:12Z"
mac: ENC[AES256_GCM,data:nu44CjCVES+B+UI+6xwT3fCEN958FuKH7eHUTr+XEoHEGfh2Nx+5G5VciJD1TcsQ9yb0C1uWEGkCH8wrjcUEEDNe9MPVGqsREV7fpmO9Cr0wrW5Ji8gnbTGTjI7GswoYG3jUtMzdktBJnX8g6vit2VE7s9l+mE2S3YH3RXyHBDE=,iv:iff4ebSxNFumw1b82FEd0IdWBHGMOowG3LTQui7wTyg=,tag:TGhNeml/F9vtMrJm7d6MJA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3