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; + }; }