at 25.11-pre 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 [ 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 }; 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 } // 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}