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