From ac42f231f5b66d9aaeee1bc0019058b260b3c70d Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Mon, 20 Jul 2026 07:50:39 +0200 Subject: [PATCH] neptun: replace Zitadel with Authentik as the OIDC provider nixpkgs only carries Zitadel 2.71, which predates the login-v2 split and cannot take a v3/v4 database (its migrations are forward-only), so the instance running on the old Debian VPS could never have moved onto it. authentik-nix ships 2026.5.4 and tracks upstream closely. The authentik-nix input deliberately does not follow our nixpkgs, per upstream's warning that overriding it breaks their pinned python dependency set. That costs a second nixpkgs in the lock, so add nix-community's Cachix to common.nix -- without it the closure is ~400 local derivations (npm, rust, python). The laptop that runs scripts/deploy needs the same two lines in /etc/nix/nix.custom.conf. Authentik's own module creates the database and orders its units against postgresql.target, and recent versions need no redis, so the wiring is just the module plus a secret. Pin postgresql explicitly so that editing system.stateVersion can never silently demand a pg_upgrade of the identity store. Secret ownership is not uniform and the difference matters: authentik and caddy take a systemd EnvironmentFile, which PID 1 reads as root before dropping privileges, so root:root 0400 is correct. Headplane opens its secret paths itself while already running as the headscale user, so those three need an explicit owner or they fail to start. Also on neptun: - Pass Caddy's ACME account email through the same EnvironmentFile mechanism and reference it with the Caddyfile {$VAR} placeholder. services.caddy.email would render the address into the world-readable store. - Stop accepting MagicDNS from our own control server. headscale pushes override_local_dns, so joining the tailnet would point neptun's resolv.conf at a MagicDNS served by the tailscaled neptun itself hosts -- a tailscaled failure would then also take out DNS, ACME renewal and finally the certs for the control server every other node needs in order to recover. - Give headplane a writable DNS extra-records file. Its view of headscale's config stays read-only, which is the right outcome for a declarative box; records are data rather than config. - Require a password for sudo. Deploys become interactive, but darman's key is otherwise the only thing between the public internet and root. - Enable zram (8 GB, and disko leaves no room for a swap device), and let tailscaled-autoconnect retry instead of failing permanently when the control server isn't up yet on a first boot. networking.hosts still carries a PLACEHOLDER address for jupiter -- replace it from `headscale nodes list` once jupiter first enrols. Co-Authored-By: Claude Opus 4.8 --- README.md | 14 +- common.nix | 15 +- flake.lock | 250 +++++++++++++++++++++++++++++++- flake.nix | 6 +- hosts/neptun/configuration.nix | 41 +++++- hosts/neptun/secrets.nix | 55 +++---- secrets/neptun.yaml | 12 +- services/identity/authentik.nix | 47 ++++++ services/identity/zitadel.nix | 60 -------- services/vpn/headplane.nix | 46 ++++-- services/vpn/headscale.nix | 2 +- services/vpn/tailscale.nix | 10 ++ 12 files changed, 439 insertions(+), 119 deletions(-) create mode 100644 services/identity/authentik.nix delete mode 100644 services/identity/zitadel.nix diff --git a/README.md b/README.md index 7d9e52b..55310f5 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,22 @@ # homelab -Flake-based NixOS config. Host: `jupiter` (ZimaBlade, NAS + services). +Flake-based NixOS config. Hosts: `jupiter` (ZimaBlade, NAS + services), +`neptun` (netcup VPS: public reverse proxy, Authentik, headscale), +`mercury` (Raspberry Pi 3B+, DNS/DHCP), `terra` (desktop). ## Structure ``` flake.nix # inputs + nixosConfigurations (jupiter, neptun, kexec, ...) common.nix # shared base: user, ssh, nix, firewall, timezone -services/ # one reusable module per service - samba.nix avahi.nix audiobookshelf.nix containers.nix caddy.nix tailscale.nix +services/ # one reusable module per service, by category + media/ jellyfin, audiobookshelf, the *arrs, sabnzbd, seerr, ... + network/ caddy, samba, avahi, pihole, unbound + vpn/ tailscale, headscale (control server), headplane (its web UI) + identity/ authentik (OIDC provider, from the authentik-nix flake) + dev/ gitea + desktop/ hyprland + containers.nix # podman backend, shared across categories hosts/ jupiter/ # ZimaBlade NAS configuration.nix # host bits + imports common + the services it runs diff --git a/common.nix b/common.nix index 88593a2..2fe0c7e 100644 --- a/common.nix +++ b/common.nix @@ -11,7 +11,11 @@ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZpkPVhzi1zG5JI9hWyUgdyvNIQbp4ts4jw3idpMhhN erik@laptop" ]; }; - security.sudo.wheelNeedsPassword = false; + # Costs a password prompt on every `./scripts/deploy switch` (nixos-rebuild + # --use-remote-sudo). Worth it: darman's key is the only thing between the + # public internet and root on neptun. The password is darman_password from + # each host's sops file. + security.sudo.wheelNeedsPassword = true; # ---- SSH (key-only) ---- services.openssh = { @@ -27,6 +31,15 @@ experimental-features = [ "nix-command" "flakes" ]; # trust wheel so `nixos-rebuild --target-host darman@…` can push closures. trusted-users = [ "root" "@wheel" ]; + # authentik (services/identity/authentik.nix) comes from authentik-nix, + # which cache.nixos.org doesn't carry — without this it's ~400 local + # derivations (npm, rust, python). NOTE: the closure is built on whatever + # machine runs ./scripts/deploy, so the LAPTOP needs these two lines too, + # in /etc/nix/nix.custom.conf (Determinate Nix rewrites nix.conf). + extra-substituters = [ "https://nix-community.cachix.org" ]; + extra-trusted-public-keys = [ + "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs=" + ]; }; nix.gc = { automatic = true; diff --git a/flake.lock b/flake.lock index 0ba5a73..4b5496c 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,48 @@ { "nodes": { + "authentik-nix": { + "inputs": { + "authentik-src": "authentik-src", + "flake-compat": "flake-compat", + "flake-parts": "flake-parts", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix", + "systems": "systems", + "uv2nix": "uv2nix" + }, + "locked": { + "lastModified": 1784059115, + "narHash": "sha256-HDox7X6IKv0tgURi1DoWX9NYjY/ngTOfMvlOJsEl0oI=", + "owner": "nix-community", + "repo": "authentik-nix", + "rev": "1a0767799b4be2fc6d0dcf8b77d86f5838eafbc6", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "authentik-nix", + "type": "github" + } + }, + "authentik-src": { + "flake": false, + "locked": { + "lastModified": 1783473460, + "narHash": "sha256-pGOd9+Una59JUgOcPC3PoqOqY08GkJtY+jgtk13rJ1Y=", + "owner": "goauthentik", + "repo": "authentik", + "rev": "c2942671a5b98dfa596de7bf247accb48a5c71ee", + "type": "github" + }, + "original": { + "owner": "goauthentik", + "ref": "version/2026.5.4", + "repo": "authentik", + "type": "github" + } + }, "disko": { "inputs": { "nixpkgs": [ @@ -20,6 +63,82 @@ "type": "github" } }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1782949081, + "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": [ + "authentik-nix", + "systems" + ] + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1784350909, + "narHash": "sha256-ZWyzLbS1yKUTeFJLmdVuWNnHttL333/ldJbEE+KzCrM=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "4ce190229c73d44536caa7072f6308fb2d8feeb3", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "release-26.05", + "repo": "home-manager", + "type": "github" + } + }, "media-manager": { "flake": false, "locked": { @@ -42,9 +161,9 @@ "nixpkgs": [ "nixpkgs" ], - "pyproject-build-systems": "pyproject-build-systems", - "pyproject-nix": "pyproject-nix", - "uv2nix": "uv2nix" + "pyproject-build-systems": "pyproject-build-systems_2", + "pyproject-nix": "pyproject-nix_2", + "uv2nix": "uv2nix_2" }, "locked": { "lastModified": 1768002052, @@ -114,6 +233,37 @@ } }, "nixpkgs": { + "locked": { + "lastModified": 1783776592, + "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1782614948, + "narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs_2": { "locked": { "lastModified": 1784280462, "narHash": "sha256-DtoqIqM7VkR6NxAkcLpMwmi02USwWb3JdmNGLyhthc0=", @@ -130,6 +280,35 @@ } }, "pyproject-build-systems": { + "inputs": { + "nixpkgs": [ + "authentik-nix", + "nixpkgs" + ], + "pyproject-nix": [ + "authentik-nix", + "pyproject-nix" + ], + "uv2nix": [ + "authentik-nix", + "uv2nix" + ] + }, + "locked": { + "lastModified": 1782093830, + "narHash": "sha256-6gmEVe69+KlRkZD4PEEV5xAlB9CB0Y9TiuEgQjDrKTQ=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "430680a19bc85a3bda55f12e4cc1a1aadcf2e478", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-build-systems_2": { "inputs": { "nixpkgs": [ "mediamanager-nix", @@ -159,6 +338,27 @@ } }, "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "authentik-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1782905613, + "narHash": "sha256-SvXJcAemihifkTn4BGvyE5K1FJX9bl4U8DQ5pqKvD0s=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "7af23cfe91064865ecf2e835da28b45b3c6f49fd", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "pyproject-nix_2": { "inputs": { "nixpkgs": [ "mediamanager-nix", @@ -181,10 +381,12 @@ }, "root": { "inputs": { + "authentik-nix": "authentik-nix", "disko": "disko", + "home-manager": "home-manager", "mediamanager-nix": "mediamanager-nix", "nixos-images": "nixos-images", - "nixpkgs": "nixpkgs", + "nixpkgs": "nixpkgs_2", "sops-nix": "sops-nix" } }, @@ -208,7 +410,47 @@ "type": "github" } }, + "systems": { + "locked": { + "lastModified": 1689347949, + "narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=", + "owner": "nix-systems", + "repo": "default-linux", + "rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default-linux", + "type": "github" + } + }, "uv2nix": { + "inputs": { + "nixpkgs": [ + "authentik-nix", + "nixpkgs" + ], + "pyproject-nix": [ + "authentik-nix", + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1783511944, + "narHash": "sha256-Z/Ss9rWw9QYcRK+Qqkmty7PB1pIik5XGbrtit+ad2qs=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "83995ef5e4ece3c9c704aa645bbff439e15a0ac3", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } + }, + "uv2nix_2": { "inputs": { "nixpkgs": [ "mediamanager-nix", diff --git a/flake.nix b/flake.nix index 0872256..891904e 100644 --- a/flake.nix +++ b/flake.nix @@ -19,9 +19,13 @@ url = "github:strangeglyph/mediamanager-nix"; inputs.nixpkgs.follows = "nixpkgs"; }; + # Deliberately NOT `inputs.nixpkgs.follows` — upstream states overriding it + # breaks their pinned python dependency set. Costs a second nixpkgs in the + # lock; builds come prebuilt from nix-community's Cachix. + authentik-nix.url = "github:nix-community/authentik-nix"; }; - outputs = { self, nixpkgs, disko, sops-nix, nixos-images, mediamanager-nix, ... }@inputs: + outputs = { self, nixpkgs, disko, sops-nix, nixos-images, mediamanager-nix, authentik-nix, ... }@inputs: let system = "x86_64-linux"; in diff --git a/hosts/neptun/configuration.nix b/hosts/neptun/configuration.nix index 7618381..d89b349 100644 --- a/hosts/neptun/configuration.nix +++ b/hosts/neptun/configuration.nix @@ -9,7 +9,7 @@ ../../common.nix # shared base: user / ssh / nix / firewall ../../services/network/caddy.nix ../../services/vpn/tailscale.nix - ../../services/identity/zitadel.nix + ../../services/identity/authentik.nix ../../services/vpn/headscale.nix ../../services/vpn/headplane.nix ]; @@ -23,6 +23,11 @@ networking.hostName = "neptun"; + # 8 GB and no swap device (netcup gives one disk, disko takes all of it for + # root). authentik's server + worker + postgres are the memory-hungry part; + # zram is enough headroom at this size and costs no disk. + zramSwap.enable = true; + # ---- Static networking (netcup) ---- # No LAN fallback: get this right or the box is unreachable (use netcup's # VNC console / rescue system to fix). Values captured from the running VPS. @@ -36,12 +41,33 @@ # netcup IPv6 gateway is conventionally fe80::1 — VERIFY with `ip -6 route` # on the running VPS; wrong v6 gw won't break v4 reachability. networking.defaultGateway6 = { address = "fe80::1"; interface = "eth0"; }; - # Public resolvers for early boot; tailscale MagicDNS overrides once up. networking.nameservers = [ "9.9.9.9" "1.1.1.1" "2620:fe::fe" ]; + # ---- Don't take MagicDNS from our own control server ---- + # headscale pushes override_local_dns, so joining the tailnet would point + # neptun's resolv.conf at a MagicDNS served by the tailscaled neptun itself + # hosts. A tailscaled failure would then also kill DNS, and with it ACME + # renewal — expiring the certs for the very control server every other node + # needs to recover. Keep the public resolvers above and resolve the one + # tailnet name we proxy to from /etc/hosts instead. + services.tailscale.extraUpFlags = [ "--accept-dns=false" ]; + + # ⚠️ PLACEHOLDER IP — replace once jupiter first enrols, with its address + # from `headscale nodes list` on this box. Until then the audiobookshelf + # vhost and the gitea SSH forward below both fail to connect. + networking.hosts."100.64.0.2" = [ "jupiter.hosts.mgaction.town" ]; + # firewall (enable + 22), caddy (80/443), tailscale (trust tailscale0 + join # headscale) come from ../../common.nix and ../../services/{caddy,tailscale}.nix. + # ---- ACME account email ---- + # services.caddy.email would render the address into the world-readable + # store, so pass it via EnvironmentFile and reference it with the Caddyfile + # {$VAR} parse-time placeholder (see hosts/neptun/secrets.nix). globalConfig + # is types.lines, so this appends to the module's own global block. + services.caddy.environmentFile = config.sops.templates."caddy.env".path; + services.caddy.globalConfig = "email {$ACME_EMAIL}"; + # ---- Public reverse proxy vhosts ---- # Caddy gets automatic public HTTPS (Let's Encrypt) for real domains. # Proxies to jupiter's audiobookshelf over the tailnet (MagicDNS name). @@ -51,12 +77,13 @@ ''; # TODO: port your other VPS services' vhosts here before deploying. - # ---- Zitadel (identity/OIDC provider) ---- - # Runs locally on neptun (see services/identity/zitadel.nix); Caddy just terminates - # TLS and proxies to it. - services.zitadel.settings.ExternalDomain = "auth.mgaction.town"; + # ---- Authentik (identity/OIDC provider) ---- + # Runs locally on neptun (see services/identity/authentik.nix); Caddy just + # terminates TLS and proxies to its loopback HTTP listener. Authentik serves + # its UI and its OIDC endpoints from one port — no second frontend upstream, + # and no h2c (it's plain HTTP/1.1, unlike Zitadel's gRPC). services.caddy.virtualHosts."auth.mgaction.town".extraConfig = '' - reverse_proxy http://localhost:8080 + reverse_proxy http://127.0.0.1:9000 ''; # ---- Headscale + Headplane (tailnet control server + its web UI) ---- diff --git a/hosts/neptun/secrets.nix b/hosts/neptun/secrets.nix index c992881..f6af23e 100644 --- a/hosts/neptun/secrets.nix +++ b/hosts/neptun/secrets.nix @@ -14,34 +14,39 @@ sops.secrets.darman_password.neededForUsers = true; users.users.darman.hashedPasswordFile = config.sops.secrets.darman_password.path; - # Zitadel: masterKeyFile takes a path natively (no store leak). The admin - # bootstrap password is different — services.zitadel.steps would render it - # into a world-readable store path, so render a FirstInstance steps file - # from the secret instead and point extraStepsPaths at it (see - # services/identity/zitadel.nix and the pihole.env template on mercury for the same - # pattern). - sops.secrets.zitadel_master_key = { }; - sops.secrets.zitadel_admin_password = { }; - sops.templates."zitadel-first-instance.yaml".content = '' - FirstInstance: - Org: - Name: mgaction - Human: - UserName: admin - FirstName: Admin - LastName: Admin - Email: - Address: erik.simon.me@gmail.com - Verified: true - Password: ${config.sops.placeholder.zitadel_admin_password} - PasswordChangeRequired: false + # Authentik takes a single systemd EnvironmentFile (services/identity/authentik.nix). + # No `owner` here on purpose: systemd reads EnvironmentFile as root before + # dropping to the service's DynamicUser, so root:root 0400 is what we want. + # + # AUTHENTIK_SECRET_KEY signs sessions/tokens — rotating it logs everyone out. + # The BOOTSTRAP_* vars only take effect on the very first start, where they + # create the `akadmin` superuser; they're inert on every boot after that. + sops.secrets.authentik_secret_key = { }; + sops.secrets.authentik_bootstrap_password = { }; + sops.secrets.authentik_bootstrap_email = { }; + sops.templates."authentik.env".content = '' + AUTHENTIK_SECRET_KEY=${config.sops.placeholder.authentik_secret_key} + AUTHENTIK_BOOTSTRAP_PASSWORD=${config.sops.placeholder.authentik_bootstrap_password} + AUTHENTIK_BOOTSTRAP_EMAIL=${config.sops.placeholder.authentik_bootstrap_email} + ''; + + # Caddy's ACME account email. Same EnvironmentFile trick as authentik above, + # and root:root 0400 is likewise correct — systemd reads it before dropping + # to User=caddy. Wired up in configuration.nix. + sops.secrets.caddy_acme_email = { }; + sops.templates."caddy.env".content = '' + ACME_EMAIL=${config.sops.placeholder.caddy_acme_email} ''; # Headplane: cookie_secret_path takes a path natively (no store leak). # oidc.client_secret + the headscale API key are still REPLACE_ME - # placeholders (see services/vpn/headplane.nix) until Zitadel/headscale are + # placeholders (see services/vpn/headplane.nix) until Authentik/headscale are # actually deployed and those get created for real. - sops.secrets.headplane_cookie_secret = { }; - sops.secrets.headplane_oidc_client_secret = { }; - sops.secrets.headplane_headscale_api_key = { }; + # + # owner: unlike authentik's EnvironmentFile above, headplane opens these + # paths itself, already running as headscale's user — so root:root 0400 + # would fail and each needs an explicit owner. + sops.secrets.headplane_cookie_secret.owner = "headscale"; + sops.secrets.headplane_oidc_client_secret.owner = "headscale"; + sops.secrets.headplane_headscale_api_key.owner = "headscale"; } diff --git a/secrets/neptun.yaml b/secrets/neptun.yaml index 1f33e95..2f403c7 100644 --- a/secrets/neptun.yaml +++ b/secrets/neptun.yaml @@ -1,10 +1,12 @@ -tailscale_authkey: ENC[AES256_GCM,data:CBMidfdilBn1HP3TAu9TCOpuwPnzVQ/3OGCsf0vhFd8=,iv:V4UdZBPyzH8NkiXV40CxLdnfkCv5oG6xEQbNSmpmrmI=,tag:h8R2AIaFXgPycMIP8NyHVA==,type:str] darman_password: ENC[AES256_GCM,data:otLevSn6bXBrjCu7Dv9PN7Umwrb7CD1PhC1i+J8rp2mURMYVuwF/ZnrlPC7/RDZgINIRBOXOkG9lZo3UjuRyqrtvfUzIm1jGL51l1zvZ/07oNW6OUp4IZEQpoOmz1GuXBlx+yKRQeRDHYA==,iv:pnwp3uAsBoiJdF+QV27h0jztgFJKrIqFlmMT7JkcHoA=,tag:Zb9ziCGsLQt+TcJRCv8tWA==,type:str] -zitadel_master_key: ENC[AES256_GCM,data:LDZwbpwZdQ03O8I2tvIMYnK3MoQTgvOYwrBt2UuCHbk=,iv:sM66sj3JO+bb0Sk2D7M/Ehk7t7VZneuvTRP4IkT1se0=,tag:YKQ4shwZ4sIDWdNehTugew==,type:str] -zitadel_admin_password: ENC[AES256_GCM,data:q+Od3Juzvl/dVPkm0w1C42pjK7kU8ur8V5fl2JAdQkA=,iv:n8NFzQczJVeqe1ybBldbkv4QGj0xyf9/P+uvsHs3NjI=,tag:rkXvmC8kZZOrWRQd+mIanA==,type:str] +authentik_secret_key: ENC[AES256_GCM,data:qLrAWBywlMqT6D3FYDqE9I/5Ep+zDVu9ns9TA4UVOBSQ1uFGEj01TU9norqPf12pi9/Qs32mVzm5BqDG259DxdGT9DZVQc03QVwiIQYtmWo=,iv:OVCIxIP1Xv+nHmYsrxaPgYWQiwzVPUe8pnbyMBVAuoI=,tag:+ywkwPqkY1Dkx1R5cUJ7PA==,type:str] +authentik_bootstrap_email: ENC[AES256_GCM,data:OmqpKAiiFyS/rytnHYRZ,iv:VXiPV5VfduC/IW+E3gDlNAeE+hr+IZ9W7Ty6Npuu59Y=,tag:rjvTHnBxN/pMvpQC5W9S6Q==,type:str] +authentik_bootstrap_password: ENC[AES256_GCM,data:QPCY0ni3jBQY5HyK+vRlyT4YTEo=,iv:41u1Jf+WYksPUY4pvKdHQA1RHW21GAfdVDRuQ7XtdYc=,tag:SYmzv26GbE1kwleXyKLG4Q==,type:str] +tailscale_authkey: ENC[AES256_GCM,data:CBMidfdilBn1HP3TAu9TCOpuwPnzVQ/3OGCsf0vhFd8=,iv:V4UdZBPyzH8NkiXV40CxLdnfkCv5oG6xEQbNSmpmrmI=,tag:h8R2AIaFXgPycMIP8NyHVA==,type:str] headplane_cookie_secret: ENC[AES256_GCM,data:oXYRG4z16u6HS7zXoWrV2q/HL2o24n4UwVXnQvqBmbY=,iv:itAiy/w6ue4VzqO5xYnvSYN3uCLLmu52dvAAxZ2pCGc=,tag:MEFP171GpSQpAqUFsWY+VA==,type:str] headplane_oidc_client_secret: ENC[AES256_GCM,data:v86GLehXAI4Ol2ZxjtdtB1aDau0qsm68HBz3Iu4HjySPEi9jpg==,iv:EwtrNGKyVYcZg8AnD87EpHpmp0odeBXi1PM/9uUe+ko=,tag:VATaEMNxt5pnmgAHV13rLw==,type:str] headplane_headscale_api_key: ENC[AES256_GCM,data:pb2ipeI4rkTXMQMG6hbkSU7OBLcpYJ2/a+9TgSA7PAfL2tTFoDWico8=,iv:j3FkiixUbT+6oLREKP21jQGFFUUU2KqX/ym2xQCeM0k=,tag:ZulMJDpaM1c81yT/LWNWgA==,type:str] +caddy_acme_email: ENC[AES256_GCM,data:j+iPz4lt0Gqs2SKHTS53,iv:DQNfcpYy2v93sKeCvXzLOoBhAo0PjZrxHgFD1oVqYu8=,tag:1yPH/2cW/IiRi9lT8dELEg==,type:str] sops: age: - enc: | @@ -25,7 +27,7 @@ sops: Wptkf76aP9UpjhgNkxzedRebQPB7ti+UiVqCvLVimtuHcsm/NJPcRg== -----END AGE ENCRYPTED FILE----- recipient: age1hp72xyx2cnd05937e4eww95g5kdtn0wsf9j2nypw330pa69gfdxqn0lpkp - lastmodified: "2026-07-19T20:41:24Z" - mac: ENC[AES256_GCM,data:5LXTmULolfpLxdbnpCIiZ2ys7jqmug7UWr9f6rwrzfpxRlpyXcDhgh1zFqXfXlrvn6eVABNfcGJhIXz7+gZFhTuoEC5GEe60+Wx/mXn6LTJk8T25zk4KUnzTUPSLqz2S5BsGrBOkLGUnJOAyhmzMfToI1qOqzzsswfuuT7hTaeE=,iv:sqIZ4yhCsQ5LKSRrnPJUrG6qaSYW+EOFLjJvDdjStGw=,tag:kt0UzOXfoI+wQHGqLUYAhA==,type:str] + lastmodified: "2026-07-20T05:25:30Z" + mac: ENC[AES256_GCM,data:QFdUgjejtbhY8APVQ4r6uenOK8t0+GC0hazQ2jKAPbxbS4RunkXVzAOBpdIt6woW6TU9FlAVyHFxMGlg6XDI6QgM4KtsSgRbYj06PVl+E2rwFH6lOBgOYtYTS50wXWiyOyqyPDAoHyke+8/R3Ri61XgpbD7DrUOFVhihVYTqf+Y=,iv:UcUZ2o/HiP/viXfM3fRiYRR2uI3rDFb6P2mggqDcAKk=,tag:1YhQAXn3DtE3vJxs1e+MoQ==,type:str] unencrypted_suffix: _unencrypted version: 3.13.1 diff --git a/services/identity/authentik.nix b/services/identity/authentik.nix new file mode 100644 index 0000000..98970cb --- /dev/null +++ b/services/identity/authentik.nix @@ -0,0 +1,47 @@ +{ config, pkgs, inputs, ... }: + +# Authentik — self-hosted identity/OIDC provider. +# +# Replaced Zitadel: nixpkgs only carries Zitadel 2.71 (no login-v2 split, and +# a v3/v4 database migrates forward only, so an existing instance can't be +# moved onto it). authentik-nix tracks upstream closely instead. +# +# The upstream module owns postgres (createDatabase) AND orders the units +# against postgresql.target, so no manual After= is needed here. No redis — +# recent authentik runs channels/cache on postgres. +# +# TLS terminates at Caddy; every listener is pinned to loopback below so +# nothing is reachable from the tailnet (hosts trust tailscale0). +# +# Needs, wired via sops in the host's secrets.nix: an environmentFile carrying +# - AUTHENTIK_SECRET_KEY (`openssl rand -base64 60`) — signs sessions +# - AUTHENTIK_BOOTSTRAP_PASSWORD first-run akadmin password +# systemd reads EnvironmentFile as root before dropping to the service's +# DynamicUser, so the sops default root:root 0400 is correct — do NOT set +# `owner` on it the way the headplane secrets need. +{ + imports = [ inputs.authentik-nix.nixosModules.default ]; + + # Pinned explicitly: the default tracks system.stateVersion, so editing that + # line would silently demand a pg_upgrade of the identity store. Bump this + # deliberately, with a dump in hand. + services.postgresql.package = pkgs.postgresql_17; + + services.authentik = { + enable = true; + environmentFile = config.sops.templates."authentik.env".path; + + settings = { + # Default is 0.0.0.0 on all three; behind Caddy loopback is enough. + listen = { + listen_http = "127.0.0.1:9000"; + listen_https = "127.0.0.1:9443"; + listen_metrics = "127.0.0.1:9300"; + }; + + disable_startup_analytics = true; + error_reporting.enabled = false; + avatars = "initials"; + }; + }; +} diff --git a/services/identity/zitadel.nix b/services/identity/zitadel.nix deleted file mode 100644 index e7c08b2..0000000 --- a/services/identity/zitadel.nix +++ /dev/null @@ -1,60 +0,0 @@ -{ config, ... }: - -# Zitadel — self-hosted identity/OIDC provider. Local Postgres (peer-authed -# over the unix socket, no password anywhere) since Zitadel is latency- -# sensitive to its DB. TLS terminates at Caddy; Zitadel itself only listens -# on localhost:8080. -# -# The "zitadel" Postgres role doubles as both User (runtime queries) and -# Admin (bootstrap: creates db/extensions on first start) — granted -# createdb+createrole rather than using the postgres superuser, per -# Zitadel's own guidance to scope bootstrap privileges to a dedicated role. -# -# Needs, wired via sops in the host's secrets.nix: -# - masterKeyFile: 32 raw bytes, e.g. `openssl rand -hex 16` -# - extraStepsPaths: a FirstInstance admin bootstrap file (keeps the admin -# password out of the Nix store — settings.steps would leak it, since -# it's rendered into a world-readable store path) -{ - services.postgresql = { - enable = true; - ensureDatabases = [ "zitadel" ]; - ensureUsers = [ - { - name = "zitadel"; - ensureDBOwnership = true; - ensureClauses = { - createdb = true; - createrole = true; - }; - } - ]; - }; - - services.zitadel = { - enable = true; - tlsMode = "external"; - masterKeyFile = config.sops.secrets.zitadel_master_key.path; - extraStepsPaths = [ config.sops.templates."zitadel-first-instance.yaml".path ]; - - settings = { - Port = 8080; - ExternalPort = 443; - ExternalSecure = true; - - Database.postgres = { - Host = "/run/postgresql"; - Port = 5432; - Database = "zitadel"; - User = { - Username = "zitadel"; - SSL.Mode = "disable"; - }; - Admin = { - Username = "zitadel"; - SSL.Mode = "disable"; - }; - }; - }; - }; -} diff --git a/services/vpn/headplane.nix b/services/vpn/headplane.nix index 899f4fa..e62edbd 100644 --- a/services/vpn/headplane.nix +++ b/services/vpn/headplane.nix @@ -1,28 +1,50 @@ { config, ... }: # Headplane — web UI for headscale (services/vpn/headscale.nix; must be enabled -# first). Runs as headscale's own OS user so it can restart headscale when -# settings change from the UI. +# first), running as headscale's own OS user. +# +# It reads headscale's config from the nix store, which is read-only — so the +# UI DISPLAYS the settings but can't change them. That's the intended shape +# for a declaratively-configured box (config_strict already defaults off +# upstream for exactly this reason); edit them here and rebuild instead. +# DNS extra-records are the one thing worth making editable, since they're +# data rather than config — hence the writable extra_records file below, +# which also spares headplane from restarting headscale on every change. # # Served at vpn.mgaction.town/admin (path-routed alongside headscale itself, # see hosts/neptun/configuration.nix). base_url is the site root WITHOUT the # /admin prefix — Headplane appends that itself, including for the OIDC # callback. # -# Auth is Zitadel (services/identity/zitadel.nix) via OIDC. client_id, client_secret, -# and the headscale API key can't be known until Zitadel/headscale are -# actually deployed, so they're placeholders below; direct API-key login -# still works as a fallback until then. Once live: -# 1. In Zitadel: create a project + a Web application for Headplane, with -# redirect URI https://vpn.mgaction.town/admin/oidc/callback. Copy the -# generated client ID into oidc.client_id below. +# Auth is Authentik (services/identity/authentik.nix) via OIDC. client_id, +# client_secret, and the headscale API key can't be known until +# Authentik/headscale are actually deployed, so they're placeholders below; +# direct API-key login still works as a fallback until then. Once live: +# 1. In Authentik: create an OAuth2/OpenID Provider + Application with slug +# `headplane` and redirect URI +# https://vpn.mgaction.town/admin/oidc/callback. Copy the generated +# client ID into oidc.client_id below. # 2. `./scripts/edit_secrets secrets/neptun.yaml` and replace -# headplane_oidc_client_secret with the app's client secret. +# headplane_oidc_client_secret with the provider's client secret. # 3. `headscale apikeys create` on the box, and replace # headplane_headscale_api_key the same way. +# +# NOTE: Authentik issues per-application, so the issuer carries the app slug — +# it is NOT the bare host the way Zitadel's was. { + # Writable DNS extra-records, shared by both services (they run as the same + # user). tmpfiles seeds an empty JSON array — headscale won't start against + # a missing or unparseable file. + systemd.tmpfiles.rules = [ + "d /var/lib/headscale 0750 headscale headscale -" + "f /var/lib/headscale/extra_records.json 0640 headscale headscale - []" + ]; + services.headscale.settings.dns.extra_records_path = "/var/lib/headscale/extra_records.json"; + services.headplane = { enable = true; + settings.headscale.dns_records_path = "/var/lib/headscale/extra_records.json"; + settings.server = { cookie_secret_path = config.sops.secrets.headplane_cookie_secret.path; cookie_secure = true; # served over HTTPS via Caddy @@ -30,8 +52,8 @@ }; settings.oidc = { - issuer = "https://auth.mgaction.town"; - client_id = "REPLACE_ME_zitadel_client_id"; # not secret, but not known until the app exists in Zitadel + issuer = "https://auth.mgaction.town/application/o/headplane/"; + client_id = "REPLACE_ME_authentik_client_id"; # not secret, but not known until the app exists in Authentik client_secret_path = config.sops.secrets.headplane_oidc_client_secret.path; headscale_api_key_path = config.sops.secrets.headplane_headscale_api_key.path; }; diff --git a/services/vpn/headscale.nix b/services/vpn/headscale.nix index 8ef3bcd..7bafe0a 100644 --- a/services/vpn/headscale.nix +++ b/services/vpn/headscale.nix @@ -11,7 +11,7 @@ { services.headscale = { enable = true; - port = 8082; # zitadel already sits on the usual 8080 on this host + port = 8082; # off the default 8080 to stay clear of other web apps settings = { server_url = "https://vpn.mgaction.town"; diff --git a/services/vpn/tailscale.nix b/services/vpn/tailscale.nix index 57a11ba..86e93d7 100644 --- a/services/vpn/tailscale.nix +++ b/services/vpn/tailscale.nix @@ -13,4 +13,14 @@ }; # Reach the host's services over the tailnet without opening LAN ports. networking.firewall.trustedInterfaces = [ "tailscale0" ]; + + # The upstream unit is a one-shot with no Restart, so a login attempt made + # before the control server is reachable fails permanently until someone + # starts it by hand. That's the norm on a first boot — neptun hosts headscale + # itself, and the other hosts race it. 30s spacing also keeps restarts clear + # of systemd's default start limit (5 within 10s). + systemd.services.tailscaled-autoconnect.serviceConfig = { + Restart = "on-failure"; + RestartSec = 30; + }; }