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 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 07:50:39 +02:00
co-authored by Claude Opus 4.8
parent d7a66f3e3b
commit ac42f231f5
12 changed files with 439 additions and 119 deletions
+30 -25
View File
@@ -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";
}