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