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