1{
2 config,
3 lib,
4 pkgs,
5 utils,
6 ...
7}:
8
9let
10 inherit (lib)
11 concatMapStringsSep
12 escapeShellArgs
13 filter
14 filterAttrs
15 getExe
16 getExe'
17 isAttrs
18 isList
19 literalExpression
20 mapAttrs
21 mkDefault
22 mkEnableOption
23 mkIf
24 mkOption
25 mkPackageOption
26 optionals
27 optionalString
28 recursiveUpdate
29 types
30 ;
31
32 filterRecursiveNull =
33 o:
34 if isAttrs o then
35 mapAttrs (_: v: filterRecursiveNull v) (filterAttrs (_: v: v != null) o)
36 else if isList o then
37 map filterRecursiveNull (filter (v: v != null) o)
38 else
39 o;
40
41 cfg = config.services.pretix;
42 format = pkgs.formats.ini { };
43
44 configFile = format.generate "pretix.cfg" (filterRecursiveNull cfg.settings);
45
46 finalPackage = cfg.package.override {
47 inherit (cfg) plugins;
48 };
49
50 pythonEnv = cfg.package.python.buildEnv.override {
51 extraLibs =
52 with cfg.package.python.pkgs;
53 [
54 (toPythonModule finalPackage)
55 gunicorn
56 ]
57 ++ lib.optionals (
58 cfg.settings.memcached.location != null
59 ) cfg.package.optional-dependencies.memcached;
60 };
61
62 withRedis = cfg.settings.redis.location != null;
63in
64{
65 meta = with lib; {
66 maintainers = with maintainers; [ hexa ];
67 };
68
69 options.services.pretix = {
70 enable = mkEnableOption "Pretix, a ticket shop application for conferences, festivals, concerts, etc";
71
72 package = mkPackageOption pkgs "pretix" { };
73
74 group = mkOption {
75 type = types.str;
76 default = "pretix";
77 description = ''
78 Group under which pretix should run.
79 '';
80 };
81
82 user = mkOption {
83 type = types.str;
84 default = "pretix";
85 description = ''
86 User under which pretix should run.
87 '';
88 };
89
90 environmentFile = mkOption {
91 type = types.nullOr types.path;
92 default = null;
93 example = "/run/keys/pretix-secrets.env";
94 description = ''
95 Environment file to pass secret configuration values.
96
97 Each line must follow the `PRETIX_SECTION_KEY=value` pattern.
98 '';
99 };
100
101 plugins = mkOption {
102 type = types.listOf types.package;
103 default = [ ];
104 example = literalExpression ''
105 with config.services.pretix.package.plugins; [
106 passbook
107 pages
108 ];
109 '';
110 description = ''
111 Pretix plugins to install into the Python environment.
112 '';
113 };
114
115 gunicorn.extraArgs = mkOption {
116 type = with types; listOf str;
117 default = [
118 "--name=pretix"
119 ];
120 example = [
121 "--name=pretix"
122 "--workers=4"
123 "--max-requests=1200"
124 "--max-requests-jitter=50"
125 "--log-level=info"
126 ];
127 description = ''
128 Extra arguments to pass to gunicorn.
129 See <https://docs.pretix.eu/en/latest/admin/installation/manual_smallscale.html#start-pretix-as-a-service> for details.
130 '';
131 apply = escapeShellArgs;
132 };
133
134 celery = {
135 extraArgs = mkOption {
136 type = with types; listOf str;
137 default = [ ];
138 description = ''
139 Extra arguments to pass to celery.
140
141 See <https://docs.celeryq.dev/en/stable/reference/cli.html#celery-worker> for more info.
142 '';
143 apply = utils.escapeSystemdExecArgs;
144 };
145 };
146
147 nginx = {
148 enable = mkOption {
149 type = types.bool;
150 default = true;
151 example = false;
152 description = ''
153 Whether to set up an nginx virtual host.
154 '';
155 };
156
157 domain = mkOption {
158 type = types.str;
159 example = "talks.example.com";
160 description = ''
161 The domain name under which to set up the virtual host.
162 '';
163 };
164 };
165
166 database.createLocally = mkOption {
167 type = types.bool;
168 default = true;
169 example = false;
170 description = ''
171 Whether to automatically set up the database on the local DBMS instance.
172
173 Only supported for PostgreSQL. Not required for sqlite.
174 '';
175 };
176
177 settings = mkOption {
178 type = types.submodule {
179 freeformType = format.type;
180 options = {
181 pretix = {
182 instance_name = mkOption {
183 type = types.str;
184 example = "tickets.example.com";
185 description = ''
186 The name of this installation.
187 '';
188 };
189
190 url = mkOption {
191 type = types.str;
192 example = "https://tickets.example.com";
193 description = ''
194 The installation’s full URL, without a trailing slash.
195 '';
196 };
197
198 cachedir = mkOption {
199 type = types.path;
200 default = "/var/cache/pretix";
201 description = ''
202 Directory for storing temporary files.
203 '';
204 };
205
206 datadir = mkOption {
207 type = types.path;
208 default = "/var/lib/pretix";
209 description = ''
210 Directory for storing user uploads and similar data.
211 '';
212 };
213
214 logdir = mkOption {
215 type = types.path;
216 default = "/var/log/pretix";
217 description = ''
218 Directory for storing log files.
219 '';
220 };
221
222 currency = mkOption {
223 type = types.str;
224 default = "EUR";
225 example = "USD";
226 description = ''
227 Default currency for events in its ISO 4217 three-letter code.
228 '';
229 };
230
231 registration = mkOption {
232 type = types.bool;
233 default = false;
234 example = true;
235 description = ''
236 Whether to allow registration of new admin users.
237 '';
238 };
239 };
240
241 database = {
242 backend = mkOption {
243 type = types.enum [
244 "sqlite3"
245 "postgresql"
246 ];
247 default = "postgresql";
248 description = ''
249 Database backend to use.
250
251 Only postgresql is recommended for production setups.
252 '';
253 };
254
255 host = mkOption {
256 type = with types; nullOr str;
257 default = if cfg.settings.database.backend == "postgresql" then "/run/postgresql" else null;
258 defaultText = literalExpression ''
259 if config.services.pretix.settings..database.backend == "postgresql" then "/run/postgresql"
260 else null
261 '';
262 description = ''
263 Database host or socket path.
264 '';
265 };
266
267 name = mkOption {
268 type = types.str;
269 default = "pretix";
270 description = ''
271 Database name.
272 '';
273 };
274
275 user = mkOption {
276 type = types.str;
277 default = "pretix";
278 description = ''
279 Database username.
280 '';
281 };
282 };
283
284 mail = {
285 from = mkOption {
286 type = types.str;
287 example = "tickets@example.com";
288 description = ''
289 E-Mail address used in the `FROM` header of outgoing mails.
290 '';
291 };
292
293 host = mkOption {
294 type = types.str;
295 default = "localhost";
296 example = "mail.example.com";
297 description = ''
298 Hostname of the SMTP server use for mail delivery.
299 '';
300 };
301
302 port = mkOption {
303 type = types.port;
304 default = 25;
305 example = 587;
306 description = ''
307 Port of the SMTP server to use for mail delivery.
308 '';
309 };
310 };
311
312 celery = {
313 backend = mkOption {
314 type = types.str;
315 default = "redis+socket://${config.services.redis.servers.pretix.unixSocket}?virtual_host=1";
316 defaultText = literalExpression ''
317 redis+socket://''${config.services.redis.servers.pretix.unixSocket}?virtual_host=1
318 '';
319 description = ''
320 URI to the celery backend used for the asynchronous job queue.
321 '';
322 };
323
324 broker = mkOption {
325 type = types.str;
326 default = "redis+socket://${config.services.redis.servers.pretix.unixSocket}?virtual_host=2";
327 defaultText = literalExpression ''
328 redis+socket://''${config.services.redis.servers.pretix.unixSocket}?virtual_host=2
329 '';
330 description = ''
331 URI to the celery broker used for the asynchronous job queue.
332 '';
333 };
334 };
335
336 redis = {
337 location = mkOption {
338 type = with types; nullOr str;
339 default = "unix://${config.services.redis.servers.pretix.unixSocket}?db=0";
340 defaultText = literalExpression ''
341 "unix://''${config.services.redis.servers.pretix.unixSocket}?db=0"
342 '';
343 description = ''
344 URI to the redis server, used to speed up locking, caching and session storage.
345 '';
346 };
347
348 sessions = mkOption {
349 type = types.bool;
350 default = true;
351 example = false;
352 description = ''
353 Whether to use redis as the session storage.
354 '';
355 };
356 };
357
358 memcached = {
359 location = mkOption {
360 type = with types; nullOr str;
361 default = null;
362 example = "127.0.0.1:11211";
363 description = ''
364 The `host:port` combination or the path to the UNIX socket of a memcached instance.
365
366 Can be used instead of Redis for caching.
367 '';
368 };
369 };
370
371 tools = {
372 pdftk = mkOption {
373 type = types.path;
374 default = getExe pkgs.pdftk;
375 defaultText = literalExpression ''
376 lib.getExe pkgs.pdftk
377 '';
378 description = ''
379 Path to the pdftk executable.
380 '';
381 };
382 };
383 };
384 };
385 default = { };
386 description = ''
387 pretix configuration as a Nix attribute set. All settings can also be passed
388 from the environment.
389
390 See <https://docs.pretix.eu/en/latest/admin/config.html> for possible options.
391 '';
392 };
393 };
394
395 config = mkIf cfg.enable {
396 # https://docs.pretix.eu/en/latest/admin/installation/index.html
397
398 environment.systemPackages = [
399 (pkgs.writeScriptBin "pretix-manage" ''
400 cd ${cfg.settings.pretix.datadir}
401 sudo=exec
402 if [[ "$USER" != ${cfg.user} ]]; then
403 sudo='exec /run/wrappers/bin/sudo -u ${cfg.user} ${optionalString withRedis "-g redis-pretix"} --preserve-env=PRETIX_CONFIG_FILE'
404 fi
405 export PRETIX_CONFIG_FILE=${configFile}
406 $sudo ${getExe' pythonEnv "pretix-manage"} "$@"
407 '')
408 ];
409
410 services.logrotate.settings.pretix = {
411 files = "${cfg.settings.pretix.logdir}/*.log";
412 su = "${cfg.user} ${cfg.group}";
413 frequency = "weekly";
414 rotate = "12";
415 copytruncate = true;
416 compress = true;
417 };
418
419 services = {
420 nginx = mkIf cfg.nginx.enable {
421 enable = true;
422 recommendedGzipSettings = mkDefault true;
423 recommendedOptimisation = mkDefault true;
424 recommendedProxySettings = mkDefault true;
425 recommendedTlsSettings = mkDefault true;
426 upstreams.pretix.servers."unix:/run/pretix/pretix.sock" = { };
427 virtualHosts.${cfg.nginx.domain} = {
428 # https://docs.pretix.eu/en/latest/admin/installation/manual_smallscale.html#ssl
429 extraConfig = ''
430 more_set_headers Referrer-Policy same-origin;
431 more_set_headers X-Content-Type-Options nosniff;
432 '';
433 locations = {
434 "/".proxyPass = "http://pretix";
435 "/media/" = {
436 alias = "${cfg.settings.pretix.datadir}/media/";
437 extraConfig = ''
438 access_log off;
439 expires 7d;
440 '';
441 };
442 "^~ (/media/(cachedfiles|invoices)|/static/(staticfiles.json|CACHE/manifest.json))" = {
443 extraConfig = ''
444 deny all;
445 return 404;
446 '';
447 };
448 "/static/" = {
449 alias = "${finalPackage}/${cfg.package.python.sitePackages}/pretix/static.dist/";
450 extraConfig = ''
451 access_log off;
452 more_set_headers Cache-Control "public";
453 expires 365d;
454 '';
455 };
456 };
457 };
458 };
459
460 postgresql = mkIf (cfg.database.createLocally && cfg.settings.database.backend == "postgresql") {
461 enable = true;
462 ensureUsers = [
463 {
464 name = cfg.settings.database.user;
465 ensureDBOwnership = true;
466 }
467 ];
468 ensureDatabases = [ cfg.settings.database.name ];
469 };
470
471 redis.servers.pretix.enable = withRedis;
472 };
473
474 systemd.services =
475 let
476 commonUnitConfig = {
477 environment.PRETIX_CONFIG_FILE = configFile;
478 serviceConfig = {
479 User = "pretix";
480 Group = "pretix";
481 EnvironmentFile = optionals (cfg.environmentFile != null) [
482 cfg.environmentFile
483 ];
484 StateDirectory = [
485 "pretix"
486 ];
487 StateDirectoryMode = "0750";
488 CacheDirectory = "pretix";
489 LogsDirectory = "pretix";
490 WorkingDirectory = cfg.settings.pretix.datadir;
491 SupplementaryGroups = optionals withRedis [
492 "redis-pretix"
493 ];
494 AmbientCapabilities = "";
495 CapabilityBoundingSet = [ "" ];
496 DevicePolicy = "closed";
497 LockPersonality = true;
498 MemoryDenyWriteExecute = false; # required by pdftk
499 NoNewPrivileges = true;
500 PrivateDevices = true;
501 PrivateTmp = true;
502 ProcSubset = "pid";
503 ProtectControlGroups = true;
504 ProtectHome = true;
505 ProtectHostname = true;
506 ProtectKernelLogs = true;
507 ProtectKernelModules = true;
508 ProtectKernelTunables = true;
509 ProtectProc = "invisible";
510 ProtectSystem = "strict";
511 RemoveIPC = true;
512 RestrictAddressFamilies = [
513 "AF_INET"
514 "AF_INET6"
515 "AF_UNIX"
516 ];
517 RestrictNamespaces = true;
518 RestrictRealtime = true;
519 RestrictSUIDSGID = true;
520 SystemCallArchitectures = "native";
521 SystemCallFilter = [
522 "@system-service"
523 "~@privileged"
524 "@chown"
525 ];
526 UMask = "0027";
527 };
528 };
529 in
530 {
531 pretix-web = recursiveUpdate commonUnitConfig {
532 description = "pretix web service";
533 after = [
534 "network.target"
535 "redis-pretix.service"
536 "postgresql.service"
537 ];
538 wantedBy = [ "multi-user.target" ];
539 preStart = ''
540 versionFile="${cfg.settings.pretix.datadir}/.version"
541 version=$(cat "$versionFile" 2>/dev/null || echo 0)
542
543 pluginsFile="${cfg.settings.pretix.datadir}/.plugins"
544 plugins=$(cat "$pluginsFile" 2>/dev/null || echo "")
545 configuredPlugins="${concatMapStringsSep "|" (package: package.name) cfg.plugins}"
546
547 if [[ $version != ${cfg.package.version} || $plugins != $configuredPlugins ]]; then
548 ${getExe' pythonEnv "pretix-manage"} migrate
549
550 echo "${cfg.package.version}" > "$versionFile"
551 echo "$configuredPlugins" > "$pluginsFile"
552 fi
553 '';
554 serviceConfig = {
555 TimeoutStartSec = "15min";
556 ExecStart = "${getExe' pythonEnv "gunicorn"} --bind unix:/run/pretix/pretix.sock ${cfg.gunicorn.extraArgs} pretix.wsgi";
557 RuntimeDirectory = "pretix";
558 Restart = "on-failure";
559 };
560 };
561
562 pretix-periodic = recursiveUpdate commonUnitConfig {
563 description = "pretix periodic task runner";
564 # every 15 minutes
565 startAt = [ "*:3,18,33,48" ];
566 serviceConfig = {
567 Type = "oneshot";
568 ExecStart = "${getExe' pythonEnv "pretix-manage"} runperiodic";
569 };
570 };
571
572 pretix-worker = recursiveUpdate commonUnitConfig {
573 description = "pretix asynchronous job runner";
574 after = [
575 "network.target"
576 "redis-pretix.service"
577 "postgresql.service"
578 ];
579 wantedBy = [ "multi-user.target" ];
580 serviceConfig = {
581 ExecStart = "${getExe' pythonEnv "celery"} -A pretix.celery_app worker ${cfg.celery.extraArgs}";
582 Restart = "on-failure";
583 };
584 };
585
586 nginx.serviceConfig.SupplementaryGroups = mkIf cfg.nginx.enable [ "pretix" ];
587 };
588
589 systemd.sockets.pretix-web.socketConfig = {
590 ListenStream = "/run/pretix/pretix.sock";
591 SocketUser = "nginx";
592 };
593
594 users = {
595 groups.${cfg.group} = { };
596 users.${cfg.user} = {
597 isSystemUser = true;
598 inherit (cfg) group;
599 };
600 };
601 };
602}