grafana-alloy: don't build the frontend twice
[NixPkgs.git] / nixos / modules / services / mail / public-inbox.nix
blob98063e0331bd84e8a1d9721367f5a12d9c279109
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 = "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 = ''
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 = "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 = "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 = "tmpfs";
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 "the public-inbox mail archiver";
147     package = mkPackageOption pkgs "public-inbox" { };
148     path = mkOption {
149       type = with types; listOf package;
150       default = [];
151       example = literalExpression "with pkgs; [ spamassassin ]";
152       description = ''
153         Additional packages to place in the path of public-inbox-mda,
154         public-inbox-watch, etc.
155       '';
156     };
157     inboxes = mkOption {
158       description = ''
159         Inboxes to configure, where attribute names are inbox names.
160       '';
161       default = {};
162       type = types.attrsOf (types.submodule ({name, ...}: {
163         freeformType = types.attrsOf iniAtom;
164         options.inboxdir = mkOption {
165           type = types.str;
166           default = "${stateDir}/inboxes/${name}";
167           description = "The absolute path to the directory which hosts the public-inbox.";
168         };
169         options.address = mkOption {
170           type = with types; listOf str;
171           example = "example-discuss@example.org";
172           description = "The email addresses of the public-inbox.";
173         };
174         options.url = mkOption {
175           type = types.nonEmptyStr;
176           example = "https://example.org/lists/example-discuss";
177           description = "URL where this inbox can be accessed over HTTP.";
178         };
179         options.description = mkOption {
180           type = types.str;
181           example = "user/dev discussion of public-inbox itself";
182           description = "User-visible description for the repository.";
183           apply = pkgs.writeText "public-inbox-description-${name}";
184         };
185         options.newsgroup = mkOption {
186           type = with types; nullOr str;
187           default = null;
188           description = "NNTP group name for the inbox.";
189         };
190         options.watch = mkOption {
191           type = with types; listOf str;
192           default = [];
193           description = "Paths for {manpage}`public-inbox-watch(1)` to monitor for new mail.";
194           example = [ "maildir:/path/to/test.example.com.git" ];
195         };
196         options.watchheader = mkOption {
197           type = with types; nullOr str;
198           default = null;
199           example = "List-Id:<test@example.com>";
200           description = ''
201             If specified, {manpage}`public-inbox-watch(1)` will only process
202             mail containing a matching header.
203           '';
204         };
205         options.coderepo = mkOption {
206           type = (types.listOf (types.enum (attrNames cfg.settings.coderepo))) // {
207             description = "list of coderepo names";
208           };
209           default = [];
210           description = "Nicknames of a 'coderepo' section associated with the inbox.";
211         };
212       }));
213     };
214     imap = {
215       enable = mkEnableOption "the public-inbox IMAP server";
216     } // publicInboxDaemonOptions "imap" 993;
217     http = {
218       enable = mkEnableOption "the public-inbox HTTP server";
219       mounts = mkOption {
220         type = with types; listOf str;
221         default = [ "/" ];
222         example = [ "/lists/archives" ];
223         description = ''
224           Root paths or URLs that public-inbox will be served on.
225           If domain parts are present, only requests to those
226           domains will be accepted.
227         '';
228       };
229       args = (publicInboxDaemonOptions "http" 80).args;
230       port = mkOption {
231         type = with types; nullOr (either str port);
232         default = 80;
233         example = "/run/public-inbox-httpd.sock";
234         description = ''
235           Listening port or systemd's ListenStream= entry
236           to be used as a reverse proxy, eg. in nginx:
237           `locations."/inbox".proxyPass = "http://unix:''${config.services.public-inbox.http.port}:/inbox";`
238           Set to null and use `systemd.sockets.public-inbox-httpd.listenStreams`
239           if you need a more advanced listening.
240         '';
241       };
242     };
243     mda = {
244       enable = mkEnableOption "the public-inbox Mail Delivery Agent";
245       args = mkOption {
246         type = with types; listOf str;
247         default = [];
248         description = "Command-line arguments to pass to {manpage}`public-inbox-mda(1)`.";
249       };
250     };
251     postfix.enable = mkEnableOption "the integration into Postfix";
252     nntp = {
253       enable = mkEnableOption "the public-inbox NNTP server";
254     } // publicInboxDaemonOptions "nntp" 563;
255     spamAssassinRules = mkOption {
256       type = with types; nullOr path;
257       default = "${cfg.package.sa_config}/user/.spamassassin/user_prefs";
258       defaultText = literalExpression "\${cfg.package.sa_config}/user/.spamassassin/user_prefs";
259       description = "SpamAssassin configuration specific to public-inbox.";
260     };
261     settings = mkOption {
262       description = ''
263         Settings for the [public-inbox config file](https://public-inbox.org/public-inbox-config.html).
264       '';
265       default = {};
266       type = types.submodule {
267         freeformType = gitIni.type;
268         options.publicinbox = mkOption {
269           default = {};
270           description = "public inboxes";
271           type = types.submodule {
272             # Support both global options like `services.public-inbox.settings.publicinbox.imapserver`
273             # and inbox specific options like `services.public-inbox.settings.publicinbox.foo.address`.
274             freeformType = with types; attrsOf (oneOf [ iniAtom (attrsOf iniAtom) ]);
276             options.css = mkOption {
277               type = with types; listOf str;
278               default = [];
279               description = "The local path name of a CSS file for the PSGI web interface.";
280             };
281             options.imapserver = mkOption {
282               type = with types; listOf str;
283               default = [];
284               example = [ "imap.public-inbox.org" ];
285               description = "IMAP URLs to this public-inbox instance";
286             };
287             options.nntpserver = mkOption {
288               type = with types; listOf str;
289               default = [];
290               example = [ "nntp://news.public-inbox.org" "nntps://news.public-inbox.org" ];
291               description = "NNTP URLs to this public-inbox instance";
292             };
293             options.pop3server = mkOption {
294               type = with types; listOf str;
295               default = [];
296               example = [ "pop.public-inbox.org" ];
297               description = "POP3 URLs to this public-inbox instance";
298             };
299             options.wwwlisting = mkOption {
300               type = with types; enum [ "all" "404" "match=domain" ];
301               default = "404";
302               description = ''
303                 Controls which lists (if any) are listed for when the root
304                 public-inbox URL is accessed over HTTP.
305               '';
306             };
307           };
308         };
309         options.publicinboxmda.spamcheck = mkOption {
310           type = with types; enum [ "spamc" "none" ];
311           default = "none";
312           description = ''
313             If set to spamc, {manpage}`public-inbox-watch(1)` will filter spam
314             using SpamAssassin.
315           '';
316         };
317         options.publicinboxwatch.spamcheck = mkOption {
318           type = with types; enum [ "spamc" "none" ];
319           default = "none";
320           description = ''
321             If set to spamc, {manpage}`public-inbox-watch(1)` will filter spam
322             using SpamAssassin.
323           '';
324         };
325         options.publicinboxwatch.watchspam = mkOption {
326           type = with types; nullOr str;
327           default = null;
328           example = "maildir:/path/to/spam";
329           description = ''
330             If set, mail in this maildir will be trained as spam and
331             deleted from all watched inboxes
332           '';
333         };
334         options.coderepo = mkOption {
335           default = {};
336           description = "code repositories";
337           type = types.attrsOf (types.submodule {
338             freeformType = types.attrsOf iniAtom;
339             options.cgitUrl = mkOption {
340               type = types.str;
341               description = "URL of a cgit instance";
342             };
343             options.dir = mkOption {
344               type = types.str;
345               description = "Path to a git repository";
346             };
347           });
348         };
349       };
350     };
351     openFirewall = mkEnableOption "opening the firewall when using a port option";
352   };
353   config = mkIf cfg.enable {
354     assertions = [
355       { assertion = config.services.spamassassin.enable || !useSpamAssassin;
356         message = ''
357           public-inbox is configured to use SpamAssassin, but
358           services.spamassassin.enable is false.  If you don't need
359           spam checking, set `services.public-inbox.settings.publicinboxmda.spamcheck' and
360           `services.public-inbox.settings.publicinboxwatch.spamcheck' to null.
361         '';
362       }
363       { assertion = cfg.path != [] || !useSpamAssassin;
364         message = ''
365           public-inbox is configured to use SpamAssassin, but there is
366           no spamc executable in services.public-inbox.path.  If you
367           don't need spam checking, set
368           `services.public-inbox.settings.publicinboxmda.spamcheck' and
369           `services.public-inbox.settings.publicinboxwatch.spamcheck' to null.
370         '';
371       }
372     ];
373     services.public-inbox.settings =
374       filterAttrsRecursive (n: v: v != null) {
375         publicinbox = mapAttrs (n: filterAttrs (n: v: n != "description")) cfg.inboxes;
376     };
377     users = {
378       users.public-inbox = {
379         home = stateDir;
380         group = "public-inbox";
381         isSystemUser = true;
382       };
383       groups.public-inbox = {};
384     };
385     networking.firewall = mkIf cfg.openFirewall
386       { allowedTCPPorts = mkMerge
387         (map (proto: (mkIf (cfg.${proto}.enable && types.port.check cfg.${proto}.port) [ cfg.${proto}.port ]))
388         ["imap" "http" "nntp"]);
389       };
390     services.postfix = mkIf (cfg.postfix.enable && cfg.mda.enable) {
391       # Not sure limiting to 1 is necessary, but better safe than sorry.
392       config.public-inbox_destination_recipient_limit = "1";
394       # Register the addresses as existing
395       virtual =
396         concatStringsSep "\n" (mapAttrsToList (_: inbox:
397           concatMapStringsSep "\n" (address:
398             "${address} ${address}"
399           ) inbox.address
400         ) cfg.inboxes);
402       # Deliver the addresses with the public-inbox transport
403       transport =
404         concatStringsSep "\n" (mapAttrsToList (_: inbox:
405           concatMapStringsSep "\n" (address:
406             "${address} public-inbox:${address}"
407           ) inbox.address
408         ) cfg.inboxes);
410       # The public-inbox transport
411       masterConfig.public-inbox = {
412         type = "unix";
413         privileged = true; # Required for user=
414         command = "pipe";
415         args = [
416           "flags=X" # Report as a final delivery
417           "user=${with config.users; users."public-inbox".name + ":" + groups."public-inbox".name}"
418           # Specifying a nexthop when using the transport
419           # (eg. test public-inbox:test) allows to
420           # receive mails with an extension (eg. test+foo).
421           "argv=${pkgs.writeShellScript "public-inbox-transport" ''
422             export HOME="${stateDir}"
423             export ORIGINAL_RECIPIENT="''${2:-1}"
424             export PATH="${makeBinPath cfg.path}:$PATH"
425             exec ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args}
426           ''} \${original_recipient} \${nexthop}"
427         ];
428       };
429     };
430     systemd.sockets = mkMerge (map (proto:
431       mkIf (cfg.${proto}.enable && cfg.${proto}.port != null)
432         { "public-inbox-${proto}d" = {
433             listenStreams = [ (toString cfg.${proto}.port) ];
434             wantedBy = [ "sockets.target" ];
435           };
436         }
437       ) [ "imap" "http" "nntp" ]);
438     systemd.services = mkMerge [
439       (mkIf cfg.imap.enable
440         { public-inbox-imapd = mkMerge [(serviceConfig "imapd") {
441           after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
442           requires = [ "public-inbox-init.service" ];
443           serviceConfig = {
444             ExecStart = escapeShellArgs (
445               [ "${cfg.package}/bin/public-inbox-imapd" ] ++
446               cfg.imap.args ++
447               optionals (cfg.imap.cert != null) [ "--cert" cfg.imap.cert ] ++
448               optionals (cfg.imap.key != null) [ "--key" cfg.imap.key ]
449             );
450           };
451         }];
452       })
453       (mkIf cfg.http.enable
454         { public-inbox-httpd = mkMerge [(serviceConfig "httpd") {
455           after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
456           requires = [ "public-inbox-init.service" ];
457           serviceConfig = {
458             BindReadOnlyPaths =
459               map (c: c.dir) (lib.attrValues cfg.settings.coderepo);
460             ExecStart = escapeShellArgs (
461               [ "${cfg.package}/bin/public-inbox-httpd" ] ++
462               cfg.http.args ++
463               # See https://public-inbox.org/public-inbox.git/tree/examples/public-inbox.psgi
464               # for upstream's example.
465               [ (pkgs.writeText "public-inbox.psgi" ''
466                 #!${cfg.package.fullperl} -w
467                 use strict;
468                 use warnings;
469                 use Plack::Builder;
470                 use PublicInbox::WWW;
472                 my $www = PublicInbox::WWW->new;
473                 $www->preload;
475                 builder {
476                   # If reached through a reverse proxy,
477                   # make it transparent by resetting some HTTP headers
478                   # used by public-inbox to generate URIs.
479                   enable 'ReverseProxy';
481                   # No need to send a response body if it's an HTTP HEAD requests.
482                   enable 'Head';
484                   # Route according to configured domains and root paths.
485                   ${concatMapStrings (path: ''
486                   mount q(${path}) => sub { $www->call(@_); };
487                   '') cfg.http.mounts}
488                 }
489               '') ]
490             );
491           };
492         }];
493       })
494       (mkIf cfg.nntp.enable
495         { public-inbox-nntpd = mkMerge [(serviceConfig "nntpd") {
496           after = [ "public-inbox-init.service" "public-inbox-watch.service" ];
497           requires = [ "public-inbox-init.service" ];
498           serviceConfig = {
499             ExecStart = escapeShellArgs (
500               [ "${cfg.package}/bin/public-inbox-nntpd" ] ++
501               cfg.nntp.args ++
502               optionals (cfg.nntp.cert != null) [ "--cert" cfg.nntp.cert ] ++
503               optionals (cfg.nntp.key != null) [ "--key" cfg.nntp.key ]
504             );
505           };
506         }];
507       })
508       (mkIf (any (inbox: inbox.watch != []) (attrValues cfg.inboxes)
509         || cfg.settings.publicinboxwatch.watchspam != null)
510         { public-inbox-watch = mkMerge [(serviceConfig "watch") {
511           inherit (cfg) path;
512           wants = [ "public-inbox-init.service" ];
513           requires = [ "public-inbox-init.service" ] ++
514             optional (cfg.settings.publicinboxwatch.spamcheck == "spamc") "spamassassin.service";
515           wantedBy = [ "multi-user.target" ];
516           serviceConfig = {
517             ExecStart = "${cfg.package}/bin/public-inbox-watch";
518             ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
519           };
520         }];
521       })
522       ({ public-inbox-init = let
523           PI_CONFIG = gitIni.generate "public-inbox.ini"
524             (filterAttrsRecursive (n: v: v != null) cfg.settings);
525           in mkMerge [(serviceConfig "init") {
526           wantedBy = [ "multi-user.target" ];
527           restartIfChanged = true;
528           restartTriggers = [ PI_CONFIG ];
529           script = ''
530             set -ux
531             install -D -p ${PI_CONFIG} ${stateDir}/.public-inbox/config
532             '' + optionalString useSpamAssassin ''
533               install -m 0700 -o spamd -d ${stateDir}/.spamassassin
534               ${optionalString (cfg.spamAssassinRules != null) ''
535                 ln -sf ${cfg.spamAssassinRules} ${stateDir}/.spamassassin/user_prefs
536               ''}
537             '' + concatStrings (mapAttrsToList (name: inbox: ''
538               if [ ! -e ${stateDir}/inboxes/${escapeShellArg name} ]; then
539                 # public-inbox-init creates an inbox and adds it to a config file.
540                 # It tries to atomically write the config file by creating
541                 # another file in the same directory, and renaming it.
542                 # This has the sad consequence that we can't use
543                 # /dev/null, or it would try to create a file in /dev.
544                 conf_dir="$(mktemp -d)"
546                 PI_CONFIG=$conf_dir/conf \
547                 ${cfg.package}/bin/public-inbox-init -V2 \
548                   ${escapeShellArgs ([ name "${stateDir}/inboxes/${name}" inbox.url ] ++ inbox.address)}
550                 rm -rf $conf_dir
551               fi
553               ln -sf ${inbox.description} \
554                 ${stateDir}/inboxes/${escapeShellArg name}/description
556               export GIT_DIR=${stateDir}/inboxes/${escapeShellArg name}/all.git
557               if test -d "$GIT_DIR"; then
558                 # Config is inherited by each epoch repository,
559                 # so just needs to be set for all.git.
560                 ${pkgs.git}/bin/git config core.sharedRepository 0640
561               fi
562             '') cfg.inboxes
563             );
564           serviceConfig = {
565             Type = "oneshot";
566             RemainAfterExit = true;
567             StateDirectory = [
568               "public-inbox/.public-inbox"
569               "public-inbox/.public-inbox/emergency"
570               "public-inbox/inboxes"
571             ];
572           };
573         }];
574       })
575     ];
576     environment.systemPackages = with pkgs; [ cfg.package ];
577   };
578   meta.maintainers = with lib.maintainers; [ julm qyliss ];