1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8let
9 cfg = config.services.canaille;
10
11 inherit (lib)
12 mkOption
13 mkIf
14 mkEnableOption
15 mkPackageOption
16 types
17 getExe
18 optional
19 converge
20 filterAttrsRecursive
21 ;
22
23 dataDir = "/var/lib/canaille";
24 secretsDir = "${dataDir}/secrets";
25
26 settingsFormat = pkgs.formats.toml { };
27
28 # Remove null values, so we can document optional/forbidden values that don't end up in the generated TOML file.
29 filterConfig = converge (filterAttrsRecursive (_: v: v != null));
30
31 finalPackage = cfg.package.overridePythonAttrs (old: {
32 dependencies =
33 old.dependencies
34 ++ old.optional-dependencies.front
35 ++ old.optional-dependencies.oidc
36 ++ old.optional-dependencies.scim
37 ++ old.optional-dependencies.ldap
38 ++ old.optional-dependencies.sentry
39 ++ old.optional-dependencies.postgresql
40 ++ old.optional-dependencies.otp
41 ++ old.optional-dependencies.sms;
42 makeWrapperArgs = (old.makeWrapperArgs or [ ]) ++ [
43 "--set CONFIG /etc/canaille/config.toml"
44 "--set SECRETS_DIR \"${secretsDir}\""
45 ];
46 });
47 inherit (finalPackage) python;
48 pythonEnv = python.buildEnv.override {
49 extraLibs = with python.pkgs; [
50 (toPythonModule finalPackage)
51 celery
52 ];
53 };
54
55 commonServiceConfig = {
56 WorkingDirectory = dataDir;
57 User = "canaille";
58 Group = "canaille";
59 StateDirectory = "canaille";
60 StateDirectoryMode = "0750";
61 PrivateTmp = true;
62 };
63
64 postgresqlHost = "postgresql://localhost/canaille?host=/run/postgresql";
65 createLocalPostgresqlDb = cfg.settings.CANAILLE_SQL.DATABASE_URI == postgresqlHost;
66in
67{
68
69 options.services.canaille = {
70 enable = mkEnableOption "Canaille";
71 package = mkPackageOption pkgs "canaille" { };
72 secretKeyFile = mkOption {
73 description = ''
74 File containing the Flask secret key. Its content is going to be
75 provided to Canaille as `SECRET_KEY`. Make sure it has appropriate
76 permissions. For example, copy the output of this to the specified
77 file:
78
79 ```
80 python3 -c 'import secrets; print(secrets.token_hex())'
81 ```
82 '';
83 type = types.path;
84 };
85 smtpPasswordFile = mkOption {
86 description = ''
87 File containing the SMTP password. Make sure it has appropriate permissions.
88 '';
89 default = null;
90 type = types.nullOr types.path;
91 };
92 jwtPrivateKeyFile = mkOption {
93 description = ''
94 File containing the JWT private key. Make sure it has appropriate permissions.
95
96 You can generate one using
97 ```
98 openssl genrsa -out private.pem 4096
99 openssl rsa -in private.pem -pubout -outform PEM -out public.pem
100 ```
101 '';
102 default = null;
103 type = types.nullOr types.path;
104 };
105 ldapBindPasswordFile = mkOption {
106 description = ''
107 File containing the LDAP bind password.
108 '';
109 default = null;
110 type = types.nullOr types.path;
111 };
112 settings = mkOption {
113 default = { };
114 description = "Settings for Canaille. See [the documentation](https://canaille.readthedocs.io/en/latest/references/configuration.html) for details.";
115 type = types.submodule {
116 freeformType = settingsFormat.type;
117 options = {
118 SECRET_KEY = mkOption {
119 readOnly = true;
120 description = ''
121 Flask Secret Key. Can't be set and must be provided through
122 `services.canaille.settings.secretKeyFile`.
123 '';
124 default = null;
125 type = types.nullOr types.str;
126 };
127 SERVER_NAME = mkOption {
128 description = "The domain name on which canaille will be served.";
129 example = "auth.example.org";
130 type = types.str;
131 };
132 PREFERRED_URL_SCHEME = mkOption {
133 description = "The url scheme by which canaille will be served.";
134 default = "https";
135 type = types.enum [
136 "http"
137 "https"
138 ];
139 };
140
141 CANAILLE = {
142 ACL = mkOption {
143 default = null;
144 description = ''
145 Access Control Lists.
146
147 See also [the documentation](https://canaille.readthedocs.io/en/latest/references/configuration.html#canaille.core.configuration.ACLSettings).
148 '';
149 type = types.nullOr (
150 types.submodule {
151 freeformType = settingsFormat.type;
152 options = { };
153 }
154 );
155 };
156 SMTP = mkOption {
157 default = null;
158 example = { };
159 description = ''
160 SMTP configuration. By default, sending emails is not enabled.
161
162 Set to an empty attrs to send emails from localhost without
163 authentication.
164
165 See also [the documentation](https://canaille.readthedocs.io/en/latest/references/configuration.html#canaille.core.configuration.SMTPSettings).
166 '';
167 type = types.nullOr (
168 types.submodule {
169 freeformType = settingsFormat.type;
170 options = {
171 PASSWORD = mkOption {
172 readOnly = true;
173 description = ''
174 SMTP Password. Can't be set and has to be provided using
175 `services.canaille.smtpPasswordFile`.
176 '';
177 default = null;
178 type = types.nullOr types.str;
179 };
180 };
181 }
182 );
183 };
184
185 };
186 CANAILLE_OIDC = mkOption {
187 default = null;
188 description = ''
189 OpenID Connect settings. See [the documentation](https://canaille.readthedocs.io/en/latest/references/configuration.html#canaille.oidc.configuration.OIDCSettings).
190 '';
191 type = types.nullOr (
192 types.submodule {
193 freeformType = settingsFormat.type;
194 options = {
195 JWT.PRIVATE_KEY = mkOption {
196 readOnly = true;
197 description = ''
198 JWT private key. Can't be set and has to be provided using
199 `services.canaille.jwtPrivateKeyFile`.
200 '';
201 default = null;
202 type = types.nullOr types.str;
203 };
204 };
205 }
206 );
207 };
208 CANAILLE_LDAP = mkOption {
209 default = null;
210 description = ''
211 Configuration for the LDAP backend. This storage backend is not
212 yet supported by the module, so use at your own risk!
213 '';
214 type = types.nullOr (
215 types.submodule {
216 freeformType = settingsFormat.type;
217 options = {
218 BIND_PW = mkOption {
219 readOnly = true;
220 description = ''
221 The LDAP bind password. Can't be set and has to be provided using
222 `services.canaille.ldapBindPasswordFile`.
223 '';
224 default = null;
225 type = types.nullOr types.str;
226 };
227 };
228 }
229 );
230 };
231 CANAILLE_SQL = {
232 DATABASE_URI = mkOption {
233 description = ''
234 The SQL server URI. Will configure a local PostgreSQL db if
235 left to default. Please note that the NixOS module only really
236 supports PostgreSQL for now. Change at your own risk!
237 '';
238 default = postgresqlHost;
239 type = types.str;
240 };
241 };
242 };
243 };
244 };
245 };
246
247 config = mkIf cfg.enable {
248 # We can use some kind of fix point for the config anyways, and
249 # /etc/canaille is recommended by upstream. The alternative would be to use
250 # a double wrapped canaille executable, to avoid having to rebuild Canaille
251 # on every config change.
252 environment.etc."canaille/config.toml" = {
253 source = settingsFormat.generate "config.toml" (filterConfig cfg.settings);
254 user = "canaille";
255 group = "canaille";
256 };
257
258 # Secrets management is unfortunately done in a semi stateful way, due to these constraints:
259 # - Canaille uses Pydantic, which currently only accepts an env file or a single
260 # directory (SECRETS_DIR) for loading settings from files.
261 # - The canaille user needs access to secrets, as it needs to run the CLI
262 # for e.g. user creation. Therefore specifying the SECRETS_DIR as systemd's
263 # CREDENTIALS_DIRECTORY is not an option.
264 #
265 # See this for how Pydantic maps file names/env vars to config settings:
266 # https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values
267 systemd.tmpfiles.rules =
268 [
269 "Z ${secretsDir} 700 canaille canaille - -"
270 "L+ ${secretsDir}/SECRET_KEY - - - - ${cfg.secretKeyFile}"
271 ]
272 ++ optional (
273 cfg.smtpPasswordFile != null
274 ) "L+ ${secretsDir}/CANAILLE_SMTP__PASSWORD - - - - ${cfg.smtpPasswordFile}"
275 ++ optional (
276 cfg.jwtPrivateKeyFile != null
277 ) "L+ ${secretsDir}/CANAILLE_OIDC__JWT__PRIVATE_KEY - - - - ${cfg.jwtPrivateKeyFile}"
278 ++ optional (
279 cfg.ldapBindPasswordFile != null
280 ) "L+ ${secretsDir}/CANAILLE_LDAP__BIND_PW - - - - ${cfg.ldapBindPasswordFile}";
281
282 # This is not a migration, just an initial setup of schemas
283 systemd.services.canaille-install = {
284 # We want this on boot, not on socket activation
285 wantedBy = [ "multi-user.target" ];
286 after = optional createLocalPostgresqlDb "postgresql.service";
287 serviceConfig = commonServiceConfig // {
288 Type = "oneshot";
289 ExecStart = "${getExe finalPackage} install";
290 };
291 };
292
293 systemd.services.canaille = {
294 description = "Canaille";
295 documentation = [ "https://canaille.readthedocs.io/en/latest/tutorial/deployment.html" ];
296 after = [
297 "network.target"
298 "canaille-install.service"
299 ] ++ optional createLocalPostgresqlDb "postgresql.service";
300 requires = [
301 "canaille-install.service"
302 "canaille.socket"
303 ];
304 environment = {
305 PYTHONPATH = "${pythonEnv}/${python.sitePackages}/";
306 CONFIG = "/etc/canaille/config.toml";
307 SECRETS_DIR = secretsDir;
308 };
309 serviceConfig = commonServiceConfig // {
310 Restart = "on-failure";
311 ExecStart =
312 let
313 gunicorn = python.pkgs.gunicorn.overridePythonAttrs (old: {
314 # Allows Gunicorn to set a meaningful process name
315 dependencies = (old.dependencies or [ ]) ++ old.optional-dependencies.setproctitle;
316 });
317 in
318 ''
319 ${getExe gunicorn} \
320 --name=canaille \
321 --bind='unix:///run/canaille.socket' \
322 'canaille:create_app()'
323 '';
324 };
325 restartTriggers = [ "/etc/canaille/config.toml" ];
326 };
327
328 systemd.sockets.canaille = {
329 before = [ "nginx.service" ];
330 wantedBy = [ "sockets.target" ];
331 socketConfig = {
332 ListenStream = "/run/canaille.socket";
333 SocketUser = "canaille";
334 SocketGroup = "canaille";
335 SocketMode = "770";
336 };
337 };
338
339 services.nginx.enable = true;
340 services.nginx.recommendedGzipSettings = true;
341 services.nginx.recommendedProxySettings = true;
342 services.nginx.virtualHosts."${cfg.settings.SERVER_NAME}" = {
343 forceSSL = true;
344 enableACME = true;
345 # Config from https://canaille.readthedocs.io/en/latest/tutorial/deployment.html#nginx
346 extraConfig = ''
347 charset utf-8;
348 client_max_body_size 10M;
349
350 add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
351 add_header X-Frame-Options "SAMEORIGIN" always;
352 add_header X-XSS-Protection "1; mode=block" always;
353 add_header X-Content-Type-Options "nosniff" always;
354 add_header Referrer-Policy "same-origin" always;
355 '';
356 locations = {
357 "/".proxyPass = "http://unix:///run/canaille.socket";
358 "/static" = {
359 root = "${finalPackage}/${python.sitePackages}/canaille";
360 };
361 "~* ^/static/.+\\.(?:css|cur|js|jpe?g|gif|htc|ico|png|html|xml|otf|ttf|eot|woff|woff2|svg)$" = {
362 root = "${finalPackage}/${python.sitePackages}/canaille";
363 extraConfig = ''
364 access_log off;
365 expires 30d;
366 more_set_headers Cache-Control public;
367 '';
368 };
369 };
370 };
371
372 services.postgresql = mkIf createLocalPostgresqlDb {
373 enable = true;
374 ensureUsers = [
375 {
376 name = "canaille";
377 ensureDBOwnership = true;
378 }
379 ];
380 ensureDatabases = [ "canaille" ];
381 };
382
383 users.users.canaille = {
384 isSystemUser = true;
385 group = "canaille";
386 packages = [ finalPackage ];
387 };
388
389 users.groups.canaille.members = [ config.services.nginx.user ];
390 };
391
392 meta.maintainers = with lib.maintainers; [ erictapen ];
393}