Skip to content

Commit ff2f00d

Browse files
erictapenJanik-Haag
authored andcommitted
nixos/canaille: init module
Co-Authored-By: Janik <[email protected]>
1 parent 09c2d48 commit ff2f00d

File tree

4 files changed

+454
-0
lines changed

4 files changed

+454
-0
lines changed

nixos/modules/module-list.nix

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

0 commit comments

Comments
 (0)