at master 24 kB view raw
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}