at 23.05-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 freeformType = with types; /*inbox name*/attrsOf (/*inbox option name*/attrsOf /*inbox option value*/iniAtom); 279 options.css = mkOption { 280 type = with types; listOf str; 281 default = []; 282 description = lib.mdDoc "The local path name of a CSS file for the PSGI web interface."; 283 }; 284 options.nntpserver = mkOption { 285 type = with types; listOf str; 286 default = []; 287 example = [ "nntp://news.public-inbox.org" "nntps://news.public-inbox.org" ]; 288 description = lib.mdDoc "NNTP URLs to this public-inbox instance"; 289 }; 290 options.wwwlisting = mkOption { 291 type = with types; enum [ "all" "404" "match=domain" ]; 292 default = "404"; 293 description = lib.mdDoc '' 294 Controls which lists (if any) are listed for when the root 295 public-inbox URL is accessed over HTTP. 296 ''; 297 }; 298 }; 299 }; 300 options.publicinboxmda.spamcheck = mkOption { 301 type = with types; enum [ "spamc" "none" ]; 302 default = "none"; 303 description = lib.mdDoc '' 304 If set to spamc, {manpage}`public-inbox-watch(1)` will filter spam 305 using SpamAssassin. 306 ''; 307 }; 308 options.publicinboxwatch.spamcheck = mkOption { 309 type = with types; enum [ "spamc" "none" ]; 310 default = "none"; 311 description = lib.mdDoc '' 312 If set to spamc, {manpage}`public-inbox-watch(1)` will filter spam 313 using SpamAssassin. 314 ''; 315 }; 316 options.publicinboxwatch.watchspam = mkOption { 317 type = with types; nullOr str; 318 default = null; 319 example = "maildir:/path/to/spam"; 320 description = lib.mdDoc '' 321 If set, mail in this maildir will be trained as spam and 322 deleted from all watched inboxes 323 ''; 324 }; 325 options.coderepo = mkOption { 326 default = {}; 327 description = lib.mdDoc "code repositories"; 328 type = types.attrsOf (types.submodule { 329 freeformType = types.attrsOf iniAtom; 330 options.cgitUrl = mkOption { 331 type = types.str; 332 description = lib.mdDoc "URL of a cgit instance"; 333 }; 334 options.dir = mkOption { 335 type = types.str; 336 description = lib.mdDoc "Path to a git repository"; 337 }; 338 }); 339 }; 340 }; 341 }; 342 openFirewall = mkEnableOption (lib.mdDoc "opening the firewall when using a port option"); 343 }; 344 config = mkIf cfg.enable { 345 assertions = [ 346 { assertion = config.services.spamassassin.enable || !useSpamAssassin; 347 message = '' 348 public-inbox is configured to use SpamAssassin, but 349 services.spamassassin.enable is false. If you don't need 350 spam checking, set `services.public-inbox.settings.publicinboxmda.spamcheck' and 351 `services.public-inbox.settings.publicinboxwatch.spamcheck' to null. 352 ''; 353 } 354 { assertion = cfg.path != [] || !useSpamAssassin; 355 message = '' 356 public-inbox is configured to use SpamAssassin, but there is 357 no spamc executable in services.public-inbox.path. If you 358 don't need spam checking, set 359 `services.public-inbox.settings.publicinboxmda.spamcheck' and 360 `services.public-inbox.settings.publicinboxwatch.spamcheck' to null. 361 ''; 362 } 363 ]; 364 services.public-inbox.settings = 365 filterAttrsRecursive (n: v: v != null) { 366 publicinbox = mapAttrs (n: filterAttrs (n: v: n != "description")) cfg.inboxes; 367 }; 368 users = { 369 users.public-inbox = { 370 home = stateDir; 371 group = "public-inbox"; 372 isSystemUser = true; 373 }; 374 groups.public-inbox = {}; 375 }; 376 networking.firewall = mkIf cfg.openFirewall 377 { allowedTCPPorts = mkMerge 378 (map (proto: (mkIf (cfg.${proto}.enable && types.port.check cfg.${proto}.port) [ cfg.${proto}.port ])) 379 ["imap" "http" "nntp"]); 380 }; 381 services.postfix = mkIf (cfg.postfix.enable && cfg.mda.enable) { 382 # Not sure limiting to 1 is necessary, but better safe than sorry. 383 config.public-inbox_destination_recipient_limit = "1"; 384 385 # Register the addresses as existing 386 virtual = 387 concatStringsSep "\n" (mapAttrsToList (_: inbox: 388 concatMapStringsSep "\n" (address: 389 "${address} ${address}" 390 ) inbox.address 391 ) cfg.inboxes); 392 393 # Deliver the addresses with the public-inbox transport 394 transport = 395 concatStringsSep "\n" (mapAttrsToList (_: inbox: 396 concatMapStringsSep "\n" (address: 397 "${address} public-inbox:${address}" 398 ) inbox.address 399 ) cfg.inboxes); 400 401 # The public-inbox transport 402 masterConfig.public-inbox = { 403 type = "unix"; 404 privileged = true; # Required for user= 405 command = "pipe"; 406 args = [ 407 "flags=X" # Report as a final delivery 408 "user=${with config.users; users."public-inbox".name + ":" + groups."public-inbox".name}" 409 # Specifying a nexthop when using the transport 410 # (eg. test public-inbox:test) allows to 411 # receive mails with an extension (eg. test+foo). 412 "argv=${pkgs.writeShellScript "public-inbox-transport" '' 413 export HOME="${stateDir}" 414 export ORIGINAL_RECIPIENT="''${2:-1}" 415 export PATH="${makeBinPath cfg.path}:$PATH" 416 exec ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args} 417 ''} \${original_recipient} \${nexthop}" 418 ]; 419 }; 420 }; 421 systemd.sockets = mkMerge (map (proto: 422 mkIf (cfg.${proto}.enable && cfg.${proto}.port != null) 423 { "public-inbox-${proto}d" = { 424 listenStreams = [ (toString cfg.${proto}.port) ]; 425 wantedBy = [ "sockets.target" ]; 426 }; 427 } 428 ) [ "imap" "http" "nntp" ]); 429 systemd.services = mkMerge [ 430 (mkIf cfg.imap.enable 431 { public-inbox-imapd = mkMerge [(serviceConfig "imapd") { 432 after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; 433 requires = [ "public-inbox-init.service" ]; 434 serviceConfig = { 435 ExecStart = escapeShellArgs ( 436 [ "${cfg.package}/bin/public-inbox-imapd" ] ++ 437 cfg.imap.args ++ 438 optionals (cfg.imap.cert != null) [ "--cert" cfg.imap.cert ] ++ 439 optionals (cfg.imap.key != null) [ "--key" cfg.imap.key ] 440 ); 441 }; 442 }]; 443 }) 444 (mkIf cfg.http.enable 445 { public-inbox-httpd = mkMerge [(serviceConfig "httpd") { 446 after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; 447 requires = [ "public-inbox-init.service" ]; 448 serviceConfig = { 449 ExecStart = escapeShellArgs ( 450 [ "${cfg.package}/bin/public-inbox-httpd" ] ++ 451 cfg.http.args ++ 452 # See https://public-inbox.org/public-inbox.git/tree/examples/public-inbox.psgi 453 # for upstream's example. 454 [ (pkgs.writeText "public-inbox.psgi" '' 455 #!${cfg.package.fullperl} -w 456 use strict; 457 use warnings; 458 use Plack::Builder; 459 use PublicInbox::WWW; 460 461 my $www = PublicInbox::WWW->new; 462 $www->preload; 463 464 builder { 465 # If reached through a reverse proxy, 466 # make it transparent by resetting some HTTP headers 467 # used by public-inbox to generate URIs. 468 enable 'ReverseProxy'; 469 470 # No need to send a response body if it's an HTTP HEAD requests. 471 enable 'Head'; 472 473 # Route according to configured domains and root paths. 474 ${concatMapStrings (path: '' 475 mount q(${path}) => sub { $www->call(@_); }; 476 '') cfg.http.mounts} 477 } 478 '') ] 479 ); 480 }; 481 }]; 482 }) 483 (mkIf cfg.nntp.enable 484 { public-inbox-nntpd = mkMerge [(serviceConfig "nntpd") { 485 after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; 486 requires = [ "public-inbox-init.service" ]; 487 serviceConfig = { 488 ExecStart = escapeShellArgs ( 489 [ "${cfg.package}/bin/public-inbox-nntpd" ] ++ 490 cfg.nntp.args ++ 491 optionals (cfg.nntp.cert != null) [ "--cert" cfg.nntp.cert ] ++ 492 optionals (cfg.nntp.key != null) [ "--key" cfg.nntp.key ] 493 ); 494 }; 495 }]; 496 }) 497 (mkIf (any (inbox: inbox.watch != []) (attrValues cfg.inboxes) 498 || cfg.settings.publicinboxwatch.watchspam != null) 499 { public-inbox-watch = mkMerge [(serviceConfig "watch") { 500 inherit (cfg) path; 501 wants = [ "public-inbox-init.service" ]; 502 requires = [ "public-inbox-init.service" ] ++ 503 optional (cfg.settings.publicinboxwatch.spamcheck == "spamc") "spamassassin.service"; 504 wantedBy = [ "multi-user.target" ]; 505 serviceConfig = { 506 ExecStart = "${cfg.package}/bin/public-inbox-watch"; 507 ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; 508 }; 509 }]; 510 }) 511 ({ public-inbox-init = let 512 PI_CONFIG = gitIni.generate "public-inbox.ini" 513 (filterAttrsRecursive (n: v: v != null) cfg.settings); 514 in mkMerge [(serviceConfig "init") { 515 wantedBy = [ "multi-user.target" ]; 516 restartIfChanged = true; 517 restartTriggers = [ PI_CONFIG ]; 518 script = '' 519 set -ux 520 install -D -p ${PI_CONFIG} ${stateDir}/.public-inbox/config 521 '' + optionalString useSpamAssassin '' 522 install -m 0700 -o spamd -d ${stateDir}/.spamassassin 523 ${optionalString (cfg.spamAssassinRules != null) '' 524 ln -sf ${cfg.spamAssassinRules} ${stateDir}/.spamassassin/user_prefs 525 ''} 526 '' + concatStrings (mapAttrsToList (name: inbox: '' 527 if [ ! -e ${stateDir}/inboxes/${escapeShellArg name} ]; then 528 # public-inbox-init creates an inbox and adds it to a config file. 529 # It tries to atomically write the config file by creating 530 # another file in the same directory, and renaming it. 531 # This has the sad consequence that we can't use 532 # /dev/null, or it would try to create a file in /dev. 533 conf_dir="$(mktemp -d)" 534 535 PI_CONFIG=$conf_dir/conf \ 536 ${cfg.package}/bin/public-inbox-init -V2 \ 537 ${escapeShellArgs ([ name "${stateDir}/inboxes/${name}" inbox.url ] ++ inbox.address)} 538 539 rm -rf $conf_dir 540 fi 541 542 ln -sf ${inbox.description} \ 543 ${stateDir}/inboxes/${escapeShellArg name}/description 544 545 export GIT_DIR=${stateDir}/inboxes/${escapeShellArg name}/all.git 546 if test -d "$GIT_DIR"; then 547 # Config is inherited by each epoch repository, 548 # so just needs to be set for all.git. 549 ${pkgs.git}/bin/git config core.sharedRepository 0640 550 fi 551 '') cfg.inboxes 552 ) + '' 553 shopt -s nullglob 554 for inbox in ${stateDir}/inboxes/*/; do 555 # This should be idempotent, but only do it for new 556 # inboxes anyway because it's only needed once, and could 557 # be slow for large pre-existing inboxes. 558 ls -1 "$inbox" | grep -q '^xap' || 559 ${cfg.package}/bin/public-inbox-index "$inbox" 560 done 561 ''; 562 serviceConfig = { 563 Type = "oneshot"; 564 RemainAfterExit = true; 565 StateDirectory = [ 566 "public-inbox/.public-inbox" 567 "public-inbox/.public-inbox/emergency" 568 "public-inbox/inboxes" 569 ]; 570 }; 571 }]; 572 }) 573 ]; 574 environment.systemPackages = with pkgs; [ cfg.package ]; 575 }; 576 meta.maintainers = with lib.maintainers; [ julm qyliss ]; 577}