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