python3Packages.orjson: Disable failing tests on 32 bit
[NixPkgs.git] / nixos / modules / services / mail / public-inbox.nix
blobab7ff5f726a4dc51f7bbeac71ff57219b6fe697d
1 { lib, pkgs, config, ... }:
3 with lib;
5 let
6   cfg = config.services.public-inbox;
7   stateDir = "/var/lib/public-inbox";
9   gitIni = pkgs.formats.gitIni { listsAsDuplicateKeys = true; };
10   iniAtom = elemAt gitIni.type/*attrsOf*/.functor.wrapped/*attrsOf*/.functor.wrapped/*either*/.functor.wrapped 0;
12   useSpamAssassin = cfg.settings.publicinboxmda.spamcheck == "spamc" ||
13                     cfg.settings.publicinboxwatch.spamcheck == "spamc";
15   publicInboxDaemonOptions = proto: defaultPort: {
16     args = mkOption {
17       type = with types; listOf str;
18       default = [];
19       description = lib.mdDoc "Command-line arguments to pass to {manpage}`public-inbox-${proto}d(1)`.";
20     };
21     port = mkOption {
22       type = with types; nullOr (either str port);
23       default = defaultPort;
24       description = lib.mdDoc ''
25         Listening port.
26         Beware that public-inbox uses well-known ports number to decide whether to enable TLS or not.
27         Set to null and use `systemd.sockets.public-inbox-${proto}d.listenStreams`
28         if you need a more advanced listening.
29       '';
30     };
31     cert = mkOption {
32       type = with types; nullOr str;
33       default = null;
34       example = "/path/to/fullchain.pem";
35       description = lib.mdDoc "Path to TLS certificate to use for connections to {manpage}`public-inbox-${proto}d(1)`.";
36     };
37     key = mkOption {
38       type = with types; nullOr str;
39       default = null;
40       example = "/path/to/key.pem";
41       description = lib.mdDoc "Path to TLS key to use for connections to {manpage}`public-inbox-${proto}d(1)`.";
42     };
43   };
45   serviceConfig = srv:
46     let proto = removeSuffix "d" srv;
47         needNetwork = builtins.hasAttr proto cfg && cfg.${proto}.port == null;
48     in {
49     serviceConfig = {
50       # Enable JIT-compiled C (via Inline::C)
51       Environment = [ "PERL_INLINE_DIRECTORY=/run/public-inbox-${srv}/perl-inline" ];
52       # NonBlocking is REQUIRED to avoid a race condition
53       # if running simultaneous services.
54       NonBlocking = true;
55       #LimitNOFILE = 30000;
56       User = config.users.users."public-inbox".name;
57       Group = config.users.groups."public-inbox".name;
58       RuntimeDirectory = [
59           "public-inbox-${srv}/perl-inline"
60         ];
61       RuntimeDirectoryMode = "700";
62       # This is for BindPaths= and BindReadOnlyPaths=
63       # to allow traversal of directories they create inside RootDirectory=
64       UMask = "0066";
65       StateDirectory = ["public-inbox"];
66       StateDirectoryMode = "0750";
67       WorkingDirectory = stateDir;
68       BindReadOnlyPaths = [
69           "/etc"
70           "/run/systemd"
71           "${config.i18n.glibcLocales}"
72         ] ++
73         mapAttrsToList (name: inbox: inbox.description) cfg.inboxes ++
74         # Without confinement the whole Nix store
75         # is made available to the service
76         optionals (!config.systemd.services."public-inbox-${srv}".confinement.enable) [
77           "${pkgs.dash}/bin/dash:/bin/sh"
78           builtins.storeDir
79         ];
80       # The following options are only for optimizing:
81       # systemd-analyze security public-inbox-'*'
82       AmbientCapabilities = "";
83       CapabilityBoundingSet = "";
84       # ProtectClock= adds DeviceAllow=char-rtc r
85       DeviceAllow = "";
86       LockPersonality = true;
87       MemoryDenyWriteExecute = true;
88       NoNewPrivileges = true;
89       PrivateNetwork = mkDefault (!needNetwork);
90       ProcSubset = "pid";
91       ProtectClock = true;
92       ProtectHome = mkDefault true;
93       ProtectHostname = true;
94       ProtectKernelLogs = true;
95       ProtectProc = "invisible";
96       #ProtectSystem = "strict";
97       RemoveIPC = true;
98       RestrictAddressFamilies = [ "AF_UNIX" ] ++
99         optionals needNetwork [ "AF_INET" "AF_INET6" ];
100       RestrictNamespaces = true;
101       RestrictRealtime = true;
102       RestrictSUIDSGID = true;
103       SystemCallFilter = [
104         "@system-service"
105         "~@aio" "~@chown" "~@keyring" "~@memlock" "~@resources"
106         # Not removing @setuid and @privileged because Inline::C needs them.
107         # Not removing @timer because git upload-pack needs it.
108       ];
109       SystemCallArchitectures = "native";
111       # The following options are redundant when confinement is enabled
112       RootDirectory = "/var/empty";
113       TemporaryFileSystem = "/";
114       PrivateMounts = true;
115       MountAPIVFS = true;
116       PrivateDevices = true;
117       PrivateTmp = true;
118       PrivateUsers = true;
119       ProtectControlGroups = true;
120       ProtectKernelModules = true;
121       ProtectKernelTunables = true;
122     };
123     confinement = {
124       # Until we agree upon doing it directly here in NixOS
125       # https://github.com/NixOS/nixpkgs/pull/104457#issuecomment-1115768447
126       # let the user choose to enable the confinement with:
127       # systemd.services.public-inbox-httpd.confinement.enable = true;
128       # systemd.services.public-inbox-imapd.confinement.enable = true;
129       # systemd.services.public-inbox-init.confinement.enable = true;
130       # systemd.services.public-inbox-nntpd.confinement.enable = true;
131       #enable = true;
132       mode = "full-apivfs";
133       # Inline::C needs a /bin/sh, and dash is enough
134       binSh = "${pkgs.dash}/bin/dash";
135       packages = [
136           pkgs.iana-etc
137           (getLib pkgs.nss)
138           pkgs.tzdata
139         ];
140     };
141   };
145   options.services.public-inbox = {
146     enable = mkEnableOption (lib.mdDoc "the public-inbox mail archiver");
147     package = mkOption {
148       type = types.package;
149       default = pkgs.public-inbox;
150       defaultText = literalExpression "pkgs.public-inbox";
151       description = lib.mdDoc "public-inbox package to use.";
152     };
153     path = mkOption {
154       type = with types; listOf package;
155       default = [];
156       example = literalExpression "with pkgs; [ spamassassin ]";
157       description = lib.mdDoc ''
158         Additional packages to place in the path of public-inbox-mda,
159         public-inbox-watch, etc.
160       '';
161     };
162     inboxes = mkOption {
163       description = lib.mdDoc ''
164         Inboxes to configure, where attribute names are inbox names.
165       '';
166       default = {};
167       type = types.attrsOf (types.submodule ({name, ...}: {
168         freeformType = types.attrsOf iniAtom;
169         options.inboxdir = mkOption {
170           type = types.str;
171           default = "${stateDir}/inboxes/${name}";
172           description = lib.mdDoc "The absolute path to the directory which hosts the public-inbox.";
173         };
174         options.address = mkOption {
175           type = with types; listOf str;
176           example = "example-discuss@example.org";
177           description = lib.mdDoc "The email addresses of the public-inbox.";
178         };
179         options.url = mkOption {
180           type = with types; nullOr str;
181           default = null;
182           example = "https://example.org/lists/example-discuss";
183           description = lib.mdDoc "URL where this inbox can be accessed over HTTP.";
184         };
185         options.description = mkOption {
186           type = types.str;
187           example = "user/dev discussion of public-inbox itself";
188           description = lib.mdDoc "User-visible description for the repository.";
189           apply = pkgs.writeText "public-inbox-description-${name}";
190         };
191         options.newsgroup = mkOption {
192           type = with types; nullOr str;
193           default = null;
194           description = lib.mdDoc "NNTP group name for the inbox.";
195         };
196         options.watch = mkOption {
197           type = with types; listOf str;
198           default = [];
199           description = lib.mdDoc "Paths for {manpage}`public-inbox-watch(1)` to monitor for new mail.";
200           example = [ "maildir:/path/to/test.example.com.git" ];
201         };
202         options.watchheader = mkOption {
203           type = with types; nullOr str;
204           default = null;
205           example = "List-Id:<test@example.com>";
206           description = lib.mdDoc ''
207             If specified, {manpage}`public-inbox-watch(1)` will only process
208             mail containing a matching header.
209           '';
210         };
211         options.coderepo = mkOption {
212           type = (types.listOf (types.enum (attrNames cfg.settings.coderepo))) // {
213             description = "list of coderepo names";
214           };
215           default = [];
216           description = lib.mdDoc "Nicknames of a 'coderepo' section associated with the inbox.";
217         };
218       }));
219     };
220     imap = {
221       enable = mkEnableOption (lib.mdDoc "the public-inbox IMAP server");
222     } // publicInboxDaemonOptions "imap" 993;
223     http = {
224       enable = mkEnableOption (lib.mdDoc "the public-inbox HTTP server");
225       mounts = mkOption {
226         type = with types; listOf str;
227         default = [ "/" ];
228         example = [ "/lists/archives" ];
229         description = lib.mdDoc ''
230           Root paths or URLs that public-inbox will be served on.
231           If domain parts are present, only requests to those
232           domains will be accepted.
233         '';
234       };
235       args = (publicInboxDaemonOptions "http" 80).args;
236       port = mkOption {
237         type = with types; nullOr (either str port);
238         default = 80;
239         example = "/run/public-inbox-httpd.sock";
240         description = lib.mdDoc ''
241           Listening port or systemd's ListenStream= entry
242           to be used as a reverse proxy, eg. in nginx:
243           `locations."/inbox".proxyPass = "http://unix:''${config.services.public-inbox.http.port}:/inbox";`
244           Set to null and use `systemd.sockets.public-inbox-httpd.listenStreams`
245           if you need a more advanced listening.
246         '';
247       };
248     };
249     mda = {
250       enable = mkEnableOption (lib.mdDoc "the public-inbox Mail Delivery Agent");
251       args = mkOption {
252         type = with types; listOf str;
253         default = [];
254         description = lib.mdDoc "Command-line arguments to pass to {manpage}`public-inbox-mda(1)`.";
255       };
256     };
257     postfix.enable = mkEnableOption (lib.mdDoc "the integration into Postfix");
258     nntp = {
259       enable = mkEnableOption (lib.mdDoc "the public-inbox NNTP server");
260     } // publicInboxDaemonOptions "nntp" 563;
261     spamAssassinRules = mkOption {
262       type = with types; nullOr path;
263       default = "${cfg.package.sa_config}/user/.spamassassin/user_prefs";
264       defaultText = literalExpression "\${cfg.package.sa_config}/user/.spamassassin/user_prefs";
265       description = lib.mdDoc "SpamAssassin configuration specific to public-inbox.";
266     };
267     settings = mkOption {
268       description = lib.mdDoc ''
269         Settings for the [public-inbox config file](https://public-inbox.org/public-inbox-config.html).
270       '';
271       default = {};
272       type = types.submodule {
273         freeformType = gitIni.type;
274         options.publicinbox = mkOption {
275           default = {};
276           description = lib.mdDoc "public inboxes";
277           type = types.submodule {
278             freeformType = with types; /*inbox name*/attrsOf (/*inbox option name*/attrsOf /*inbox option value*/iniAtom);
279             options.css = mkOption {
280               type = with types; listOf str;
281               default = [];
282               description = lib.mdDoc "The local path name of a CSS file for the PSGI web interface.";
283             };
284             options.nntpserver = mkOption {
285               type = with types; listOf str;
286               default = [];
287               example = [ "nntp://news.public-inbox.org" "nntps://news.public-inbox.org" ];
288               description = lib.mdDoc "NNTP URLs to this public-inbox instance";
289             };
290             options.wwwlisting = mkOption {
291               type = with types; enum [ "all" "404" "match=domain" ];
292               default = "404";
293               description = lib.mdDoc ''
294                 Controls which lists (if any) are listed for when the root
295                 public-inbox URL is accessed over HTTP.
296               '';
297             };
298           };
299         };
300         options.publicinboxmda.spamcheck = mkOption {
301           type = with types; enum [ "spamc" "none" ];
302           default = "none";
303           description = lib.mdDoc ''
304             If set to spamc, {manpage}`public-inbox-watch(1)` will filter spam
305             using SpamAssassin.
306           '';
307         };
308         options.publicinboxwatch.spamcheck = mkOption {
309           type = with types; enum [ "spamc" "none" ];
310           default = "none";
311           description = lib.mdDoc ''
312             If set to spamc, {manpage}`public-inbox-watch(1)` will filter spam
313             using SpamAssassin.
314           '';
315         };
316         options.publicinboxwatch.watchspam = mkOption {
317           type = with types; nullOr str;
318           default = null;
319           example = "maildir:/path/to/spam";
320           description = lib.mdDoc ''
321             If set, mail in this maildir will be trained as spam and
322             deleted from all watched inboxes
323           '';
324         };
325         options.coderepo = mkOption {
326           default = {};
327           description = lib.mdDoc "code repositories";
328           type = types.attrsOf (types.submodule {
329             freeformType = types.attrsOf iniAtom;
330             options.cgitUrl = mkOption {
331               type = types.str;
332               description = lib.mdDoc "URL of a cgit instance";
333             };
334             options.dir = mkOption {
335               type = types.str;
336               description = lib.mdDoc "Path to a git repository";
337             };
338           });
339         };
340       };
341     };
342     openFirewall = mkEnableOption (lib.mdDoc "opening the firewall when using a port option");
343   };
344   config = mkIf cfg.enable {
345     assertions = [
346       { assertion = config.services.spamassassin.enable || !useSpamAssassin;
347         message = ''
348           public-inbox is configured to use SpamAssassin, but
349           services.spamassassin.enable is false.  If you don't need
350           spam checking, set `services.public-inbox.settings.publicinboxmda.spamcheck' and
351           `services.public-inbox.settings.publicinboxwatch.spamcheck' to null.
352         '';
353       }
354       { assertion = cfg.path != [] || !useSpamAssassin;
355         message = ''
356           public-inbox is configured to use SpamAssassin, but there is
357           no spamc executable in services.public-inbox.path.  If you
358           don't need spam checking, set
359           `services.public-inbox.settings.publicinboxmda.spamcheck' and
360           `services.public-inbox.settings.publicinboxwatch.spamcheck' to null.
361         '';
362       }
363     ];
364     services.public-inbox.settings =
365       filterAttrsRecursive (n: v: v != null) {
366         publicinbox = mapAttrs (n: filterAttrs (n: v: n != "description")) cfg.inboxes;
367     };
368     users = {
369       users.public-inbox = {
370         home = stateDir;
371         group = "public-inbox";
372         isSystemUser = true;
373       };
374       groups.public-inbox = {};
375     };
376     networking.firewall = mkIf cfg.openFirewall
377       { allowedTCPPorts = mkMerge
378         (map (proto: (mkIf (cfg.${proto}.enable && types.port.check cfg.${proto}.port) [ cfg.${proto}.port ]))
379         ["imap" "http" "nntp"]);
380       };
381     services.postfix = mkIf (cfg.postfix.enable && cfg.mda.enable) {
382       # Not sure limiting to 1 is necessary, but better safe than sorry.
383       config.public-inbox_destination_recipient_limit = "1";
385       # Register the addresses as existing
386       virtual =
387         concatStringsSep "\n" (mapAttrsToList (_: inbox:
388           concatMapStringsSep "\n" (address:
389             "${address} ${address}"
390           ) inbox.address
391         ) cfg.inboxes);
393       # Deliver the addresses with the public-inbox transport
394       transport =
395         concatStringsSep "\n" (mapAttrsToList (_: inbox:
396           concatMapStringsSep "\n" (address:
397             "${address} public-inbox:${address}"
398           ) inbox.address
399         ) cfg.inboxes);
401       # The public-inbox transport
402       masterConfig.public-inbox = {
403         type = "unix";
404         privileged = true; # Required for user=
405         command = "pipe";
406         args = [
407           "flags=X" # Report as a final delivery
408           "user=${with config.users; users."public-inbox".name + ":" + groups."public-inbox".name}"
409           # Specifying a nexthop when using the transport
410           # (eg. test public-inbox:test) allows to
411           # receive mails with an extension (eg. test+foo).
412           "argv=${pkgs.writeShellScript "public-inbox-transport" ''
413             export HOME="${stateDir}"
414             export ORIGINAL_RECIPIENT="''${2:-1}"
415             export PATH="${makeBinPath cfg.path}:$PATH"
416             exec ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args}
417           ''} \${original_recipient} \${nexthop}"
418         ];
419       };
420     };
421     systemd.sockets = mkMerge (map (proto:
422       mkIf (cfg.${proto}.enable && cfg.${proto}.port != null)
423         { "public-inbox-${proto}d" = {
424             listenStreams = [ (toString cfg.${proto}.port) ];
425             wantedBy = [ "sockets.target" ];
426           };
427         }
428       ) [ "imap" "http" "nntp" ]);
429     systemd.services = mkMerge [
430       (mkIf cfg.imap.enable
431         { public-inbox-imapd = mkMerge [(serviceConfig "imapd") {
432           after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
433           requires = [ "public-inbox-init.service" ];
434           serviceConfig = {
435             ExecStart = escapeShellArgs (
436               [ "${cfg.package}/bin/public-inbox-imapd" ] ++
437               cfg.imap.args ++
438               optionals (cfg.imap.cert != null) [ "--cert" cfg.imap.cert ] ++
439               optionals (cfg.imap.key != null) [ "--key" cfg.imap.key ]
440             );
441           };
442         }];
443       })
444       (mkIf cfg.http.enable
445         { public-inbox-httpd = mkMerge [(serviceConfig "httpd") {
446           after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
447           requires = [ "public-inbox-init.service" ];
448           serviceConfig = {
449             ExecStart = escapeShellArgs (
450               [ "${cfg.package}/bin/public-inbox-httpd" ] ++
451               cfg.http.args ++
452               # See https://public-inbox.org/public-inbox.git/tree/examples/public-inbox.psgi
453               # for upstream's example.
454               [ (pkgs.writeText "public-inbox.psgi" ''
455                 #!${cfg.package.fullperl} -w
456                 use strict;
457                 use warnings;
458                 use Plack::Builder;
459                 use PublicInbox::WWW;
461                 my $www = PublicInbox::WWW->new;
462                 $www->preload;
464                 builder {
465                   # If reached through a reverse proxy,
466                   # make it transparent by resetting some HTTP headers
467                   # used by public-inbox to generate URIs.
468                   enable 'ReverseProxy';
470                   # No need to send a response body if it's an HTTP HEAD requests.
471                   enable 'Head';
473                   # Route according to configured domains and root paths.
474                   ${concatMapStrings (path: ''
475                   mount q(${path}) => sub { $www->call(@_); };
476                   '') cfg.http.mounts}
477                 }
478               '') ]
479             );
480           };
481         }];
482       })
483       (mkIf cfg.nntp.enable
484         { public-inbox-nntpd = mkMerge [(serviceConfig "nntpd") {
485           after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
486           requires = [ "public-inbox-init.service" ];
487           serviceConfig = {
488             ExecStart = escapeShellArgs (
489               [ "${cfg.package}/bin/public-inbox-nntpd" ] ++
490               cfg.nntp.args ++
491               optionals (cfg.nntp.cert != null) [ "--cert" cfg.nntp.cert ] ++
492               optionals (cfg.nntp.key != null) [ "--key" cfg.nntp.key ]
493             );
494           };
495         }];
496       })
497       (mkIf (any (inbox: inbox.watch != []) (attrValues cfg.inboxes)
498         || cfg.settings.publicinboxwatch.watchspam != null)
499         { public-inbox-watch = mkMerge [(serviceConfig "watch") {
500           inherit (cfg) path;
501           wants = [ "public-inbox-init.service" ];
502           requires = [ "public-inbox-init.service" ] ++
503             optional (cfg.settings.publicinboxwatch.spamcheck == "spamc") "spamassassin.service";
504           wantedBy = [ "multi-user.target" ];
505           serviceConfig = {
506             ExecStart = "${cfg.package}/bin/public-inbox-watch";
507             ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
508           };
509         }];
510       })
511       ({ public-inbox-init = let
512           PI_CONFIG = gitIni.generate "public-inbox.ini"
513             (filterAttrsRecursive (n: v: v != null) cfg.settings);
514           in mkMerge [(serviceConfig "init") {
515           wantedBy = [ "multi-user.target" ];
516           restartIfChanged = true;
517           restartTriggers = [ PI_CONFIG ];
518           script = ''
519             set -ux
520             install -D -p ${PI_CONFIG} ${stateDir}/.public-inbox/config
521             '' + optionalString useSpamAssassin ''
522               install -m 0700 -o spamd -d ${stateDir}/.spamassassin
523               ${optionalString (cfg.spamAssassinRules != null) ''
524                 ln -sf ${cfg.spamAssassinRules} ${stateDir}/.spamassassin/user_prefs
525               ''}
526             '' + concatStrings (mapAttrsToList (name: inbox: ''
527               if [ ! -e ${stateDir}/inboxes/${escapeShellArg name} ]; then
528                 # public-inbox-init creates an inbox and adds it to a config file.
529                 # It tries to atomically write the config file by creating
530                 # another file in the same directory, and renaming it.
531                 # This has the sad consequence that we can't use
532                 # /dev/null, or it would try to create a file in /dev.
533                 conf_dir="$(mktemp -d)"
535                 PI_CONFIG=$conf_dir/conf \
536                 ${cfg.package}/bin/public-inbox-init -V2 \
537                   ${escapeShellArgs ([ name "${stateDir}/inboxes/${name}" inbox.url ] ++ inbox.address)}
539                 rm -rf $conf_dir
540               fi
542               ln -sf ${inbox.description} \
543                 ${stateDir}/inboxes/${escapeShellArg name}/description
545               export GIT_DIR=${stateDir}/inboxes/${escapeShellArg name}/all.git
546               if test -d "$GIT_DIR"; then
547                 # Config is inherited by each epoch repository,
548                 # so just needs to be set for all.git.
549                 ${pkgs.git}/bin/git config core.sharedRepository 0640
550               fi
551             '') cfg.inboxes
552             ) + ''
553             shopt -s nullglob
554             for inbox in ${stateDir}/inboxes/*/; do
555               # This should be idempotent, but only do it for new
556               # inboxes anyway because it's only needed once, and could
557               # be slow for large pre-existing inboxes.
558               ls -1 "$inbox" | grep -q '^xap' ||
559               ${cfg.package}/bin/public-inbox-index "$inbox"
560             done
561           '';
562           serviceConfig = {
563             Type = "oneshot";
564             RemainAfterExit = true;
565             StateDirectory = [
566               "public-inbox/.public-inbox"
567               "public-inbox/.public-inbox/emergency"
568               "public-inbox/inboxes"
569             ];
570           };
571         }];
572       })
573     ];
574     environment.systemPackages = with pkgs; [ cfg.package ];
575   };
576   meta.maintainers = with lib.maintainers; [ julm qyliss ];