This repository has no description
8.1 kB
239 lines
1let
2 domain = "id.lassul.us";
3 port = 1411;
4in
5{
6 self,
7 config,
8 lib,
9 pkgs,
10 ...
11}:
12let
13 cfg = config.services.zitadel;
14 vars = config.clan.core.vars.generators.zitadel.files;
15 yaml = pkgs.formats.yaml { };
16 configFile = yaml.generate "config.yaml" cfg.settings;
17 args = lib.cli.toCommandLineShellGNU { } {
18 config = [ configFile ];
19 steps = [
20 (yaml.generate "steps.yaml" cfg.steps)
21 vars."steps.yaml".path
22 ];
23 masterkeyFile = cfg.masterKeyFile;
24 inherit (cfg) tlsMode;
25 };
26 idpCredentials = name: url: {
27 prompts.client_id = {
28 description = ''
29 ${name} login on ${domain}: client id of an OAuth app (${url})
30 with the callback URL
31 https://${domain}/ui/login/login/externalidp/callback
32 Leave empty to not offer ${name} logins.
33 '';
34 persist = true;
35 };
36 prompts.client_secret = {
37 description = "${name} login on ${domain}: client secret of that OAuth app";
38 type = "hidden";
39 persist = true;
40 };
41 };
42in
43{
44 # Zitadel: identity provider for lassul.us services (OIDC). Anyone can
45 # register; users sign in with a passkey or an external login (GitHub,
46 # Google, their own OpenID provider via byoidc) linked to their account. The
47 # login policy, these logins and the services' projects and OIDC apps come from
48 # lass.zitadel (modules/zitadel); the zitadel-reconcile service applies
49 # them. Further IdPs and the grants of "managed" projects are done in the
50 # console at https://id.lassul.us/ui/console.
51 #
52 # Example projects for services on other hosts, once they speak OIDC (the
53 # client secret ends up in /var/lib/zitadel-apps/<project>-<app>.env here):
54 # lass.zitadel.projects.assist = {
55 # access = "declarative";
56 # users = [ "lass@lassul.us" ];
57 # apps.web.redirectUris = [ "https://assist.lassul.us/<callback>" ];
58 # };
59 # lass.zitadel.projects.flix = {
60 # access = "managed";
61 # # jellyfin-plugin-sso with a provider named id.lassul.us
62 # apps.jellyfin.redirectUris = [ "https://flix.lassul.us/sso/OID/redirect/id.lassul.us" ];
63 # };
64
65 clan.core.vars.generators.zitadel = {
66 files."masterkey".owner = "zitadel";
67 # initial password of the admin user lass, changed at first login
68 files."admin-password".deploy = false;
69 files."steps.yaml".owner = "zitadel";
70 runtimeInputs = [
71 pkgs.jq
72 pkgs.openssl
73 pkgs.pwgen
74 ];
75 script = ''
76 openssl rand -hex 16 | tr -d '\n' > "$out/masterkey"
77 pwgen -s -c -n -y 32 1 | tr -d '\n' > "$out/admin-password"
78 jq -n --rawfile pw "$out/admin-password" \
79 '{FirstInstance: {Org: {Human: {Password: $pw}}}}' > "$out/steps.yaml"
80 '';
81 };
82
83 services.zitadel = {
84 enable = true;
85 package = self.packages.${pkgs.stdenv.hostPlatform.system}.zitadel;
86 masterKeyFile = vars."masterkey".path;
87 settings = {
88 # daily usage report to zitadel.com, on by default
89 ServicePing.Enabled = false;
90 # a new instance would otherwise send logins to the separate Login V2 app, which isn't deployed
91 DefaultInstance.Features.LoginV2.Required = false;
92 Port = port;
93 ExternalDomain = domain;
94 ExternalPort = 443;
95 ExternalSecure = true;
96 Machine.Identification = {
97 PrivateIp.Enabled = false;
98 Hostname.Enabled = true;
99 Webhook.Enabled = false;
100 };
101 Database.postgres = {
102 Host = "/run/postgresql";
103 Port = 5432;
104 Database = "zitadel";
105 User.Username = "zitadel";
106 Admin.Username = "zitadel";
107 Admin.ExistingDatabase = "zitadel";
108 };
109 };
110 steps.FirstInstance = {
111 InstanceName = "lassul.us";
112 Org = {
113 Name = "lassul.us";
114 Human = {
115 UserName = "lass";
116 FirstName = "lass";
117 LastName = "lassulus";
118 Email = {
119 Address = "lass@lassul.us";
120 Verified = true;
121 };
122 PasswordChangeRequired = true;
123 };
124 };
125 };
126 };
127
128 # zitadel-invite (tools/zitadel-invite) as root, with the token of
129 # zitadel-reconcile
130 users.users.root.packages = [
131 (pkgs.writeShellScriptBin "zitadel-invite" ''
132 export ZITADEL_URL=''${ZITADEL_URL:-https://${domain}}
133 export ZITADEL_TOKEN_FILE=''${ZITADEL_TOKEN_FILE:-${cfg.steps.FirstInstance.PatPath}}
134 exec ${lib.getExe self.packages.${pkgs.stdenv.hostPlatform.system}.zitadel-invite} "$@"
135 '')
136 ];
137
138 lass.zitadel.loginPolicy = {
139 allowRegister = true;
140 allowExternalIdp = true;
141 allowUsernamePassword = true;
142 passwordlessType = "PASSWORDLESS_TYPE_ALLOWED";
143 };
144 # Verification codes and password resets. Zitadel asks every user whose
145 # address is unverified (registration, byoidc) to verify it before the
146 # login finishes. Sent as the send-only account id@lassul.us of the
147 # mailserver here (configs/mailserver.nix), which DKIM-signs it.
148 lass.zitadel.smtp = {
149 senderAddress = "id@lassul.us";
150 senderName = "lassul.us ID";
151 host = "mail.lassul.us:465";
152 tls = true;
153 user = "id@lassul.us";
154 passwordFile = config.clan.core.vars.generators.mailserver-id.files."password".path;
155 };
156 lass.zitadel.idps = {
157 GitHub = {
158 type = "github";
159 clientIdFile = config.clan.core.vars.generators.zitadel-github.files.client_id.path;
160 clientSecretFile = config.clan.core.vars.generators.zitadel-github.files.client_secret.path;
161 scopes = [
162 "read:user"
163 "user:email"
164 ];
165 };
166 Google = {
167 type = "google";
168 clientIdFile = config.clan.core.vars.generators.zitadel-google.files.client_id.path;
169 clientSecretFile = config.clan.core.vars.generators.zitadel-google.files.client_secret.path;
170 scopes = [
171 "openid"
172 "profile"
173 "email"
174 ];
175 };
176 # byoidc (configs/byoidc.nix): sign in with any OpenID provider. Anyone
177 # can run one that claims any email address, so these accounts are never
178 # linked to existing ones.
179 byoidc = {
180 type = "oidc";
181 name = "Your own provider";
182 issuer = "https://byoidc.lassul.us";
183 clientId = "zitadel";
184 clientSecretFile = config.clan.core.vars.generators.byoidc.files."client-secret".path;
185 providerOptions = {
186 isLinkingAllowed = false;
187 isCreationAllowed = true;
188 isAutoCreation = true;
189 isAutoUpdate = true;
190 };
191 };
192 };
193 clan.core.vars.generators.zitadel-github =
194 idpCredentials "GitHub" "https://github.com/settings/applications/new";
195 clan.core.vars.generators.zitadel-google =
196 idpCredentials "Google" "https://console.cloud.google.com/apis/credentials";
197
198 # The database and its owner come from ensureDatabases/ensureUsers, so the
199 # zitadel role needs no CREATEROLE/CREATEDB. `zitadel init zitadel` creates
200 # only the schemas inside it; start-from-setup then skips the role and
201 # database creation that start-from-init would attempt.
202 systemd.services.zitadel = {
203 after = [ "postgresql.target" ];
204 requires = [ "postgresql.target" ];
205 serviceConfig = {
206 ExecStartPre = "${lib.getExe' cfg.package "zitadel"} init zitadel --config ${configFile}";
207 ExecStart = lib.mkForce "${lib.getExe' cfg.package "zitadel"} start-from-setup ${args}";
208 };
209 };
210
211 services.postgresql = {
212 enable = true;
213 ensureDatabases = [ "zitadel" ];
214 ensureUsers = [
215 {
216 name = "zitadel";
217 ensureDBOwnership = true;
218 }
219 ];
220 };
221
222 # users, passkeys, IdP links, OIDC apps; its secrets are encrypted with the
223 # masterkey from clan vars
224 lass.backup.state.zitadel.preBackup = ''
225 mkdir -p /var/backup/postgresql
226 ${pkgs.sudo}/bin/sudo -u postgres ${config.services.postgresql.package}/bin/pg_dump \
227 zitadel | ${pkgs.gzip}/bin/gzip > /var/backup/postgresql/zitadel.sql.gz
228 '';
229
230 # Zitadel serves the console, OIDC and its gRPC APIs on one port over h2c.
231 services.nginx.virtualHosts.${domain} = {
232 enableACME = true;
233 forceSSL = true;
234 locations."/".extraConfig = ''
235 grpc_pass grpc://127.0.0.1:${toString port};
236 grpc_set_header Host $host;
237 '';
238 };
239}