1{
2 config,
3 lib,
4 pkgs,
5 utils,
6 ...
7}:
8
9let
10 cfg = config.services.pretalx;
11 format = pkgs.formats.ini { };
12
13 configFile = format.generate "pretalx.cfg" cfg.settings;
14
15 finalPackage = cfg.package.override {
16 inherit (cfg) plugins;
17 };
18
19 pythonEnv = finalPackage.python.buildEnv.override {
20 extraLibs =
21 with finalPackage.python.pkgs;
22 [
23 (toPythonModule finalPackage)
24 gunicorn
25 ]
26 ++ finalPackage.optional-dependencies.redis
27 ++ lib.optionals cfg.celery.enable [ celery ]
28 ++ lib.optionals (
29 cfg.settings.database.backend == "postgresql"
30 ) finalPackage.optional-dependencies.postgres;
31 };
32in
33
34{
35 meta = with lib; {
36 maintainers = with maintainers; [ hexa ] ++ teams.c3d2.members;
37 };
38
39 options.services.pretalx = {
40 enable = lib.mkEnableOption "pretalx";
41
42 package = lib.mkPackageOption pkgs "pretalx" { };
43
44 group = lib.mkOption {
45 type = lib.types.str;
46 default = "pretalx";
47 description = "Group under which pretalx should run.";
48 };
49
50 user = lib.mkOption {
51 type = lib.types.str;
52 default = "pretalx";
53 description = "User under which pretalx should run.";
54 };
55
56 plugins = lib.mkOption {
57 type = with lib.types; listOf package;
58 default = [ ];
59 example = lib.literalExpression ''
60 with config.services.pretalx.package.plugins; [
61 pages
62 youtube
63 ];
64 '';
65 description = ''
66 Pretalx plugins to install into the Python environment.
67 '';
68 };
69
70 gunicorn.extraArgs = lib.mkOption {
71 type = with lib.types; listOf str;
72 default = [
73 "--name=pretalx"
74 ];
75 example = [
76 "--name=pretalx"
77 "--workers=4"
78 "--max-requests=1200"
79 "--max-requests-jitter=50"
80 "--log-level=info"
81 ];
82 description = ''
83 Extra arguments to pass to gunicorn.
84 See <https://docs.pretalx.org/administrator/installation.html#step-6-starting-pretalx-as-a-service> for details.
85 '';
86 apply = lib.escapeShellArgs;
87 };
88
89 celery = {
90 enable = lib.mkOption {
91 type = lib.types.bool;
92 default = true;
93 example = false;
94 description = ''
95 Whether to set up celery as an asynchronous task runner.
96 '';
97 };
98
99 extraArgs = lib.mkOption {
100 type = with lib.types; listOf str;
101 default = [ ];
102 description = ''
103 Extra arguments to pass to celery.
104
105 See <https://docs.celeryq.dev/en/stable/reference/cli.html#celery-worker> for more info.
106 '';
107 apply = utils.escapeSystemdExecArgs;
108 };
109 };
110
111 nginx = {
112 enable = lib.mkOption {
113 type = lib.types.bool;
114 default = true;
115 example = false;
116 description = ''
117 Whether to set up an nginx virtual host.
118 '';
119 };
120
121 domain = lib.mkOption {
122 type = lib.types.str;
123 example = "talks.example.com";
124 description = ''
125 The domain name under which to set up the virtual host.
126 '';
127 };
128 };
129
130 database.createLocally = lib.mkOption {
131 type = lib.types.bool;
132 default = true;
133 example = false;
134 description = ''
135 Whether to automatically set up the database on the local DBMS instance.
136
137 Currently only supported for PostgreSQL. Not required for sqlite.
138 '';
139 };
140
141 settings = lib.mkOption {
142 type = lib.types.submodule {
143 freeformType = format.type;
144 options = {
145 database = {
146 backend = lib.mkOption {
147 type = lib.types.enum [
148 "postgresql"
149 ];
150 default = "postgresql";
151 description = ''
152 Database backend to use.
153
154 Currently only PostgreSQL gets tested, and as such we don't support any other DBMS.
155 '';
156 readOnly = true; # only postgres supported right now
157 };
158
159 host = lib.mkOption {
160 type = with lib.types; nullOr types.path;
161 default =
162 if cfg.settings.database.backend == "postgresql" then
163 "/run/postgresql"
164 else if cfg.settings.database.backend == "mysql" then
165 "/run/mysqld/mysqld.sock"
166 else
167 null;
168 defaultText = lib.literalExpression ''
169 if config.services.pretalx.settings..database.backend == "postgresql" then "/run/postgresql"
170 else if config.services.pretalx.settings.database.backend == "mysql" then "/run/mysqld/mysqld.sock"
171 else null
172 '';
173 description = ''
174 Database host or socket path.
175 '';
176 };
177
178 name = lib.mkOption {
179 type = lib.types.str;
180 default = "pretalx";
181 description = ''
182 Database name.
183 '';
184 };
185
186 user = lib.mkOption {
187 type = lib.types.str;
188 default = "pretalx";
189 description = ''
190 Database username.
191 '';
192 };
193 };
194
195 files = {
196 upload_limit = lib.mkOption {
197 type = lib.types.ints.positive;
198 default = 10;
199 example = 50;
200 description = ''
201 Maximum file upload size in MiB.
202 '';
203 };
204 };
205
206 filesystem = {
207 data = lib.mkOption {
208 type = lib.types.path;
209 default = "/var/lib/pretalx";
210 description = ''
211 Base path for all other storage paths.
212 '';
213 };
214 logs = lib.mkOption {
215 type = lib.types.path;
216 default = "/var/log/pretalx";
217 description = ''
218 Path to the log directory, that pretalx logs message to.
219 '';
220 };
221 static = lib.mkOption {
222 type = lib.types.path;
223 default = "${cfg.package.static}/";
224 defaultText = lib.literalExpression "\${config.services.pretalx.package}.static}/";
225 readOnly = true;
226 description = ''
227 Path to the directory that contains static files.
228 '';
229 };
230 };
231
232 celery = {
233 backend = lib.mkOption {
234 type = with lib.types; nullOr str;
235 default = lib.optionalString cfg.celery.enable "redis+socket://${config.services.redis.servers.pretalx.unixSocket}?virtual_host=1";
236 defaultText = lib.literalExpression ''
237 optionalString config.services.pretalx.celery.enable "redis+socket://''${config.services.redis.servers.pretalx.unixSocket}?virtual_host=1"
238 '';
239 description = ''
240 URI to the celery backend used for the asynchronous job queue.
241 '';
242 };
243
244 broker = lib.mkOption {
245 type = with lib.types; nullOr str;
246 default = lib.optionalString cfg.celery.enable "redis+socket://${config.services.redis.servers.pretalx.unixSocket}?virtual_host=2";
247 defaultText = lib.literalExpression ''
248 optionalString config.services.pretalx.celery.enable "redis+socket://''${config.services.redis.servers.pretalx.unixSocket}?virtual_host=2"
249 '';
250 description = ''
251 URI to the celery broker used for the asynchronous job queue.
252 '';
253 };
254 };
255
256 redis = {
257 location = lib.mkOption {
258 type = with lib.types; nullOr str;
259 default = "unix://${config.services.redis.servers.pretalx.unixSocket}?db=0";
260 defaultText = lib.literalExpression ''
261 "unix://''${config.services.redis.servers.pretalx.unixSocket}?db=0"
262 '';
263 description = ''
264 URI to the redis server, used to speed up locking, caching and session storage.
265 '';
266 };
267
268 session = lib.mkOption {
269 type = lib.types.bool;
270 default = true;
271 example = false;
272 description = ''
273 Whether to use redis as the session storage.
274 '';
275 };
276 };
277
278 site = {
279 url = lib.mkOption {
280 type = lib.types.str;
281 default = "https://${cfg.nginx.domain}";
282 defaultText = lib.literalExpression "https://\${config.services.pretalx.nginx.domain}";
283 example = "https://talks.example.com";
284 description = ''
285 The base URI below which your pretalx instance will be reachable.
286 '';
287 };
288 };
289 };
290 };
291 default = { };
292 description = ''
293 pretalx configuration as a Nix attribute set. All settings can also be passed
294 from the environment.
295
296 See <https://docs.pretalx.org/administrator/configure.html> for possible options.
297 '';
298 };
299 };
300
301 config = lib.mkIf cfg.enable {
302 # https://docs.pretalx.org/administrator/installation.html
303
304 environment.systemPackages = [
305 (pkgs.writeScriptBin "pretalx-manage" ''
306 cd ${cfg.settings.filesystem.data}
307 sudo=exec
308 if [[ "$USER" != ${cfg.user} ]]; then
309 sudo='exec /run/wrappers/bin/sudo -u ${cfg.user} --preserve-env=PRETALX_CONFIG_FILE'
310 fi
311 export PRETALX_CONFIG_FILE=${configFile}
312 $sudo ${lib.getExe' pythonEnv "pretalx-manage"} "$@"
313 '')
314 ];
315
316 services.logrotate.settings.pretalx = {
317 files = "${cfg.settings.filesystem.logs}/*.log";
318 su = "${cfg.user} ${cfg.group}";
319 frequency = "weekly";
320 rotate = "12";
321 copytruncate = true;
322 compress = true;
323 };
324
325 services = {
326 nginx = lib.mkIf cfg.nginx.enable {
327 enable = true;
328 recommendedGzipSettings = lib.mkDefault true;
329 recommendedOptimisation = lib.mkDefault true;
330 recommendedProxySettings = lib.mkDefault true;
331 recommendedTlsSettings = lib.mkDefault true;
332 upstreams.pretalx.servers."unix:/run/pretalx/pretalx.sock" = { };
333 virtualHosts.${cfg.nginx.domain} = {
334 # https://docs.pretalx.org/administrator/installation.html#step-7-ssl
335 extraConfig = ''
336 more_set_headers "Referrer-Policy: same-origin";
337 more_set_headers "X-Content-Type-Options: nosniff";
338 '';
339 locations = {
340 "/".proxyPass = "http://pretalx";
341 "/media/" = {
342 alias = "${cfg.settings.filesystem.data}/media/";
343 extraConfig = ''
344 access_log off;
345 more_set_headers 'Content-Disposition: attachment; filename="$1"';
346 expires 7d;
347 '';
348 };
349 "/static/" = {
350 alias = cfg.settings.filesystem.static;
351 extraConfig = ''
352 access_log off;
353 more_set_headers Cache-Control "public";
354 expires 365d;
355 '';
356 };
357 };
358 };
359 };
360
361 postgresql =
362 lib.mkIf (cfg.database.createLocally && cfg.settings.database.backend == "postgresql")
363 {
364 enable = true;
365 ensureUsers = [
366 {
367 name = cfg.settings.database.user;
368 ensureDBOwnership = true;
369 }
370 ];
371 ensureDatabases = [ cfg.settings.database.name ];
372 };
373
374 redis.servers.pretalx.enable = true;
375 };
376
377 systemd.services =
378 let
379 commonUnitConfig = {
380 environment.PRETALX_CONFIG_FILE = configFile;
381 serviceConfig = {
382 User = "pretalx";
383 Group = "pretalx";
384 StateDirectory = [
385 "pretalx"
386 "pretalx/media"
387 ];
388 StateDirectoryMode = "0750";
389 LogsDirectory = "pretalx";
390 WorkingDirectory = cfg.settings.filesystem.data;
391 SupplementaryGroups = [ "redis-pretalx" ];
392 AmbientCapabilities = "";
393 CapabilityBoundingSet = [ "" ];
394 DevicePolicy = "closed";
395 LockPersonality = true;
396 MemoryDenyWriteExecute = true;
397 NoNewPrivileges = true;
398 PrivateDevices = true;
399 PrivateTmp = true;
400 ProcSubset = "pid";
401 ProtectControlGroups = true;
402 ProtectHome = true;
403 ProtectHostname = true;
404 ProtectKernelLogs = true;
405 ProtectKernelModules = true;
406 ProtectKernelTunables = true;
407 ProtectProc = "invisible";
408 ProtectSystem = "strict";
409 RemoveIPC = true;
410 RestrictAddressFamilies = [
411 "AF_INET"
412 "AF_INET6"
413 "AF_UNIX"
414 ];
415 RestrictNamespaces = true;
416 RestrictRealtime = true;
417 RestrictSUIDSGID = true;
418 SystemCallArchitectures = "native";
419 SystemCallFilter = [
420 "@system-service"
421 "~@privileged"
422 "@chown"
423 ];
424 UMask = "0027";
425 };
426 };
427 in
428 {
429 pretalx-web = lib.recursiveUpdate commonUnitConfig {
430 description = "pretalx web service";
431 after =
432 [
433 "network.target"
434 "redis-pretalx.service"
435 ]
436 ++ lib.optionals (cfg.settings.database.backend == "postgresql") [
437 "postgresql.service"
438 ]
439 ++ lib.optionals (cfg.settings.database.backend == "mysql") [
440 "mysql.service"
441 ];
442 wantedBy = [ "multi-user.target" ];
443 preStart = ''
444 versionFile="${cfg.settings.filesystem.data}/.version"
445 version=$(cat "$versionFile" 2>/dev/null || echo 0)
446
447 if [[ $version != ${cfg.package.version} ]]; then
448 ${lib.getExe' pythonEnv "pretalx-manage"} migrate
449
450 echo "${cfg.package.version}" > "$versionFile"
451 fi
452 '';
453 serviceConfig = {
454 ExecStart = "${lib.getExe' pythonEnv "gunicorn"} --bind unix:/run/pretalx/pretalx.sock ${cfg.gunicorn.extraArgs} pretalx.wsgi";
455 RuntimeDirectory = "pretalx";
456 };
457 };
458
459 pretalx-periodic = lib.recursiveUpdate commonUnitConfig {
460 description = "pretalx periodic task runner";
461 # every 15 minutes
462 startAt = [ "*:3,18,33,48" ];
463 serviceConfig = {
464 Type = "oneshot";
465 ExecStart = "${lib.getExe' pythonEnv "pretalx-manage"} runperiodic";
466 };
467 };
468
469 pretalx-clear-sessions = lib.recursiveUpdate commonUnitConfig {
470 description = "pretalx session pruning";
471 startAt = [ "monthly" ];
472 serviceConfig = {
473 Type = "oneshot";
474 ExecStart = "${lib.getExe' pythonEnv "pretalx-manage"} clearsessions";
475 };
476 };
477
478 pretalx-worker = lib.mkIf cfg.celery.enable (
479 lib.recursiveUpdate commonUnitConfig {
480 description = "pretalx asynchronous job runner";
481 after =
482 [
483 "network.target"
484 "redis-pretalx.service"
485 ]
486 ++ lib.optionals (cfg.settings.database.backend == "postgresql") [
487 "postgresql.service"
488 ]
489 ++ lib.optionals (cfg.settings.database.backend == "mysql") [
490 "mysql.service"
491 ];
492 wantedBy = [ "multi-user.target" ];
493 serviceConfig.ExecStart = "${lib.getExe' pythonEnv "celery"} -A pretalx.celery_app worker ${cfg.celery.extraArgs}";
494 }
495 );
496
497 nginx.serviceConfig.SupplementaryGroups = lib.mkIf cfg.nginx.enable [ "pretalx" ];
498 };
499
500 systemd.sockets.pretalx-web.socketConfig = {
501 ListenStream = "/run/pretalx/pretalx.sock";
502 SocketUser = "nginx";
503 };
504
505 users = {
506 groups.${cfg.group} = { };
507 users.${cfg.user} = {
508 isSystemUser = true;
509 inherit (cfg) group;
510 };
511 };
512 };
513}