diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index e0d1c037935..84309f90132 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -505,6 +505,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..0f9bc4ef226 --- /dev/null +++ b/nixos/modules/services/mail/public-inbox.nix @@ -0,0 +1,579 @@ +{ 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 { + serviceConfig = { + # 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" + ]; + RuntimeDirectoryMode = "700"; + # This is for BindPaths= and BindReadOnlyPaths= + # to allow traversal of directories they create inside RootDirectory= + UMask = "0066"; + StateDirectory = ["public-inbox"]; + StateDirectoryMode = "0750"; + WorkingDirectory = stateDir; + BindReadOnlyPaths = [ + "/etc" + "/run/systemd" + "${config.i18n.glibcLocales}" + ] ++ + mapAttrsToList (name: inbox: inbox.description) cfg.inboxes ++ + # Without confinement the whole Nix store + # is made available to the service + optionals (!config.systemd.services."public-inbox-${srv}".confinement.enable) [ + "${pkgs.dash}/bin/dash:/bin/sh" + builtins.storeDir + ]; + # 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; + PrivateNetwork = mkDefault (!needNetwork); + ProcSubset = "pid"; + ProtectClock = true; + ProtectHome = mkDefault true; + ProtectHostname = true; + ProtectKernelLogs = 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"; + + # The following options are redundant when confinement is enabled + RootDirectory = "/var/empty"; + TemporaryFileSystem = "/"; + PrivateMounts = true; + MountAPIVFS = true; + PrivateDevices = true; + PrivateTmp = true; + PrivateUsers = true; + ProtectControlGroups = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + }; + confinement = { + # Until we agree upon doing it directly here in NixOS + # https://github.com/NixOS/nixpkgs/pull/104457#issuecomment-1115768447 + # let the user choose to enable the confinement with: + # systemd.services.public-inbox-httpd.confinement.enable = true; + # systemd.services.public-inbox-imapd.confinement.enable = true; + # systemd.services.public-inbox-init.confinement.enable = true; + # systemd.services.public-inbox-nntpd.confinement.enable = true; + #enable = true; + mode = "full-apivfs"; + # Inline::C needs a /bin/sh, and dash is enough + binSh = "${pkgs.dash}/bin/dash"; + packages = [ + pkgs.iana-etc + (getLib pkgs.nss) + pkgs.tzdata + ]; + }; + }; +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."; + apply = pkgs.writeText "public-inbox-description-${name}"; + }; + 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 = mkMerge [(serviceConfig "imapd") { + after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; + requires = [ "public-inbox-init.service" ]; + serviceConfig = { + 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 = mkMerge [(serviceConfig "httpd") { + after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; + requires = [ "public-inbox-init.service" ]; + serviceConfig = { + 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 = mkMerge [(serviceConfig "nntpd") { + after = [ "public-inbox-init.service" "public-inbox-watch.service" ]; + requires = [ "public-inbox-init.service" ]; + serviceConfig = { + 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 = mkMerge [(serviceConfig "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 = { + 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 mkMerge [(serviceConfig "init") { + 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 ${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 = { + Type = "oneshot"; + RemainAfterExit = true; + StateDirectory = [ + "public-inbox/.public-inbox" + "public-inbox/.public-inbox/emergency" + "public-inbox/inboxes" + ]; + }; + }]; + }) + ]; + environment.systemPackages = with pkgs; [ cfg.package ]; + }; + meta.maintainers = with lib.maintainers; [ julm qyliss ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 0c085b64efa..c00f7829ac6 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -456,6 +456,7 @@ in proxy = handleTest ./proxy.nix {}; prowlarr = handleTest ./prowlarr.nix {}; pt2-clone = handleTest ./pt2-clone.nix {}; + public-inbox = handleTest ./public-inbox.nix {}; pulseaudio = discoverTests (import ./pulseaudio.nix); qboot = handleTestOn ["x86_64-linux" "i686-linux"] ./qboot.nix {}; quorum = handleTest ./quorum.nix {}; diff --git a/nixos/tests/public-inbox.nix b/nixos/tests/public-inbox.nix new file mode 100644 index 00000000000..7de40400fcb --- /dev/null +++ b/nixos/tests/public-inbox.nix @@ -0,0 +1,227 @@ +import ./make-test-python.nix ({ pkgs, lib, ... }: +let + orga = "example"; + domain = "${orga}.localdomain"; + + tls-cert = pkgs.runCommand "selfSignedCert" { buildInputs = [ pkgs.openssl ]; } '' + openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -nodes -days 36500 \ + -subj '/CN=machine.${domain}' + install -D -t $out key.pem cert.pem + ''; +in +{ + name = "public-inbox"; + + meta.maintainers = with pkgs.lib.maintainers; [ julm ]; + + machine = { config, pkgs, nodes, ... }: let + inherit (config.services) gitolite public-inbox; + # Git repositories paths in Gitolite. + # Only their baseNameOf is used for configuring public-inbox. + repositories = [ + "user/repo1" + "user/repo2" + ]; + in + { + virtualisation.diskSize = 1 * 1024; + virtualisation.memorySize = 1 * 1024; + networking.domain = domain; + + security.pki.certificateFiles = [ "${tls-cert}/cert.pem" ]; + # If using security.acme: + #security.acme.certs."${domain}".postRun = '' + # systemctl try-restart public-inbox-nntpd public-inbox-imapd + #''; + + services.public-inbox = { + enable = true; + postfix.enable = true; + openFirewall = true; + settings.publicinbox = { + css = [ "href=https://machine.${domain}/style/light.css" ]; + nntpserver = [ "nntps://machine.${domain}" ]; + wwwlisting = "match=domain"; + }; + mda = { + enable = true; + args = [ "--no-precheck" ]; # Allow Bcc: + }; + http = { + enable = true; + port = "/run/public-inbox-http.sock"; + #port = 8080; + args = ["-W0"]; + mounts = [ + "https://machine.${domain}/inbox" + ]; + }; + nntp = { + enable = true; + #port = 563; + args = ["-W0"]; + cert = "${tls-cert}/cert.pem"; + key = "${tls-cert}/key.pem"; + }; + imap = { + enable = true; + #port = 993; + args = ["-W0"]; + cert = "${tls-cert}/cert.pem"; + key = "${tls-cert}/key.pem"; + }; + inboxes = lib.recursiveUpdate (lib.genAttrs (map baseNameOf repositories) (repo: { + address = [ + # Routed to the "public-inbox:" transport in services.postfix.transport + "${repo}@${domain}" + ]; + description = '' + ${repo}@${domain} : + discussions about ${repo}. + ''; + url = "https://machine.${domain}/inbox/${repo}"; + newsgroup = "inbox.comp.${orga}.${repo}"; + coderepo = [ repo ]; + })) + { + repo2 = { + hide = [ + "imap" # FIXME: doesn't work for IMAP as of public-inbox 1.6.1 + "manifest" + "www" + ]; + }; + }; + settings.coderepo = lib.listToAttrs (map (path: lib.nameValuePair (baseNameOf path) { + dir = "/var/lib/gitolite/repositories/${path}.git"; + cgitUrl = "https://git.${domain}/${path}.git"; + }) repositories); + }; + + # Use gitolite to store Git repositories listed in coderepo entries + services.gitolite = { + enable = true; + adminPubkey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmoTOQnGqX+//us5oye8UuE+tQBx9QEM7PN13jrwgqY root@localhost"; + }; + systemd.services.public-inbox-httpd = { + serviceConfig.SupplementaryGroups = [ gitolite.group ]; + }; + + # Use nginx as a reverse proxy for public-inbox-httpd + services.nginx = { + enable = true; + recommendedGzipSettings = true; + recommendedOptimisation = true; + recommendedTlsSettings = true; + recommendedProxySettings = true; + virtualHosts."machine.${domain}" = { + forceSSL = true; + sslCertificate = "${tls-cert}/cert.pem"; + sslCertificateKey = "${tls-cert}/key.pem"; + locations."/".return = "302 /inbox"; + locations."= /inbox".return = "302 /inbox/"; + locations."/inbox".proxyPass = "http://unix:${public-inbox.http.port}:/inbox"; + # If using TCP instead of a Unix socket: + #locations."/inbox".proxyPass = "http://127.0.0.1:${toString public-inbox.http.port}/inbox"; + # Referred to by settings.publicinbox.css + # See http://public-inbox.org/meta/_/text/color/ + locations."= /style/light.css".alias = pkgs.writeText "light.css" '' + * { background:#fff; color:#000 } + + a { color:#00f; text-decoration:none } + a:visited { color:#808 } + + *.q { color:#008 } + + *.add { color:#060 } + *.del {color:#900 } + *.head { color:#000 } + *.hunk { color:#960 } + + .hl.num { color:#f30 } /* number */ + .hl.esc { color:#f0f } /* escape character */ + .hl.str { color:#f30 } /* string */ + .hl.ppc { color:#c3c } /* preprocessor */ + .hl.pps { color:#f30 } /* preprocessor string */ + .hl.slc { color:#099 } /* single-line comment */ + .hl.com { color:#099 } /* multi-line comment */ + /* .hl.opt { color:#ccc } */ /* operator */ + /* .hl.ipl { color:#ccc } */ /* interpolation */ + + /* keyword groups kw[a-z] */ + .hl.kwa { color:#f90 } + .hl.kwb { color:#060 } + .hl.kwc { color:#f90 } + /* .hl.kwd { color:#ccc } */ + ''; + }; + }; + + services.postfix = { + enable = true; + setSendmail = true; + #sslCert = "${tls-cert}/cert.pem"; + #sslKey = "${tls-cert}/key.pem"; + recipientDelimiter = "+"; + }; + + environment.systemPackages = [ + pkgs.mailutils + pkgs.openssl + ]; + + }; + + testScript = '' + start_all() + machine.wait_for_unit("multi-user.target") + machine.wait_for_unit("public-inbox-init.service") + + # Very basic check that Gitolite can work; + # Gitolite is not needed for the rest of this testScript + machine.wait_for_unit("gitolite-init.service") + + # List inboxes through public-inbox-httpd + machine.wait_for_unit("nginx.service") + machine.succeed("curl -L https://machine.${domain} | grep repo1@${domain}") + # The repo2 inbox is hidden + machine.fail("curl -L https://machine.${domain} | grep repo2@${domain}") + machine.wait_for_unit("public-inbox-httpd.service") + + # Send a mail and read it through public-inbox-httpd + # Must work too when using a recipientDelimiter. + machine.wait_for_unit("postfix.service") + machine.succeed("mail -t <${pkgs.writeText "mail" '' + Subject: Testing mail + From: root@localhost + To: repo1+extension@${domain} + Message-ID: + Content-Type: text/plain; charset=utf-8 + Content-Disposition: inline + + This is a testing mail. + ''}") + machine.sleep(5) + machine.succeed("curl -L 'https://machine.${domain}/inbox/repo1/repo1@root-1/T/#u' | grep 'This is a testing mail.'") + + # Read a mail through public-inbox-imapd + machine.wait_for_open_port(993) + machine.wait_for_unit("public-inbox-imapd.service") + machine.succeed("openssl s_client -ign_eof -crlf -connect machine.${domain}:993 <${pkgs.writeText "imap-commands" '' + tag login anonymous@${domain} anonymous + tag SELECT INBOX.comp.${orga}.repo1.0 + tag FETCH 1 (BODY[HEADER]) + tag LOGOUT + ''} | grep '^Message-ID: '") + + # TODO: Read a mail through public-inbox-nntpd + #machine.wait_for_open_port(563) + #machine.wait_for_unit("public-inbox-nntpd.service") + + # Delete a mail. + # Note that the use of an extension not listed in the addresses + # require to use --all + machine.succeed("curl -L https://machine.example.localdomain/inbox/repo1/repo1@root-1/raw | sudo -u public-inbox public-inbox-learn rm --all") + machine.fail("curl -L https://machine.example.localdomain/inbox/repo1/repo1@root-1/T/#u | grep 'This is a testing mail.'") + ''; +}) diff --git a/pkgs/pkgs-lib/formats.nix b/pkgs/pkgs-lib/formats.nix index 6495b024b00..cb46b63dd0c 100644 --- a/pkgs/pkgs-lib/formats.nix +++ b/pkgs/pkgs-lib/formats.nix @@ -135,6 +135,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 [ diff --git a/pkgs/servers/mail/public-inbox/0002-msgtime-drop-Date-Parse-for-RFC2822.patch b/pkgs/servers/mail/public-inbox/0002-msgtime-drop-Date-Parse-for-RFC2822.patch deleted file mode 100644 index ebc9a6f2237..00000000000 --- a/pkgs/servers/mail/public-inbox/0002-msgtime-drop-Date-Parse-for-RFC2822.patch +++ /dev/null @@ -1,172 +0,0 @@ -From c9b5164c954cd0de80d971f1c4ced16bf41ea81b Mon Sep 17 00:00:00 2001 -From: Eric Wong -Date: Fri, 29 Nov 2019 12:25:07 +0000 -Subject: [PATCH 2/2] msgtime: drop Date::Parse for RFC2822 - -Date::Parse is not optimized for RFC2822 dates and isn't -packaged on OpenBSD. It's still useful for historical -email when email clients were less conformant, but is -less relevant for new emails. ---- - lib/PublicInbox/MsgTime.pm | 115 ++++++++++++++++++++++++++++++++----- - t/msgtime.t | 6 ++ - 2 files changed, 107 insertions(+), 14 deletions(-) - -diff --git a/lib/PublicInbox/MsgTime.pm b/lib/PublicInbox/MsgTime.pm -index 58e11d72..e9b27a49 100644 ---- a/lib/PublicInbox/MsgTime.pm -+++ b/lib/PublicInbox/MsgTime.pm -@@ -7,24 +7,114 @@ use strict; - use warnings; - use base qw(Exporter); - our @EXPORT_OK = qw(msg_timestamp msg_datestamp); --use Date::Parse qw(str2time strptime); -+use Time::Local qw(timegm); -+my @MoY = qw(january february march april may june -+ july august september october november december); -+my %MoY; -+@MoY{@MoY} = (0..11); -+@MoY{map { substr($_, 0, 3) } @MoY} = (0..11); -+ -+my %OBSOLETE_TZ = ( # RFC2822 4.3 (Obsolete Date and Time) -+ EST => '-0500', EDT => '-0400', -+ CST => '-0600', CDT => '-0500', -+ MST => '-0700', MDT => '-0600', -+ PST => '-0800', PDT => '-0700', -+ UT => '+0000', GMT => '+0000', Z => '+0000', -+ -+ # RFC2822 states: -+ # The 1 character military time zones were defined in a non-standard -+ # way in [RFC822] and are therefore unpredictable in their meaning. -+); -+my $OBSOLETE_TZ = join('|', keys %OBSOLETE_TZ); - - sub str2date_zone ($) { - my ($date) = @_; -+ my ($ts, $zone); -+ -+ # RFC822 is most likely for email, but we can tolerate an extra comma -+ # or punctuation as long as all the data is there. -+ # We'll use '\s' since Unicode spaces won't affect our parsing. -+ # SpamAssassin ignores commas and redundant spaces, too. -+ if ($date =~ /(?:[A-Za-z]+,?\s+)? # day-of-week -+ ([0-9]+),?\s+ # dd -+ ([A-Za-z]+)\s+ # mon -+ ([0-9]{2,})\s+ # YYYY or YY (or YYY :P) -+ ([0-9]+)[:\.] # HH: -+ ((?:[0-9]{2})|(?:\s?[0-9])) # MM -+ (?:[:\.]((?:[0-9]{2})|(?:\s?[0-9])))? # :SS -+ \s+ # a TZ offset is required: -+ ([\+\-])? # TZ sign -+ [\+\-]* # I've seen extra "-" e.g. "--500" -+ ([0-9]+|$OBSOLETE_TZ)(?:\s|$) # TZ offset -+ /xo) { -+ my ($dd, $m, $yyyy, $hh, $mm, $ss, $sign, $tz) = -+ ($1, $2, $3, $4, $5, $6, $7, $8); -+ # don't accept non-English months -+ defined(my $mon = $MoY{lc($m)}) or return; -+ -+ if (defined(my $off = $OBSOLETE_TZ{$tz})) { -+ $sign = substr($off, 0, 1); -+ $tz = substr($off, 1); -+ } -+ -+ # Y2K problems: 3-digit years, follow RFC2822 -+ if (length($yyyy) <= 3) { -+ $yyyy += 1900; -+ -+ # and 2-digit years from '09 (2009) (0..49) -+ $yyyy += 100 if $yyyy < 1950; -+ } -+ -+ $ts = timegm($ss // 0, $mm, $hh, $dd, $mon, $yyyy); - -- my $ts = str2time($date); -- return undef unless(defined $ts); -+ # Compute the time offset from [+-]HHMM -+ $tz //= 0; -+ my ($tz_hh, $tz_mm); -+ if (length($tz) == 1) { -+ $tz_hh = $tz; -+ $tz_mm = 0; -+ } elsif (length($tz) == 2) { -+ $tz_hh = 0; -+ $tz_mm = $tz; -+ } else { -+ $tz_hh = $tz; -+ $tz_hh =~ s/([0-9]{2})\z//; -+ $tz_mm = $1; -+ } -+ while ($tz_mm >= 60) { -+ $tz_mm -= 60; -+ $tz_hh += 1; -+ } -+ $sign //= '+'; -+ my $off = $sign . ($tz_mm * 60 + ($tz_hh * 60 * 60)); -+ $ts -= $off; -+ $sign = '+' if $off == 0; -+ $zone = sprintf('%s%02d%02d', $sign, $tz_hh, $tz_mm); - -- # off is the time zone offset in seconds from GMT -- my ($ss,$mm,$hh,$day,$month,$year,$off) = strptime($date); -- return undef unless(defined $off); -+ # Time::Zone and Date::Parse are part of the same distibution, -+ # and we need Time::Zone to deal with tz names like "EDT" -+ } elsif (eval { require Date::Parse }) { -+ $ts = Date::Parse::str2time($date); -+ return undef unless(defined $ts); - -- # Compute the time zone from offset -- my $sign = ($off < 0) ? '-' : '+'; -- my $hour = abs(int($off / 3600)); -- my $min = ($off / 60) % 60; -- my $zone = sprintf('%s%02d%02d', $sign, $hour, $min); -+ # off is the time zone offset in seconds from GMT -+ my ($ss,$mm,$hh,$day,$month,$year,$off) = -+ Date::Parse::strptime($date); -+ return undef unless(defined $off); -+ -+ # Compute the time zone from offset -+ my $sign = ($off < 0) ? '-' : '+'; -+ my $hour = abs(int($off / 3600)); -+ my $min = ($off / 60) % 60; -+ -+ $zone = sprintf('%s%02d%02d', $sign, $hour, $min); -+ } else { -+ warn "Date::Parse missing for non-RFC822 date: $date\n"; -+ return undef; -+ } - -+ # Note: we've already applied the offset to $ts at this point, -+ # but we want to keep "git fsck" happy. - # "-1200" is the furthest westermost zone offset, - # but git fast-import is liberal so we use "-1400" - if ($zone >= 1400 || $zone <= -1400) { -@@ -59,9 +149,6 @@ sub msg_date_only ($) { - my @date = $hdr->header_raw('Date'); - my ($ts); - foreach my $d (@date) { -- # Y2K problems: 3-digit years -- $d =~ s!([A-Za-z]{3}) ([0-9]{3}) ([0-9]{2}:[0-9]{2}:[0-9]{2})! -- my $yyyy = $2 + 1900; "$1 $yyyy $3"!e; - $ts = eval { str2date_zone($d) } and return $ts; - if ($@) { - my $mid = $hdr->header_raw('Message-ID'); -diff --git a/t/msgtime.t b/t/msgtime.t -index 6b396602..d9643b65 100644 ---- a/t/msgtime.t -+++ b/t/msgtime.t -@@ -84,4 +84,10 @@ is_deeply(datestamp('Fri, 28 Jun 2002 12:54:40 -700'), [1025294080, '-0700']); - is_deeply(datestamp('Sat, 12 Jan 2002 12:52:57 -200'), [1010847177, '-0200']); - is_deeply(datestamp('Mon, 05 Nov 2001 10:36:16 -800'), [1004985376, '-0800']); - -+# obsolete formats described in RFC2822 -+for (qw(UT GMT Z)) { -+ is_deeply(datestamp('Fri, 02 Oct 1993 00:00:00 '.$_), [ 749520000, '+0000']); -+} -+is_deeply(datestamp('Fri, 02 Oct 1993 00:00:00 EDT'), [ 749534400, '-0400']); -+ - done_testing(); --- -2.24.1 - diff --git a/pkgs/servers/mail/public-inbox/default.nix b/pkgs/servers/mail/public-inbox/default.nix index affcb0e8b23..8ffbab1eac1 100644 --- a/pkgs/servers/mail/public-inbox/default.nix +++ b/pkgs/servers/mail/public-inbox/default.nix @@ -1,19 +1,73 @@ -{ buildPerlPackage, lib, fetchurl, fetchpatch, makeWrapper -, DBDSQLite, EmailMIME, IOSocketSSL, IPCRun, Plack, PlackMiddlewareReverseProxy -, SearchXapian, TimeDate, URI -, git, highlight, openssl, xapian +{ stdenv, lib, fetchurl, makeWrapper, nixosTests +, buildPerlPackage +, coreutils +, curl +, git +, gnumake +, highlight +, libgit2 +, man +, openssl +, pkg-config +, sqlite +, xapian +, AnyURIEscape +, DBDSQLite +, DBI +, EmailAddressXS +, EmailMIME +, IOSocketSSL +, IPCRun +, Inline +, InlineC +, LinuxInotify2 +, MailIMAPClient +, ParseRecDescent +, Plack +, PlackMiddlewareReverseProxy +, SearchXapian +, TimeDate +, URI }: let - # These tests would fail, and produce "Operation not permitted" - # errors from git, because they use git init --shared. This tries - # to set the setgid bit, which isn't permitted inside build - # sandboxes. - # - # These tests were indentified with - # grep -r shared t/ - skippedTests = [ "convert-compact" "search" "v2writable" "www_listing" ]; + skippedTests = [ + # These tests would fail, and produce "Operation not permitted" + # errors from git, because they use git init --shared. This tries + # to set the setgid bit, which isn't permitted inside build + # sandboxes. + # + # These tests were indentified with + # grep -r shared t/ + "convert-compact" "search" "v2writable" "www_listing" + # perl5.32.0-public-inbox> t/eml.t ...................... 1/? Cannot parse parameter '=?ISO-8859-1?Q?=20charset=3D=1BOF?=' at t/eml.t line 270. + # perl5.32.0-public-inbox> # Failed test 'got wide character by assuming utf-8' + # perl5.32.0-public-inbox> # at t/eml.t line 272. + # perl5.32.0-public-inbox> Wide character in print at /nix/store/38vxlxrvg3yji3jms44qn94lxdysbj5j-perl-5.32.0/lib/perl5/5.32.0/Test2/Formatter/TAP.pm line 125. + "eml" + # Failed test 'Makefile OK' + # at t/hl_mod.t line 19. + # got: 'makefile' + # expected: 'make' + "hl_mod" + # Failed test 'clone + index v1 synced ->created_at' + # at t/lei-mirror.t line 175. + # got: '1638378723' + # expected: undef + # Failed test 'clone + index v1 synced ->created_at' + # at t/lei-mirror.t line 178. + # got: '1638378723' + # expected: undef + # May be due to the use of $ENV{HOME}. + "lei-mirror" + # Failed test 'child error (pure-Perl)' + # at t/spawn.t line 33. + # got: '0' + # expected: anything else + # waiting for child to reap grandchild... + "spawn" + ]; testConditions = with lib; concatMapStringsSep " " (n: "! -name ${escapeShellArg n}.t") skippedTests; @@ -22,53 +76,86 @@ in buildPerlPackage rec { pname = "public-inbox"; - version = "1.2.0"; + version = "1.8.0"; src = fetchurl { - url = "https://public-inbox.org/releases/public-inbox-${version}.tar.gz"; - sha256 = "0sa2m4f2x7kfg3mi4im7maxqmqvawafma8f7g92nyfgybid77g6s"; + url = "https://public-inbox.org/public-inbox.git/snapshot/public-inbox-${version}.tar.gz"; + sha256 = "sha256-laJOOCk5NecIGWesv4D30cLGfijQHVkeo55eNqNKzew="; }; - patches = [ - (fetchpatch { - url = "https://public-inbox.org/meta/20200101032822.GA13063@dcvr/raw"; - sha256 = "0ncxqqkvi5lwi8zaa7lk7l8mf8h278raxsvbvllh3z7jhfb48r3l"; - }) - ./0002-msgtime-drop-Date-Parse-for-RFC2822.patch - ]; - outputs = [ "out" "devdoc" "sa_config" ]; postConfigure = '' substituteInPlace Makefile --replace 'TEST_FILES = t/*.t' \ 'TEST_FILES = $(shell find t -name *.t ${testConditions})' + substituteInPlace lib/PublicInbox/TestCommon.pm \ + --replace /bin/cp ${coreutils}/bin/cp ''; nativeBuildInputs = [ makeWrapper ]; buildInputs = [ - DBDSQLite EmailMIME IOSocketSSL IPCRun Plack PlackMiddlewareReverseProxy - SearchXapian TimeDate URI highlight + AnyURIEscape + DBDSQLite + DBI + EmailAddressXS + EmailMIME + highlight + IOSocketSSL + IPCRun + Inline + InlineC + ParseRecDescent + Plack + PlackMiddlewareReverseProxy + SearchXapian + TimeDate + URI + libgit2 # For Gcf2 + man ]; - checkInputs = [ git openssl xapian ]; + doCheck = !stdenv.isDarwin; + checkInputs = [ + MailIMAPClient + curl + git + openssl + pkg-config + sqlite + xapian + ] ++ lib.optionals stdenv.isLinux [ + LinuxInotify2 + ]; preCheck = '' perl certs/create-certs.perl + export TEST_LEI_ERR_LOUD=1 + export HOME="$NIX_BUILD_TOP"/home + mkdir -p "$HOME"/.cache/public-inbox/inline-c ''; installTargets = [ "install" ]; postInstall = '' for prog in $out/bin/*; do - wrapProgram $prog --prefix PATH : ${lib.makeBinPath [ git ]} + wrapProgram $prog --prefix PATH : ${lib.makeBinPath [ + git + /* for InlineC */ + gnumake + stdenv.cc.cc + ]} done mv sa_config $sa_config ''; + passthru.tests = { + nixos-public-inbox = nixosTests.public-inbox; + }; + meta = with lib; { homepage = "https://public-inbox.org/"; license = licenses.agpl3Plus; - maintainers = with maintainers; [ qyliss ]; + maintainers = with maintainers; [ julm qyliss ]; platforms = platforms.all; }; }