]> Git — Sourcephile - sourcephile-nix.git/blob - nixos/modules/services/mail/public-inbox.nix
public-inbox: use custom module
[sourcephile-nix.git] / nixos / modules / services / mail / public-inbox.nix
1 {
2 lib,
3 pkgs,
4 config,
5 ...
6 }:
7
8 with lib;
9
10 let
11 cfg = config.services.public-inbox;
12 stateDir = "/var/lib/public-inbox";
13
14 gitIni = pkgs.formats.gitIni { listsAsDuplicateKeys = true; };
15 iniAtom = gitIni.lib.types.atom;
16
17 useSpamAssassin =
18 cfg.settings.publicinboxmda.spamcheck == "spamc"
19 || cfg.settings.publicinboxwatch.spamcheck == "spamc";
20
21 publicInboxDaemonOptions = proto: defaultPort: {
22 args = mkOption {
23 type = with types; listOf str;
24 default = [ ];
25 description = "Command-line arguments to pass to {manpage}`public-inbox-${proto}d(1)`.";
26 };
27 port = mkOption {
28 type = with types; nullOr (either str port);
29 default = defaultPort;
30 description = ''
31 Listening port.
32 Beware that public-inbox uses well-known ports number to decide whether to enable TLS or not.
33 Set to null and use `systemd.sockets.public-inbox-${proto}d.listenStreams`
34 if you need a more advanced listening.
35 '';
36 };
37 cert = mkOption {
38 type = with types; nullOr str;
39 default = null;
40 example = "/path/to/fullchain.pem";
41 description = "Path to TLS certificate to use for connections to {manpage}`public-inbox-${proto}d(1)`.";
42 };
43 key = mkOption {
44 type = with types; nullOr str;
45 default = null;
46 example = "/path/to/key.pem";
47 description = "Path to TLS key to use for connections to {manpage}`public-inbox-${proto}d(1)`.";
48 };
49 };
50
51 serviceConfig =
52 srv:
53 let
54 proto = removeSuffix "d" srv;
55 needNetwork = builtins.hasAttr proto cfg && cfg.${proto}.port == null;
56 in
57 {
58 serviceConfig = {
59 # Enable JIT-compiled C (via Inline::C)
60 Environment = [ "PERL_INLINE_DIRECTORY=/run/public-inbox-${srv}/perl-inline" ];
61 # NonBlocking is REQUIRED to avoid a race condition
62 # if running simultaneous services.
63 NonBlocking = true;
64 #LimitNOFILE = 30000;
65 User = config.users.users."public-inbox".name;
66 Group = config.users.groups."public-inbox".name;
67 RuntimeDirectory = [
68 "public-inbox-${srv}/perl-inline"
69 ];
70 RuntimeDirectoryMode = "700";
71 # This is for BindPaths= and BindReadOnlyPaths=
72 # to allow traversal of directories they create inside RootDirectory=
73 UMask = "0066";
74 StateDirectory = [ "public-inbox" ];
75 StateDirectoryMode = "0750";
76 WorkingDirectory = stateDir;
77 BindReadOnlyPaths =
78 [
79 "/etc"
80 "/run/systemd"
81 "${config.i18n.glibcLocales}"
82 ]
83 ++ mapAttrsToList (name: inbox: inbox.description) cfg.inboxes
84 ++ filter (x: x != null) [
85 cfg.${proto}.cert or null
86 cfg.${proto}.key or null
87 ];
88 # The following options are only for optimizing:
89 # systemd-analyze security public-inbox-'*'
90 AmbientCapabilities = "";
91 CapabilityBoundingSet = "";
92 # ProtectClock= adds DeviceAllow=char-rtc r
93 DeviceAllow = "";
94 LockPersonality = true;
95 MemoryDenyWriteExecute = true;
96 NoNewPrivileges = true;
97 PrivateNetwork = mkDefault (!needNetwork);
98 ProcSubset = "pid";
99 ProtectClock = true;
100 ProtectHome = "tmpfs";
101 ProtectHostname = true;
102 ProtectKernelLogs = true;
103 ProtectProc = "invisible";
104 ProtectSystem = "strict";
105 RemoveIPC = true;
106 RestrictAddressFamilies =
107 [ "AF_UNIX" ]
108 ++ optionals needNetwork [
109 "AF_INET"
110 "AF_INET6"
111 ];
112 RestrictNamespaces = true;
113 RestrictRealtime = true;
114 RestrictSUIDSGID = true;
115 SystemCallFilter = [
116 "@system-service"
117 "~@aio"
118 "~@chown"
119 "~@keyring"
120 "~@memlock"
121 "~@resources"
122 # Not removing @setuid and @privileged because Inline::C needs them.
123 # Not removing @timer because git upload-pack needs it.
124 ];
125 SystemCallArchitectures = "native";
126 };
127 confinement = {
128 enable = true;
129 mode = "full-apivfs";
130 # Inline::C needs a /bin/sh, and dash is enough
131 binSh = "${pkgs.dash}/bin/dash";
132 packages = [
133 pkgs.iana-etc
134 (getLib pkgs.nss)
135 pkgs.tzdata
136 ];
137 };
138 };
139 in
140
141 {
142 options.services.public-inbox = {
143 enable = mkEnableOption "the public-inbox mail archiver";
144 package = mkPackageOption pkgs "public-inbox" { };
145 path = mkOption {
146 type = with types; listOf package;
147 default = [ ];
148 example = literalExpression "with pkgs; [ spamassassin ]";
149 description = ''
150 Additional packages to place in the path of public-inbox-mda,
151 public-inbox-watch, etc.
152 '';
153 };
154 inboxes = mkOption {
155 description = ''
156 Inboxes to configure, where attribute names are inbox names.
157 '';
158 default = { };
159 type = types.attrsOf (
160 types.submodule (
161 { name, ... }:
162 {
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 );
215 };
216 imap = {
217 enable = mkEnableOption "the public-inbox IMAP server";
218 } // publicInboxDaemonOptions "imap" 993;
219 http = {
220 enable = mkEnableOption "the public-inbox HTTP server";
221 mounts = mkOption {
222 type = with types; listOf str;
223 default = [ "/" ];
224 example = [ "/lists/archives" ];
225 description = ''
226 Root paths or URLs that public-inbox will be served on.
227 If domain parts are present, only requests to those
228 domains will be accepted.
229 '';
230 };
231 args = (publicInboxDaemonOptions "http" 80).args;
232 port = mkOption {
233 type = with types; nullOr (either str port);
234 default = 80;
235 example = "/run/public-inbox-httpd.sock";
236 description = ''
237 Listening port or systemd's ListenStream= entry
238 to be used as a reverse proxy, eg. in nginx:
239 `locations."/inbox".proxyPass = "http://unix:''${config.services.public-inbox.http.port}:/inbox";`
240 Set to null and use `systemd.sockets.public-inbox-httpd.listenStreams`
241 if you need a more advanced listening.
242 '';
243 };
244 };
245 mda = {
246 enable = mkEnableOption "the public-inbox Mail Delivery Agent";
247 args = mkOption {
248 type = with types; listOf str;
249 default = [ ];
250 description = "Command-line arguments to pass to {manpage}`public-inbox-mda(1)`.";
251 };
252 };
253 postfix.enable = mkEnableOption "the integration into Postfix";
254 nntp = {
255 enable = mkEnableOption "the public-inbox NNTP server";
256 } // publicInboxDaemonOptions "nntp" 563;
257 spamAssassinRules = mkOption {
258 type = with types; nullOr path;
259 default = "${cfg.package.sa_config}/user/.spamassassin/user_prefs";
260 defaultText = literalExpression "\${cfg.package.sa_config}/user/.spamassassin/user_prefs";
261 description = "SpamAssassin configuration specific to public-inbox.";
262 };
263 settings = mkOption {
264 description = ''
265 Settings for the [public-inbox config file](https://public-inbox.org/public-inbox-config.html).
266 '';
267 default = { };
268 type = types.submodule {
269 freeformType = gitIni.type;
270 options.publicinbox = mkOption {
271 default = { };
272 description = "public inboxes";
273 type = types.submodule {
274 # Support both global options like `services.public-inbox.settings.publicinbox.imapserver`
275 # and inbox specific options like `services.public-inbox.settings.publicinbox.foo.address`.
276 freeformType =
277 with types;
278 attrsOf (oneOf [
279 iniAtom
280 (attrsOf iniAtom)
281 ]);
282
283 options.css = mkOption {
284 type = with types; listOf str;
285 default = [ ];
286 description = "The local path name of a CSS file for the PSGI web interface.";
287 };
288 options.imapserver = mkOption {
289 type = with types; listOf str;
290 default = [ ];
291 example = [ "imap.public-inbox.org" ];
292 description = "IMAP URLs to this public-inbox instance";
293 };
294 options.nntpserver = mkOption {
295 type = with types; listOf str;
296 default = [ ];
297 example = [
298 "nntp://news.public-inbox.org"
299 "nntps://news.public-inbox.org"
300 ];
301 description = "NNTP URLs to this public-inbox instance";
302 };
303 options.pop3server = mkOption {
304 type = with types; listOf str;
305 default = [ ];
306 example = [ "pop.public-inbox.org" ];
307 description = "POP3 URLs to this public-inbox instance";
308 };
309 options.wwwlisting = mkOption {
310 type =
311 with types;
312 enum [
313 "all"
314 "404"
315 "match=domain"
316 ];
317 default = "404";
318 description = ''
319 Controls which lists (if any) are listed for when the root
320 public-inbox URL is accessed over HTTP.
321 '';
322 };
323 };
324 };
325 options.publicinboxmda.spamcheck = mkOption {
326 type =
327 with types;
328 enum [
329 "spamc"
330 "none"
331 ];
332 default = "none";
333 description = ''
334 If set to spamc, {manpage}`public-inbox-watch(1)` will filter spam
335 using SpamAssassin.
336 '';
337 };
338 options.publicinboxwatch.spamcheck = mkOption {
339 type =
340 with types;
341 enum [
342 "spamc"
343 "none"
344 ];
345 default = "none";
346 description = ''
347 If set to spamc, {manpage}`public-inbox-watch(1)` will filter spam
348 using SpamAssassin.
349 '';
350 };
351 options.publicinboxwatch.watchspam = mkOption {
352 type = with types; nullOr str;
353 default = null;
354 example = "maildir:/path/to/spam";
355 description = ''
356 If set, mail in this maildir will be trained as spam and
357 deleted from all watched inboxes
358 '';
359 };
360 options.coderepo = mkOption {
361 default = { };
362 description = "code repositories";
363 type = types.attrsOf (
364 types.submodule {
365 freeformType = types.attrsOf iniAtom;
366 options.cgitUrl = mkOption {
367 type = types.str;
368 description = "URL of a cgit instance";
369 };
370 options.dir = mkOption {
371 type = types.str;
372 description = "Path to a git repository";
373 };
374 }
375 );
376 };
377 };
378 };
379 openFirewall = mkEnableOption "opening the firewall when using a port option";
380 };
381 config = mkIf cfg.enable {
382 assertions = [
383 {
384 assertion = config.services.spamassassin.enable || !useSpamAssassin;
385 message = ''
386 public-inbox is configured to use SpamAssassin, but
387 services.spamassassin.enable is false. If you don't need
388 spam checking, set `services.public-inbox.settings.publicinboxmda.spamcheck' and
389 `services.public-inbox.settings.publicinboxwatch.spamcheck' to null.
390 '';
391 }
392 {
393 assertion = cfg.path != [ ] || !useSpamAssassin;
394 message = ''
395 public-inbox is configured to use SpamAssassin, but there is
396 no spamc executable in services.public-inbox.path. If you
397 don't need spam checking, set
398 `services.public-inbox.settings.publicinboxmda.spamcheck' and
399 `services.public-inbox.settings.publicinboxwatch.spamcheck' to null.
400 '';
401 }
402 ];
403 services.public-inbox.settings = filterAttrsRecursive (n: v: v != null) {
404 publicinbox = mapAttrs (n: filterAttrs (n: v: n != "description")) cfg.inboxes;
405 };
406 users = {
407 users.public-inbox = {
408 home = stateDir;
409 group = "public-inbox";
410 isSystemUser = true;
411 };
412 groups.public-inbox = { };
413 };
414 networking.firewall = mkIf cfg.openFirewall {
415 allowedTCPPorts = mkMerge (
416 map
417 (proto: (mkIf (cfg.${proto}.enable && types.port.check cfg.${proto}.port) [ cfg.${proto}.port ]))
418 [
419 "imap"
420 "http"
421 "nntp"
422 ]
423 );
424 };
425 services.postfix = mkIf (cfg.postfix.enable && cfg.mda.enable) {
426 # Not sure limiting to 1 is necessary, but better safe than sorry.
427 config.public-inbox_destination_recipient_limit = "1";
428
429 # Register the addresses as existing
430 virtual = concatStringsSep "\n" (
431 mapAttrsToList (
432 _: inbox: concatMapStringsSep "\n" (address: "${address} ${address}") inbox.address
433 ) cfg.inboxes
434 );
435
436 # Deliver the addresses with the public-inbox transport
437 transport = concatStringsSep "\n" (
438 mapAttrsToList (
439 _: inbox: concatMapStringsSep "\n" (address: "${address} public-inbox:${address}") inbox.address
440 ) cfg.inboxes
441 );
442
443 # The public-inbox transport
444 masterConfig.public-inbox = {
445 type = "unix";
446 privileged = true; # Required for user=
447 command = "pipe";
448 args = [
449 "flags=X" # Report as a final delivery
450 "user=${with config.users; users."public-inbox".name + ":" + groups."public-inbox".name}"
451 # Specifying a nexthop when using the transport
452 # (eg. test public-inbox:test) allows to
453 # receive mails with an extension (eg. test+foo).
454 "argv=${pkgs.writeShellScript "public-inbox-transport" ''
455 export HOME="${stateDir}"
456 export ORIGINAL_RECIPIENT="''${2:-1}"
457 export PATH="${makeBinPath cfg.path}:$PATH"
458 exec ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args}
459 ''} \${original_recipient} \${nexthop}"
460 ];
461 };
462 };
463 systemd.sockets = mkMerge (
464 map
465 (
466 proto:
467 mkIf (cfg.${proto}.enable && cfg.${proto}.port != null) {
468 "public-inbox-${proto}d" = {
469 listenStreams = [ (toString cfg.${proto}.port) ];
470 wantedBy = [ "sockets.target" ];
471 };
472 }
473 )
474 [
475 "imap"
476 "http"
477 "nntp"
478 ]
479 );
480 systemd.services = mkMerge [
481 (mkIf cfg.imap.enable {
482 public-inbox-imapd = mkMerge [
483 (serviceConfig "imapd")
484 {
485 after = [
486 "public-inbox-init.service"
487 "public-inbox-watch.service"
488 ];
489 requires = [ "public-inbox-init.service" ];
490 serviceConfig = {
491 ExecStart = escapeShellArgs (
492 [ "${cfg.package}/bin/public-inbox-imapd" ]
493 ++ cfg.imap.args
494 ++ optionals (cfg.imap.cert != null) [
495 "--cert"
496 cfg.imap.cert
497 ]
498 ++ optionals (cfg.imap.key != null) [
499 "--key"
500 cfg.imap.key
501 ]
502 );
503 };
504 }
505 ];
506 })
507 (mkIf cfg.http.enable {
508 public-inbox-httpd = mkMerge [
509 (serviceConfig "httpd")
510 {
511 after = [
512 "public-inbox-init.service"
513 "public-inbox-watch.service"
514 ];
515 requires = [ "public-inbox-init.service" ];
516 serviceConfig = {
517 BindReadOnlyPaths = map (c: c.dir) (lib.attrValues cfg.settings.coderepo);
518 ExecStart = escapeShellArgs (
519 [ "${cfg.package}/bin/public-inbox-httpd" ]
520 ++ cfg.http.args
521 ++
522 # See https://public-inbox.org/public-inbox.git/tree/examples/public-inbox.psgi
523 # for upstream's example.
524 [
525 (pkgs.writeText "public-inbox.psgi" ''
526 #!${cfg.package.fullperl} -w
527 use strict;
528 use warnings;
529 use Plack::Builder;
530 use PublicInbox::WWW;
531
532 my $www = PublicInbox::WWW->new;
533 $www->preload;
534
535 builder {
536 # If reached through a reverse proxy,
537 # make it transparent by resetting some HTTP headers
538 # used by public-inbox to generate URIs.
539 enable 'ReverseProxy';
540
541 # No need to send a response body if it's an HTTP HEAD requests.
542 enable 'Head';
543
544 # Route according to configured domains and root paths.
545 ${concatMapStrings (path: ''
546 mount q(${path}) => sub { $www->call(@_); };
547 '') cfg.http.mounts}
548 }
549 '')
550 ]
551 );
552 };
553 }
554 ];
555 })
556 (mkIf cfg.nntp.enable {
557 public-inbox-nntpd = mkMerge [
558 (serviceConfig "nntpd")
559 {
560 after = [
561 "public-inbox-init.service"
562 "public-inbox-watch.service"
563 ];
564 requires = [ "public-inbox-init.service" ];
565 serviceConfig = {
566 ExecStart = escapeShellArgs (
567 [ "${cfg.package}/bin/public-inbox-nntpd" ]
568 ++ cfg.nntp.args
569 ++ optionals (cfg.nntp.cert != null) [
570 "--cert"
571 cfg.nntp.cert
572 ]
573 ++ optionals (cfg.nntp.key != null) [
574 "--key"
575 cfg.nntp.key
576 ]
577 );
578 };
579 }
580 ];
581 })
582 (mkIf
583 (
584 any (inbox: inbox.watch != [ ]) (attrValues cfg.inboxes)
585 || cfg.settings.publicinboxwatch.watchspam != null
586 )
587 {
588 public-inbox-watch = mkMerge [
589 (serviceConfig "watch")
590 {
591 inherit (cfg) path;
592 wants = [ "public-inbox-init.service" ];
593 requires = [
594 "public-inbox-init.service"
595 ] ++ optional (cfg.settings.publicinboxwatch.spamcheck == "spamc") "spamassassin.service";
596 wantedBy = [ "multi-user.target" ];
597 serviceConfig = {
598 ExecStart = "${cfg.package}/bin/public-inbox-watch";
599 ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
600 };
601 }
602 ];
603 }
604 )
605 ({
606 public-inbox-init =
607 let
608 PI_CONFIG = gitIni.generate "public-inbox.ini" (
609 filterAttrsRecursive (n: v: v != null) cfg.settings
610 );
611 in
612 mkMerge [
613 (serviceConfig "init")
614 {
615 wantedBy = [ "multi-user.target" ];
616 restartIfChanged = true;
617 restartTriggers = [ PI_CONFIG ];
618 script =
619 ''
620 set -ux
621 install -D -p ${PI_CONFIG} ${stateDir}/.public-inbox/config
622 ''
623 + optionalString useSpamAssassin ''
624 install -m 0700 -o spamd -d ${stateDir}/.spamassassin
625 ${optionalString (cfg.spamAssassinRules != null) ''
626 ln -sf ${cfg.spamAssassinRules} ${stateDir}/.spamassassin/user_prefs
627 ''}
628 ''
629 + concatStrings (
630 mapAttrsToList (name: inbox: ''
631 if [ ! -e ${stateDir}/inboxes/${escapeShellArg name} ]; then
632 # public-inbox-init creates an inbox and adds it to a config file.
633 # It tries to atomically write the config file by creating
634 # another file in the same directory, and renaming it.
635 # This has the sad consequence that we can't use
636 # /dev/null, or it would try to create a file in /dev.
637 conf_dir="$(mktemp -d)"
638
639 PI_CONFIG=$conf_dir/conf \
640 ${cfg.package}/bin/public-inbox-init -V2 \
641 ${escapeShellArgs (
642 [
643 name
644 "${stateDir}/inboxes/${name}"
645 inbox.url
646 ]
647 ++ inbox.address
648 )}
649
650 rm -rf $conf_dir
651 fi
652
653 ln -sf ${inbox.description} \
654 ${stateDir}/inboxes/${escapeShellArg name}/description
655
656 export GIT_DIR=${stateDir}/inboxes/${escapeShellArg name}/all.git
657 if test -d "$GIT_DIR"; then
658 # Config is inherited by each epoch repository,
659 # so just needs to be set for all.git.
660 ${pkgs.git}/bin/git config core.sharedRepository 0640
661 fi
662 '') cfg.inboxes
663 );
664 serviceConfig = {
665 Type = "oneshot";
666 RemainAfterExit = true;
667 StateDirectory = [
668 "public-inbox/.public-inbox"
669 "public-inbox/.public-inbox/emergency"
670 "public-inbox/inboxes"
671 ];
672 };
673 }
674 ];
675 })
676 ];
677 environment.systemPackages = with pkgs; [ cfg.package ];
678 };
679 meta.maintainers = with lib.maintainers; [
680 julm
681 qyliss
682 ];
683 }