diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 43ae28ac02c5..3aae26f38543 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -516,6 +516,7 @@
./services/mail/rspamd.nix
./services/mail/rss2email.nix
./services/mail/roundcube.nix
+ ./services/mail/schleuder.nix
./services/mail/sympa.nix
./services/mail/nullmailer.nix
./services/matrix/appservice-discord.nix
diff --git a/nixos/modules/services/mail/schleuder.nix b/nixos/modules/services/mail/schleuder.nix
new file mode 100644
index 000000000000..7ba15f1070bd
--- /dev/null
+++ b/nixos/modules/services/mail/schleuder.nix
@@ -0,0 +1,162 @@
+{ config, pkgs, lib, ... }:
+let
+ cfg = config.services.schleuder;
+ settingsFormat = pkgs.formats.yaml { };
+ postfixMap = entries: lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${value}") entries);
+ writePostfixMap = name: entries: pkgs.writeText name (postfixMap entries);
+ configScript = pkgs.writeScript "schleuder-cfg" ''
+ #!${pkgs.runtimeShell}
+ set -exuo pipefail
+ umask 0077
+ ${pkgs.yq}/bin/yq \
+ --slurpfile overrides <(${pkgs.yq}/bin/yq . <${lib.escapeShellArg cfg.extraSettingsFile}) \
+ < ${settingsFormat.generate "schleuder.yml" cfg.settings} \
+ '. * $overrides[0]' \
+ > /etc/schleuder/schleuder.yml
+ chown schleuder: /etc/schleuder/schleuder.yml
+ '';
+in
+{
+ options.services.schleuder = {
+ enable = lib.mkEnableOption "Schleuder secure remailer";
+ enablePostfix = lib.mkEnableOption "automatic postfix integration" // { default = true; };
+ lists = lib.mkOption {
+ description = ''
+ List of list addresses that should be handled by Schleuder.
+
+ Note that this is only handled by the postfix integration, and
+ the setup of the lists, their members and their keys has to be
+ performed separately via schleuder's API, using a tool such as
+ schleuder-cli.
+ '';
+ type = lib.types.listOf lib.types.str;
+ default = [ ];
+ example = [ "widget-team@example.com" "security@example.com" ];
+ };
+ /* maybe one day....
+ domains = lib.mkOption {
+ description = "Domains for which all mail should be handled by Schleuder.";
+ type = lib.types.listOf lib.types.str;
+ default = [];
+ example = ["securelists.example.com"];
+ };
+ */
+ settings = lib.mkOption {
+ description = ''
+ Settings for schleuder.yml.
+
+ Check the example configuration for possible values.
+ '';
+ type = lib.types.submodule {
+ freeformType = settingsFormat.type;
+ options.keyserver = lib.mkOption {
+ type = lib.types.str;
+ description = ''
+ Key server from which to fetch and update keys.
+
+ Note that NixOS uses a different default from upstream, since the upstream default sks-keyservers.net is deprecated.
+ '';
+ default = "keys.openpgp.org";
+ };
+ };
+ default = { };
+ };
+ extraSettingsFile = lib.mkOption {
+ description = "YAML file to merge into the schleuder config at runtime. This can be used for secrets such as API keys.";
+ type = lib.types.nullOr lib.types.path;
+ default = null;
+ };
+ listDefaults = lib.mkOption {
+ description = ''
+ Default settings for lists (list-defaults.yml).
+
+ Check the example configuration for possible values.
+ '';
+ type = settingsFormat.type;
+ default = { };
+ };
+ };
+ config = lib.mkIf cfg.enable {
+ assertions = [
+ {
+ assertion = !(cfg.settings.api ? valid_api_keys);
+ message = ''
+ services.schleuder.settings.api.valid_api_keys is set. Defining API keys via NixOS config results in them being copied to the world-readable Nix store. Please use the extraSettingsFile option to store API keys in a non-public location.
+ '';
+ }
+ {
+ assertion = !(lib.any (db: db ? password) (lib.attrValues cfg.settings.database or {}));
+ message = ''
+ A password is defined for at least one database in services.schleuder.settings.database. Defining passwords via NixOS config results in them being copied to the world-readable Nix store. Please use the extraSettingsFile option to store database passwords in a non-public location.
+ '';
+ }
+ ];
+ users.users.schleuder.isSystemUser = true;
+ users.users.schleuder.group = "schleuder";
+ users.groups.schleuder = {};
+ environment.systemPackages = [
+ pkgs.schleuder-cli
+ ];
+ services.postfix = lib.mkIf cfg.enablePostfix {
+ extraMasterConf = ''
+ schleuder unix - n n - - pipe
+ flags=DRhu user=schleuder argv=/${pkgs.schleuder}/bin/schleuder work ''${recipient}
+ '';
+ transport = lib.mkIf (cfg.lists != [ ]) (postfixMap (lib.genAttrs cfg.lists (_: "schleuder:")));
+ extraConfig = ''
+ schleuder_destination_recipient_limit = 1
+ '';
+ # review: does this make sense?
+ localRecipients = lib.mkIf (cfg.lists != [ ]) cfg.lists;
+ };
+ systemd.services = let commonServiceConfig = {
+ # We would have liked to use DynamicUser, but since the default
+ # database is SQLite and lives in StateDirectory, and that same
+ # database needs to be readable from the postfix service, this
+ # isn't trivial to do.
+ User = "schleuder";
+ StateDirectory = "schleuder";
+ StateDirectoryMode = "0700";
+ }; in
+ {
+ schleuder-init = {
+ serviceConfig = commonServiceConfig // {
+ ExecStartPre = lib.mkIf (cfg.extraSettingsFile != null) [
+ "+${configScript}"
+ ];
+ ExecStart = [ "${pkgs.schleuder}/bin/schleuder install" ];
+ Type = "oneshot";
+ };
+ };
+ schleuder-api-daemon = {
+ after = [ "local-fs.target" "network.target" "schleuder-init.service" ];
+ wantedBy = [ "multi-user.target" ];
+ requires = [ "schleuder-init.service" ];
+ serviceConfig = commonServiceConfig // {
+ ExecStart = [ "${pkgs.schleuder}/bin/schleuder-api-daemon" ];
+ };
+ };
+ schleuder-weekly-key-maintenance = {
+ after = [ "local-fs.target" "network.target" ];
+ startAt = "weekly";
+ serviceConfig = commonServiceConfig // {
+ ExecStart = [
+ "${pkgs.schleuder}/bin/schleuder refresh_keys"
+ "${pkgs.schleuder}/bin/schleuder check_keys"
+ ];
+ };
+ };
+ };
+
+ environment.etc."schleuder/schleuder.yml" = lib.mkIf (cfg.extraSettingsFile == null) {
+ source = settingsFormat.generate "schleuder.yml" cfg.settings;
+ };
+ environment.etc."schleuder/list-defaults.yml".source = settingsFormat.generate "list-defaults.yml" cfg.listDefaults;
+
+ services.schleuder = {
+ #lists_dir = "/var/lib/schleuder.lists";
+ settings.filters_dir = lib.mkDefault "/var/lib/schleuder/filters";
+ settings.keyword_handlers_dir = lib.mkDefault "/var/lib/schleuder/keyword_handlers";
+ };
+ };
+}
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index 38c320886d1a..658c62b2a4e2 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -485,6 +485,7 @@ in {
samba = handleTest ./samba.nix {};
samba-wsdd = handleTest ./samba-wsdd.nix {};
sanoid = handleTest ./sanoid.nix {};
+ schleuder = handleTest ./schleuder.nix {};
sddm = handleTest ./sddm.nix {};
seafile = handleTest ./seafile.nix {};
searx = handleTest ./searx.nix {};
diff --git a/nixos/tests/schleuder.nix b/nixos/tests/schleuder.nix
new file mode 100644
index 000000000000..a9e4cc325bc7
--- /dev/null
+++ b/nixos/tests/schleuder.nix
@@ -0,0 +1,128 @@
+let
+ certs = import ./common/acme/server/snakeoil-certs.nix;
+ domain = certs.domain;
+in
+import ./make-test-python.nix {
+ name = "schleuder";
+ nodes.machine = { pkgs, ... }: {
+ imports = [ ./common/user-account.nix ];
+ services.postfix = {
+ enable = true;
+ enableSubmission = true;
+ tlsTrustedAuthorities = "${certs.ca.cert}";
+ sslCert = "${certs.${domain}.cert}";
+ sslKey = "${certs.${domain}.key}";
+ inherit domain;
+ destination = [ domain ];
+ localRecipients = [ "root" "alice" "bob" ];
+ };
+ services.schleuder = {
+ enable = true;
+ # Don't do it like this in production! The point of this setting
+ # is to allow loading secrets from _outside_ the world-readable
+ # Nix store.
+ extraSettingsFile = pkgs.writeText "schleuder-api-keys.yml" ''
+ api:
+ valid_api_keys:
+ - fnord
+ '';
+ lists = [ "security@${domain}" ];
+ settings.api = {
+ tls_cert_file = "${certs.${domain}.cert}";
+ tls_key_file = "${certs.${domain}.key}";
+ };
+ };
+
+ environment.systemPackages = [
+ pkgs.gnupg
+ pkgs.msmtp
+ (pkgs.writeScriptBin "do-test" ''
+ #!${pkgs.runtimeShell}
+ set -exuo pipefail
+
+ # Generate a GPG key with no passphrase and export it
+ sudo -u alice gpg --passphrase-fd 0 --batch --yes --quick-generate-key 'alice@${domain}' rsa4096 sign,encr < <(echo)
+ sudo -u alice gpg --armor --export alice@${domain} > alice.asc
+ # Create a new mailing list with alice as the owner, and alice's key
+ schleuder-cli list new security@${domain} alice@${domain} alice.asc
+
+ # Send an email from a non-member of the list. Use --auto-from so we don't have to specify who it's from twice.
+ msmtp --auto-from security@${domain} --host=${domain} --port=25 --tls --tls-starttls < list.asc
+
+ # Import the key into alice's keyring, so we can verify it as well as decrypting
+ sudo -u alice gpg --import decrypted
+ # And check that the text matches.
+ grep "big security problem" decrypted
+ '')
+
+ # For debugging:
+ # pkgs.vim pkgs.openssl pkgs.sqliteinteractive
+ ];
+
+ security.pki.certificateFiles = [ certs.ca.cert ];
+
+ # Since we don't have internet here, use dnsmasq to provide MX records from /etc/hosts
+ services.dnsmasq = {
+ enable = true;
+ extraConfig = ''
+ selfmx
+ '';
+ };
+
+ networking.extraHosts = ''
+ 127.0.0.1 ${domain}
+ '';
+
+ # schleuder-cli's config is not quite optimal in several ways:
+ # - A fingerprint _must_ be pinned, it doesn't even have an option
+ # to trust the PKI
+ # - It compares certificate fingerprints rather than key
+ # fingerprints, so renewals break the pin (though that's not
+ # relevant for this test)
+ # - It compares them as strings, which means we need to match the
+ # expected format exactly. This means removing the :s and
+ # lowercasing it.
+ # Refs:
+ # https://0xacab.org/schleuder/schleuder-cli/-/issues/16
+ # https://0xacab.org/schleuder/schleuder-cli/-/blob/f8895b9f47083d8c7b99a2797c93f170f3c6a3c0/lib/schleuder-cli/helper.rb#L230-238
+ systemd.tmpfiles.rules = let cliconfig = pkgs.runCommand "schleuder-cli.yml"
+ {
+ nativeBuildInputs = [ pkgs.jq pkgs.openssl ];
+ } ''
+ fp=$(openssl x509 -in ${certs.${domain}.cert} -noout -fingerprint -sha256 | cut -d = -f 2 | tr -d : | tr 'A-Z' 'a-z')
+ cat > $out <