{ lib, pkgs, config, ... }: with lib; let cfg = config.services.public-inbox; stateDir = "/var/lib/public-inbox"; manref = name: vol: "${name}${toString vol}"; singleIniAtom = with types; nullOr (oneOf [ bool int float str ]) // { description = "INI atom (null, bool, int, float or string)"; }; iniAtom = with types; coercedTo singleIniAtom singleton (listOf singleIniAtom) // { description = singleIniAtom.description + " or a list of them for duplicate keys"; }; iniAttrs = with types; attrsOf (either (attrsOf iniAtom) iniAtom); gitIni = { type = with types; attrsOf iniAttrs; generate = name: value: pkgs.writeText name (generators.toGitINI value); }; environment = { PI_EMERGENCY = "${stateDir}/emergency"; PI_CONFIG = gitIni.generate "public-inbox.ini" (filterAttrsRecursive (n: v: v != null) cfg.settings); }; useSpamAssassin = cfg.settings.publicinboxmda.spamcheck == "spamc" || cfg.settings.publicinboxwatch.spamcheck == "spamc"; serviceConfig = srv: { # 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" ]; 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 false; PrivateTmp = true; PrivateUsers = true; ProtectClock = true; ProtectControlGroups = true; ProtectHome = true; ProtectHostname = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProtectSystem = "strict"; RemoveIPC = true; RestrictAddressFamilies = [ "AF_UNIX" ]; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; SystemCallFilter = optionals (srv != "init") [ "@system-service" "~@aio" "~@chown" "~@keyring" "~@memlock" "~@resources" "~@setuid" "~@timer" "~@privileged" ]; SystemCallArchitectures = "native"; SystemCallErrorNumber = "EPERM"; }; in { options.services.public-inbox = { enable = mkEnableOption "the public-inbox mail archiver"; package = mkOption { type = types.package; default = pkgs.public-inbox; description = "public-inbox package to use."; }; path = mkOption { type = with types; listOf package; default = []; example = literalExample "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.submodule { freeformType = 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."; }; })); }; }; 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}."; }; }; 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 = mkOption { type = with types; listOf str; default = ["-W0"]; description = "Command-line arguments to pass to ${manref "public-inbox-httpd" 1}."; }; 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. ''; }; }; imap = { enable = mkEnableOption "the public-inbox IMAP server"; args = mkOption { type = with types; listOf str; default = ["-W0"]; description = "Command-line arguments to pass to ${manref "public-inbox-imapd" 1}."; }; port = mkOption { type = with types; nullOr port; default = 993; description = '' Listening port. Set to null and use systemd.sockets.public-inbox-imapd.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 public-inbox IMAP connections."; }; key = mkOption { type = with types; nullOr str; default = null; example = "/path/to/key.pem"; description = "Path to TLS key to use for public-inbox IMAP connections."; }; }; nntp = { enable = mkEnableOption "the public-inbox NNTP server"; port = mkOption { type = with types; nullOr port; default = 563; description = '' Listening port. Set to null and use systemd.sockets.public-inbox-nntpd.listenStreams if you need a more advanced listening. ''; }; args = mkOption { type = with types; listOf str; default = ["-W0"]; description = "Command-line arguments to pass to ${manref "public-inbox-nntpd" 1}."; }; cert = mkOption { type = with types; nullOr str; default = null; example = "/path/to/fullchain.pem"; description = "Path to TLS certificate to use for public-inbox NNTP connections"; }; key = mkOption { type = with types; nullOr str; default = null; example = "/path/to/key.pem"; description = "Path to TLS key to use for public-inbox NNTP connections."; }; }; spamAssassinRules = mkOption { type = with types; nullOr path; default = "${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-inbox configuration."; type = types.submodule { freeformType = iniAttrs; 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 = mkOption { default = {}; description = "mailbox delivery agent"; type = types.submodule { freeformType = iniAttrs; options.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 = mkOption { default = {}; description = "mailbox watcher"; type = types.submodule { freeformType = iniAttrs; options.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.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.submodule { freeformType = types.attrsOf (types.submodule { freeformType = types.either (types.attrsOf iniAtom) 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 = { # Use runCommand instead of linkFarm, # because Postfix rejects .forward if it's a symlink. home = pkgs.runCommand "public-inbox-home" {} ('' install -D -p ${environment.PI_CONFIG} $out/.public-inbox/config ln -s ${stateDir}/emergency $out/.public-inbox/emergency ln -s ${stateDir}/spamassassin $out/.spamassassin '' + optionalString cfg.mda.enable '' cp ${let env = concatStringsSep " " (mapAttrsToList (n: v: "${n}=${escapeShellArg v}") environment); in pkgs.writeText "forward" '' |"env ${env} PATH=\"${makeBinPath cfg.path}:$PATH\" ${cfg.package}/bin/public-inbox-mda ${escapeShellArgs cfg.mda.args} ''} $out/.forward ''); group = "public-inbox"; isSystemUser = true; }; groups.public-inbox = {}; }; networking.firewall = mkIf cfg.openFirewall { allowedTCPPorts = mkMerge [ (mkIf (cfg.http.enable && types.port.check cfg.http.port) [ cfg.http.port ]) (mkIf (cfg.imap.enable && types.port.check cfg.imap.port) [ cfg.imap.port ]) (mkIf (cfg.nntp.enable && types.port.check cfg.nntp.port) [ cfg.nntp.port ]) ]; }; 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" ]; }; } ) [ "http" "imap" "nntp" ]); systemd.services = mkMerge [ (mkIf cfg.http.enable { public-inbox-httpd = { inherit environment; after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; requires = [ "public-inbox-init.service" ]; serviceConfig = serviceConfig "httpd" // { ExecStart = escapeShellArgs ( [ "${cfg.package}/bin/public-inbox-httpd" ] ++ cfg.http.args ++ [ (pkgs.writeText "public-inbox.psgi" '' #!${cfg.package.fullperl} -w use strict; use PublicInbox::WWW; use Plack::Builder; my $www = PublicInbox::WWW->new; $www->preload; builder { enable 'Head'; enable 'ReverseProxy'; ${concatMapStrings (path: '' mount q(${path}) => sub { $www->call(@_); }; '') cfg.http.mounts} } '') ] ); }; }; }) (mkIf cfg.imap.enable { public-inbox-imapd = { inherit environment; after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; requires = [ "public-inbox-init.service" ]; serviceConfig = 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.nntp.enable { public-inbox-nntpd = { inherit environment; after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; requires = [ "public-inbox-init.service" ]; serviceConfig = 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 environment; 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 = serviceConfig "watch" // { ExecStart = "${cfg.package}/bin/public-inbox-watch"; ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; }; }; }) ({ public-inbox-init = { inherit environment; wantedBy = [ "multi-user.target" ]; restartIfChanged = true; restartTriggers = [ environment.PI_CONFIG ]; script = '' set -ux ${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 ls -1 "$inbox" | ${pkgs.gnugrep}/bin/grep -q '^xap' && continue # 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. ${cfg.package}/bin/public-inbox-index "$inbox" done ''; serviceConfig = serviceConfig "init" // { Type = "oneshot"; RemainAfterExit = true; StateDirectory = [ "public-inbox" "public-inbox/emergency" "public-inbox/inboxes" ]; StateDirectoryMode = "0750"; }; }; }) ]; environment.systemPackages = with pkgs; [ cfg.package ]; }; meta.maintainers = with lib.maintainers; [ julm ]; }