diff --git a/hosts/neptun/configuration.nix b/hosts/neptun/configuration.nix index c70e6c8..c6db923 100644 --- a/hosts/neptun/configuration.nix +++ b/hosts/neptun/configuration.nix @@ -9,6 +9,7 @@ ../../common.nix # shared base: user / ssh / nix / firewall ../../services/caddy.nix ../../services/tailscale.nix + ../../services/zitadel.nix ]; # ---- Boot (UEFI) ---- @@ -48,6 +49,14 @@ ''; # TODO: port your other VPS services' vhosts here before deploying. + # ---- Zitadel (identity/OIDC provider) ---- + # Runs locally on neptun (see services/zitadel.nix); Caddy just terminates + # TLS and proxies to it. + services.zitadel.settings.ExternalDomain = "auth.mgaction.town"; + services.caddy.virtualHosts."auth.mgaction.town".extraConfig = '' + reverse_proxy http://localhost:8080 + ''; + # ---- Gitea SSH forward ---- # Caddy only proxies HTTP; git-over-ssh needs a raw TCP forward. Gitea's # own built-in SSH server runs on jupiter:2222 (see services/gitea.nix — diff --git a/hosts/neptun/secrets.nix b/hosts/neptun/secrets.nix index 0c96430..89299ff 100644 --- a/hosts/neptun/secrets.nix +++ b/hosts/neptun/secrets.nix @@ -13,4 +13,27 @@ # darman's console password (own hash = distinct from jupiter/mercury). 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/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 + ''; } diff --git a/secrets/neptun.yaml b/secrets/neptun.yaml index 1784ef7..47f17cd 100644 --- a/secrets/neptun.yaml +++ b/secrets/neptun.yaml @@ -1,5 +1,7 @@ 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] sops: age: - enc: | @@ -20,7 +22,7 @@ sops: Wptkf76aP9UpjhgNkxzedRebQPB7ti+UiVqCvLVimtuHcsm/NJPcRg== -----END AGE ENCRYPTED FILE----- recipient: age1hp72xyx2cnd05937e4eww95g5kdtn0wsf9j2nypw330pa69gfdxqn0lpkp - lastmodified: "2026-07-13T18:50:14Z" - mac: ENC[AES256_GCM,data:52kyoG1moXQ9rDT7NLbxJby5ZiUn0Gym3u4kuLTsXxlGIvots7FipAg98BN4LZrL+8sGYMy2k8EAFzSB3F0tU5fi0f+R0F7CtKhK4nKCC5Hg70odN7HePobDw9dfR6hExLcSpUJBlUmpU0Lw1DL3MLjEEcfgzOYHmmrBv9rI4KU=,iv:2bIH2i874H1vg+nBJhsxohSYsjpFTb1toRw8mK5UN40=,tag:V2uijc0BymsLg3vDsHob9w==,type:str] + lastmodified: "2026-07-19T20:28:23Z" + mac: ENC[AES256_GCM,data:3dRmmSJqaKi8w1mtmQIaL8AynSUCH4WRUpUax5g+3qKrhQeect7TdCFyTU991mfmUnxfQ8nI7nyAI2DdAb/TTHxAjDgXd0Qn31olGYZRQFRV25YOiXozvXoHVjroW86IQDgh6UBp5BkpU+HrfCfLs/opAx97jLu14M4sLLuu8R4=,iv:qmKCCX70IJ/JQvpjMW1eYM2cgjVx+1PS44ahhTXiOp0=,tag:hi7V9zwo4uZxAjEm3dUxBw==,type:str] unencrypted_suffix: _unencrypted version: 3.13.1 diff --git a/services/zitadel.nix b/services/zitadel.nix new file mode 100644 index 0000000..6775adb --- /dev/null +++ b/services/zitadel.nix @@ -0,0 +1,62 @@ +{ 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 is terminated at Caddy (see the host's +# configuration.nix for the public vhost); Zitadel itself only listens on +# localhost:8080. +# +# The "zitadel" Postgres role doubles as both Database.postgres.User (normal +# runtime queries) and .Admin (bootstrap: creates the db/extensions on first +# start) — granted createdb+createrole instead of using the real postgres +# superuser, matching Zitadel's own guidance to keep bootstrap privileges +# scoped 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"; + }; + }; + }; + }; +}