diff --git a/modules/server/authelia.nix b/modules/server/authelia.nix new file mode 100644 index 0000000..8a6e0e8 --- /dev/null +++ b/modules/server/authelia.nix @@ -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/ 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.` at + # /run/secrets/ 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.`). 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.) 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: : ...` + # 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"; + }; + }; + }; +} \ No newline at end of file diff --git a/modules/server/coredns.nix b/modules/server/coredns.nix index a6b6646..db950ce 100644 --- a/modules/server/coredns.nix +++ b/modules/server/coredns.nix @@ -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 diff --git a/modules/server/default.nix b/modules/server/default.nix index 67a65e7..4d712dc 100644 --- a/modules/server/default.nix +++ b/modules/server/default.nix @@ -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") diff --git a/modules/server/nginx.nix b/modules/server/nginx.nix index a17b88f..0f14ffe 100644 --- a/modules/server/nginx.nix +++ b/modules/server/nginx.nix @@ -123,20 +123,77 @@ 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"; - extraConfig = '' - auth_basic "vtimeline"; - auth_basic_user_file ${config.sops.secrets.vtimeline-htpasswd.path}; - ''; + locations = { + "/" = { + extraConfig = '' + 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/`, + # not `vtimeline.zeroq.su/`, 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; @@ -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: | - # : - # 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. } diff --git a/modules/server/secrets/authelia.yaml b/modules/server/secrets/authelia.yaml new file mode 100644 index 0000000..27967c6 --- /dev/null +++ b/modules/server/secrets/authelia.yaml @@ -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 diff --git a/modules/server/secrets/vtimeline-htpasswd.yaml b/modules/server/secrets/vtimeline-htpasswd.yaml deleted file mode 100644 index 5ebd2c0..0000000 --- a/modules/server/secrets/vtimeline-htpasswd.yaml +++ /dev/null @@ -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