diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 4b2cb803e20..6e6cc9879ac 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -490,6 +490,7 @@ ./services/mail/postfixadmin.nix ./services/mail/postsrsd.nix ./services/mail/postgrey.nix + ./services/mail/public-inbox.nix ./services/mail/spamassassin.nix ./services/mail/rspamd.nix ./services/mail/rss2email.nix diff --git a/nixos/modules/services/mail/public-inbox.nix b/nixos/modules/services/mail/public-inbox.nix new file mode 100644 index 00000000000..039eae5670a --- /dev/null +++ b/nixos/modules/services/mail/public-inbox.nix @@ -0,0 +1,560 @@ +{ lib, pkgs, config, ... }: + +with lib; + +let + cfg = config.services.public-inbox; + stateDir = "/var/lib/public-inbox"; + + manref = name: vol: "${name}${toString vol}"; + + gitIni = pkgs.formats.gitIni { listsAsDuplicateKeys = true; }; + iniAtom = elemAt gitIni.type/*attrsOf*/.functor.wrapped/*attrsOf*/.functor.wrapped/*either*/.functor.wrapped 0; + + useSpamAssassin = cfg.settings.publicinboxmda.spamcheck == "spamc" || + cfg.settings.publicinboxwatch.spamcheck == "spamc"; + + publicInboxDaemonOptions = proto: defaultPort: { + args = mkOption { + type = with types; listOf str; + default = []; + description = "Command-line arguments to pass to ${manref "public-inbox-${proto}d" 1}."; + }; + port = mkOption { + type = with types; nullOr (either str port); + default = defaultPort; + description = '' + Listening port. + Beware that public-inbox uses well-known ports number to decide whether to enable TLS or not. + Set to null and use systemd.sockets.public-inbox-${proto}d.listenStreams + if you need a more advanced listening. + ''; + }; + cert = mkOption { + type = with types; nullOr str; + default = null; + example = "/path/to/fullchain.pem"; + description = "Path to TLS certificate to use for connections to ${manref "public-inbox-${proto}d" 1}."; + }; + key = mkOption { + type = with types; nullOr str; + default = null; + example = "/path/to/key.pem"; + description = "Path to TLS key to use for connections to ${manref "public-inbox-${proto}d" 1}."; + }; + }; + + serviceConfig = srv: + let proto = removeSuffix "d" srv; + needNetwork = builtins.hasAttr proto cfg && cfg.${proto}.port == null; + in { + # Enable JIT-compiled C (via Inline::C) + Environment = [ "PERL_INLINE_DIRECTORY=/run/public-inbox-${srv}/perl-inline" ]; + # NonBlocking is REQUIRED to avoid a race condition + # if running simultaneous services. + NonBlocking = true; + #LimitNOFILE = 30000; + User = config.users.users."public-inbox".name; + Group = config.users.groups."public-inbox".name; + RuntimeDirectory = [ + "public-inbox-${srv}/perl-inline" + # Create RootDirectory= in the host's mount namespace. + "public-inbox-${srv}/root" + ]; + RuntimeDirectoryMode = "700"; + # Avoid mounting RootDirectory= in the own RootDirectory= of ExecStart='s mount namespace. + InaccessiblePaths = ["-+/run/public-inbox-${srv}/root"]; + # This is for BindPaths= and BindReadOnlyPaths= + # to allow traversal of directories they create in RootDirectory=. + UMask = "0066"; + RootDirectory = "/run/public-inbox-${srv}/root"; + RootDirectoryStartOnly = true; + WorkingDirectory = stateDir; + MountAPIVFS = true; + BindReadOnlyPaths = [ + builtins.storeDir + "/etc" + "/run" + # For Inline::C + "/bin/sh" + ]; + BindPaths = [ + stateDir + ]; + # The following options are only for optimizing: + # systemd-analyze security public-inbox-'*' + AmbientCapabilities = ""; + CapabilityBoundingSet = ""; + # ProtectClock= adds DeviceAllow=char-rtc r + DeviceAllow = ""; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateNetwork = mkDefault (!needNetwork); + PrivateTmp = true; + PrivateUsers = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = mkDefault true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ "AF_UNIX" ] + ++ optionals needNetwork [ "AF_INET" "AF_INET6" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallFilter = [ + "@system-service" + "~@aio" "~@chown" "~@keyring" "~@memlock" "~@resources" + # Not removing @setuid and @privileged + # because Inline::C needs them. + # Not removing @timer + # because git upload-pack needs it. + ]; + SystemCallArchitectures = "native"; + }; +in + +{ + options.services.public-inbox = { + enable = mkEnableOption "the public-inbox mail archiver"; + package = mkOption { + type = types.package; + default = pkgs.public-inbox; + defaultText = literalExpression "pkgs.public-inbox"; + description = "public-inbox package to use."; + }; + path = mkOption { + type = with types; listOf package; + default = []; + example = literalExpression "with pkgs; [ spamassassin ]"; + description = '' + Additional packages to place in the path of public-inbox-mda, + public-inbox-watch, etc. + ''; + }; + inboxes = mkOption { + description = '' + Inboxes to configure, where attribute names are inbox names. + ''; + default = {}; + type = types.attrsOf (types.submodule ({name, ...}: { + freeformType = types.attrsOf iniAtom; + options.inboxdir = mkOption { + type = types.str; + default = "${stateDir}/inboxes/${name}"; + description = "The absolute path to the directory which hosts the public-inbox."; + }; + options.address = mkOption { + type = with types; listOf str; + example = "example-discuss@example.org"; + description = "The email addresses of the public-inbox."; + }; + options.url = mkOption { + type = with types; nullOr str; + default = null; + example = "https://example.org/lists/example-discuss"; + description = "URL where this inbox can be accessed over HTTP."; + }; + options.description = mkOption { + type = types.str; + example = "user/dev discussion of public-inbox itself"; + description = "User-visible description for the repository."; + }; + options.newsgroup = mkOption { + type = with types; nullOr str; + default = null; + description = "NNTP group name for the inbox."; + }; + options.watch = mkOption { + type = with types; listOf str; + default = []; + description = "Paths for ${manref "public-inbox-watch" 1} to monitor for new mail."; + example = [ "maildir:/path/to/test.example.com.git" ]; + }; + options.watchheader = mkOption { + type = with types; nullOr str; + default = null; + example = "List-Id:"; + description = '' + If specified, ${manref "public-inbox-watch" 1} will only process + mail containing a matching header. + ''; + }; + options.coderepo = mkOption { + type = (types.listOf (types.enum (attrNames cfg.settings.coderepo))) // { + description = "list of coderepo names"; + }; + default = []; + description = "Nicknames of a 'coderepo' section associated with the inbox."; + }; + })); + }; + imap = { + enable = mkEnableOption "the public-inbox IMAP server"; + } // publicInboxDaemonOptions "imap" 993; + http = { + enable = mkEnableOption "the public-inbox HTTP server"; + mounts = mkOption { + type = with types; listOf str; + default = [ "/" ]; + example = [ "/lists/archives" ]; + description = '' + Root paths or URLs that public-inbox will be served on. + If domain parts are present, only requests to those + domains will be accepted. + ''; + }; + args = (publicInboxDaemonOptions "http" 80).args; + port = mkOption { + type = with types; nullOr (either str port); + default = 80; + example = "/run/public-inbox-httpd.sock"; + description = '' + Listening port or systemd's ListenStream= entry + to be used as a reverse proxy, eg. in nginx: + locations."/inbox".proxyPass = "http://unix:''${config.services.public-inbox.http.port}:/inbox"; + Set to null and use systemd.sockets.public-inbox-httpd.listenStreams + if you need a more advanced listening. + ''; + }; + }; + mda = { + enable = mkEnableOption "the public-inbox Mail Delivery Agent"; + args = mkOption { + type = with types; listOf str; + default = []; + description = "Command-line arguments to pass to ${manref "public-inbox-mda" 1}."; + }; + }; + postfix.enable = mkEnableOption "the integration into Postfix"; + nntp = { + enable = mkEnableOption "the public-inbox NNTP server"; + } // publicInboxDaemonOptions "nntp" 563; + spamAssassinRules = mkOption { + type = with types; nullOr path; + default = "${cfg.package.sa_config}/user/.spamassassin/user_prefs"; + defaultText = literalExpression "\${cfg.package.sa_config}/user/.spamassassin/user_prefs"; + description = "SpamAssassin configuration specific to public-inbox."; + }; + settings = mkOption { + description = '' + Settings for the public-inbox config file. + ''; + default = {}; + type = types.submodule { + freeformType = gitIni.type; + options.publicinbox = mkOption { + default = {}; + description = "public inboxes"; + type = types.submodule { + freeformType = with types; /*inbox name*/attrsOf (/*inbox option name*/attrsOf /*inbox option value*/iniAtom); + options.css = mkOption { + type = with types; listOf str; + default = []; + description = "The local path name of a CSS file for the PSGI web interface."; + }; + options.nntpserver = mkOption { + type = with types; listOf str; + default = []; + example = [ "nntp://news.public-inbox.org" "nntps://news.public-inbox.org" ]; + description = "NNTP URLs to this public-inbox instance"; + }; + options.wwwlisting = mkOption { + type = with types; enum [ "all" "404" "match=domain" ]; + default = "404"; + description = '' + Controls which lists (if any) are listed for when the root + public-inbox URL is accessed over HTTP. + ''; + }; + }; + }; + options.publicinboxmda.spamcheck = mkOption { + type = with types; enum [ "spamc" "none" ]; + default = "none"; + description = '' + If set to spamc, ${manref "public-inbox-watch" 1} will filter spam + using SpamAssassin. + ''; + }; + options.publicinboxwatch.spamcheck = mkOption { + type = with types; enum [ "spamc" "none" ]; + default = "none"; + description = '' + If set to spamc, ${manref "public-inbox-watch" 1} will filter spam + using SpamAssassin. + ''; + }; + options.publicinboxwatch.watchspam = mkOption { + type = with types; nullOr str; + default = null; + example = "maildir:/path/to/spam"; + description = '' + If set, mail in this maildir will be trained as spam and + deleted from all watched inboxes + ''; + }; + options.coderepo = mkOption { + default = {}; + description = "code repositories"; + type = types.attrsOf (types.submodule { + freeformType = types.attrsOf iniAtom; + options.cgitUrl = mkOption { + type = types.str; + description = "URL of a cgit instance"; + }; + options.dir = mkOption { + type = types.str; + description = "Path to a git repository"; + }; + }); + }; + }; + }; + openFirewall = mkEnableOption "opening the firewall when using a port option"; + }; + config = mkIf cfg.enable { + assertions = [ + { assertion = config.services.spamassassin.enable || !useSpamAssassin; + message = '' + public-inbox is configured to use SpamAssassin, but + services.spamassassin.enable is false. If you don't need + spam checking, set `services.public-inbox.settings.publicinboxmda.spamcheck' and + `services.public-inbox.settings.publicinboxwatch.spamcheck' to null. + ''; + } + { assertion = cfg.path != [] || !useSpamAssassin; + message = '' + public-inbox is configured to use SpamAssassin, but there is + no spamc executable in services.public-inbox.path. If you + don't need spam checking, set + `services.public-inbox.settings.publicinboxmda.spamcheck' and + `services.public-inbox.settings.publicinboxwatch.spamcheck' to null. + ''; + } + ]; + services.public-inbox.settings = + filterAttrsRecursive (n: v: v != null) { + publicinbox = mapAttrs (n: filterAttrs (n: v: n != "description")) cfg.inboxes; + }; + users = { + users.public-inbox = { + home = stateDir; + group = "public-inbox"; + isSystemUser = true; + }; + groups.public-inbox = {}; + }; + networking.firewall = mkIf cfg.openFirewall + { allowedTCPPorts = mkMerge (map (proto: + (mkIf (cfg.${proto}.enable && types.port.check cfg.proto.port) [ cfg.proto.port ]) + ["imap" "http" "nntp"])); + }; + services.postfix = mkIf (cfg.postfix.enable && cfg.mda.enable) { + # Not sure limiting to 1 is necessary, but better safe than sorry. + config.public-inbox_destination_recipient_limit = "1"; + + # Register the addresses as existing + virtual = + concatStringsSep "\n" (mapAttrsToList (_: inbox: + concatMapStringsSep "\n" (address: + "${address} ${address}" + ) inbox.address + ) cfg.inboxes); + + # Deliver the addresses with the public-inbox transport + transport = + concatStringsSep "\n" (mapAttrsToList (_: inbox: + concatMapStringsSep "\n" (address: + "${address} public-inbox:${address}" + ) inbox.address + ) cfg.inboxes); + + # The public-inbox transport + masterConfig.public-inbox = { + type = "unix"; + privileged = true; # Required for user= + command = "pipe"; + args = [ + "flags=X" # Report as a final delivery + "user=${with config.users; users."public-inbox".name + ":" + groups."public-inbox".name}" + # Specifying a nexthop when using the transport + # (eg. test public-inbox:test) allows to + # receive mails with an extension (eg. test+foo). + "argv=${pkgs.writeShellScript "public-inbox-transport" '' + export HOME="${stateDir}" + export ORIGINAL_RECIPIENT="''${2:-1}" + export PATH="${makeBinPath cfg.path}:$PATH" + exec ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args} + ''} \${original_recipient} \${nexthop}" + ]; + }; + }; + systemd.sockets = mkMerge (map (proto: + mkIf (cfg.${proto}.enable && cfg.${proto}.port != null) + { "public-inbox-${proto}d" = { + listenStreams = [ (toString cfg.${proto}.port) ]; + wantedBy = [ "sockets.target" ]; + }; + } + ) [ "imap" "http" "nntp" ]); + systemd.services = mkMerge [ + (mkIf cfg.imap.enable + { public-inbox-imapd = { + after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; + requires = [ "public-inbox-init.service" ]; + serviceConfig = mkMerge [(serviceConfig "imapd") { + ExecStart = escapeShellArgs ( + [ "${cfg.package}/bin/public-inbox-imapd" ] ++ + cfg.imap.args ++ + optionals (cfg.imap.cert != null) [ "--cert" cfg.imap.cert ] ++ + optionals (cfg.imap.key != null) [ "--key" cfg.imap.key ] + ); + }]; + }; + }) + (mkIf cfg.http.enable + { public-inbox-httpd = { + after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; + requires = [ "public-inbox-init.service" ]; + serviceConfig = mkMerge [(serviceConfig "httpd") { + ExecStart = escapeShellArgs ( + [ "${cfg.package}/bin/public-inbox-httpd" ] ++ + cfg.http.args ++ + # See https://public-inbox.org/public-inbox.git/tree/examples/public-inbox.psgi + # for upstream's example. + [ (pkgs.writeText "public-inbox.psgi" '' + #!${cfg.package.fullperl} -w + use strict; + use warnings; + use Plack::Builder; + use PublicInbox::WWW; + + my $www = PublicInbox::WWW->new; + $www->preload; + + builder { + # If reached through a reverse proxy, + # make it transparent by resetting some HTTP headers + # used by public-inbox to generate URIs. + enable 'ReverseProxy'; + + # No need to send a response body if it's an HTTP HEAD requests. + enable 'Head'; + + # Route according to configured domains and root paths. + ${concatMapStrings (path: '' + mount q(${path}) => sub { $www->call(@_); }; + '') cfg.http.mounts} + } + '') ] + ); + }]; + }; + }) + (mkIf cfg.nntp.enable + { public-inbox-nntpd = { + after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; + requires = [ "public-inbox-init.service" ]; + serviceConfig = mkMerge [(serviceConfig "nntpd") { + ExecStart = escapeShellArgs ( + [ "${cfg.package}/bin/public-inbox-nntpd" ] ++ + cfg.nntp.args ++ + optionals (cfg.nntp.cert != null) [ "--cert" cfg.nntp.cert ] ++ + optionals (cfg.nntp.key != null) [ "--key" cfg.nntp.key ] + ); + }]; + }; + }) + (mkIf (any (inbox: inbox.watch != []) (attrValues cfg.inboxes) + || cfg.settings.publicinboxwatch.watchspam != null) + { public-inbox-watch = { + inherit (cfg) path; + wants = [ "public-inbox-init.service" ]; + requires = [ "public-inbox-init.service" ] ++ + optional (cfg.settings.publicinboxwatch.spamcheck == "spamc") "spamassassin.service"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = mkMerge [(serviceConfig "watch") { + ExecStart = "${cfg.package}/bin/public-inbox-watch"; + ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; + }]; + }; + }) + ({ public-inbox-init = let + PI_CONFIG = gitIni.generate "public-inbox.ini" + (filterAttrsRecursive (n: v: v != null) cfg.settings); + in { + wantedBy = [ "multi-user.target" ]; + restartIfChanged = true; + restartTriggers = [ PI_CONFIG ]; + script = '' + set -ux + install -D -p ${PI_CONFIG} ${stateDir}/.public-inbox/config + '' + optionalString useSpamAssassin '' + install -m 0700 -o spamd -d ${stateDir}/.spamassassin + ${optionalString (cfg.spamAssassinRules != null) '' + ln -sf ${cfg.spamAssassinRules} ${stateDir}/.spamassassin/user_prefs + ''} + '' + concatStrings (mapAttrsToList (name: inbox: '' + if [ ! -e ${stateDir}/inboxes/${escapeShellArg name} ]; then + # public-inbox-init creates an inbox and adds it to a config file. + # It tries to atomically write the config file by creating + # another file in the same directory, and renaming it. + # This has the sad consequence that we can't use + # /dev/null, or it would try to create a file in /dev. + conf_dir="$(mktemp -d)" + + PI_CONFIG=$conf_dir/conf \ + ${cfg.package}/bin/public-inbox-init -V2 \ + ${escapeShellArgs ([ name "${stateDir}/inboxes/${name}" inbox.url ] ++ inbox.address)} + + rm -rf $conf_dir + fi + + ln -sf ${pkgs.writeText "description" inbox.description} \ + ${stateDir}/inboxes/${escapeShellArg name}/description + + export GIT_DIR=${stateDir}/inboxes/${escapeShellArg name}/all.git + if test -d "$GIT_DIR"; then + # Config is inherited by each epoch repository, + # so just needs to be set for all.git. + ${pkgs.git}/bin/git config core.sharedRepository 0640 + fi + '') cfg.inboxes + ) + '' + shopt -s nullglob + for inbox in ${stateDir}/inboxes/*/; do + # This should be idempotent, but only do it for new + # inboxes anyway because it's only needed once, and could + # be slow for large pre-existing inboxes. + ls -1 "$inbox" | grep -q '^xap' || + ${cfg.package}/bin/public-inbox-index "$inbox" + done + ''; + serviceConfig = mkMerge [(serviceConfig "init") { + Type = "oneshot"; + RemainAfterExit = true; + StateDirectory = [ + "public-inbox" + "public-inbox/.public-inbox" + "public-inbox/.public-inbox/emergency" + "public-inbox/inboxes" + ]; + StateDirectoryMode = "0750"; + }]; + }; + }) + ]; + environment.systemPackages = with pkgs; [ cfg.package ]; + }; + meta.maintainers = with lib.maintainers; [ julm qyliss ]; +} diff --git a/pkgs/pkgs-lib/formats.nix b/pkgs/pkgs-lib/formats.nix index 5e17519d4ce..e6e6a95c1f4 100644 --- a/pkgs/pkgs-lib/formats.nix +++ b/pkgs/pkgs-lib/formats.nix @@ -123,6 +123,17 @@ rec { }; + gitIni = { listsAsDuplicateKeys ? false, ... }@args: { + + type = with lib.types; let + + iniAtom = (ini args).type/*attrsOf*/.functor.wrapped/*attrsOf*/.functor.wrapped; + + in attrsOf (attrsOf (either iniAtom (attrsOf iniAtom))); + + generate = name: value: pkgs.writeText name (lib.generators.toGitINI value); + }; + toml = {}: json {} // { type = with lib.types; let valueType = oneOf [