From bc01d4d2d03c1d7e52c81739f81060a527f3e9ac Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 30 Nov 2024 19:35:15 +0100 Subject: [PATCH 01/61] mailsystem: Add minimal dovecot configuration --- mailsystem/common.nix | 3 + mailsystem/default.nix | 47 ++++++++ mailsystem/dovecot.nix | 255 +++++++++++++++++++++++++++++++++++++++++ mailsystem/nginx.nix | 4 + 4 files changed, 309 insertions(+) create mode 100644 mailsystem/dovecot.nix diff --git a/mailsystem/common.nix b/mailsystem/common.nix index 6d9df16..2dd170c 100644 --- a/mailsystem/common.nix +++ b/mailsystem/common.nix @@ -4,4 +4,7 @@ in rec { sslCertPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem"; sslKeyPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem"; sslCertService = ["acme-finished-${cfg.fqdn}.target"]; + + dovecotDynamicStateDir = "/var/lib/dovecot"; + dovecotDynamicPasswdFile = "${dovecotDynamicStateDir}/passwd"; } diff --git a/mailsystem/default.nix b/mailsystem/default.nix index abc5b73..51d6fbd 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -37,9 +37,56 @@ default = "/var/vmail"; description = "Storage location for all mail."; }; + + accounts = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule ({name, ...}: { + options = { + name = lib.mkOption { + type = lib.types.str; + example = "user1@example.com"; + description = "Username"; + }; + + hashedPasswordFile = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + example = "/run/secrets/user1-passwordhash"; + description = '' + A file containing the user's hashed password. Use `mkpasswd` as follows + + ``` + nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' + ``` + ''; + }; + + isSystemUser = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + System users are not allowed to change their password and are + cannot receive any mails (-> send-only). Mails sent to such an + account will be rejected. + ''; + }; + }; + config.name = lib.mkDefault name; + })); + example = { + user1 = { + hashedPassword = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/"; + }; + user2 = { + hashedPassword = "$6$oE0ZNv2n7Vk9gOf$9xcZWCCLGdMflIfuA0vR1Q1Xblw6RZqPrP94mEit2/81/7AKj2bqUai5yPyWE.QYPyv6wLMHZvjw3Rlg7yTCD/"; + }; + }; + description = "All available login account for the mailsystem."; + default = {}; + }; }; imports = [ + ./dovecot.nix ./nginx.nix ./user.nix ]; diff --git a/mailsystem/dovecot.nix b/mailsystem/dovecot.nix new file mode 100644 index 0000000..460e0ef --- /dev/null +++ b/mailsystem/dovecot.nix @@ -0,0 +1,255 @@ +{ + config, + lib, + pkgs, + ... +}: +with (import ./common.nix {inherit config;}); let + cfg = config.mailsystem; + postfixCfg = config.services.postfix; + dovecot2Cfg = config.services.dovecot2; + + runtimeStateDir = "/run/dovecot2"; + + staticPasswdFile = "${runtimeStateDir}/passwd"; + initialPasswdFile = "${runtimeStateDir}/initial-passwd"; + userdbFile = "${runtimeStateDir}/userdb"; + # dovecotDynamicStateDir and dovecotDynamicPasswdFile are defined in common.nix + + systemUsers = lib.filterAttrs (user: value: value.isSystemUser) cfg.accounts; + normalUsers = lib.filterAttrs (user: value: !value.isSystemUser) cfg.accounts; + + genUserdbEntry = user: value: "${user}:::::::"; + genPasswdEntry = user: value: "${user}:${"$(head -n 1 ${value.hashedPasswordFile})"}::::::"; + + genAuthDbsScript = pkgs.writeScript "generate-dovecot-auth-dbs" '' + #!${pkgs.stdenv.shell} + set -euo pipefail + + if (! test -d "${runtimeStateDir}"); then + mkdir "${runtimeStateDir}" + chmod 755 "${runtimeStateDir}" + fi + + if (! test -d "${dovecotDynamicStateDir}"); then + mkdir "${dovecotDynamicStateDir}" + chmod 755 "${dovecotDynamicStateDir}" + fi + + # Ensure passwd files are not world-readable at any time + umask 077 + + # Ensure we have a file for every user's (initial) password hash. + for f in ${builtins.toString (lib.mapAttrsToList (user: value: value.hashedPasswordFile) cfg.accounts)}; do + if [ ! -f "$f" ]; then + echo "Expected password hash file $f does not exist!" + exit 1 + fi + done + + # Prepare static passwd-file for system users + cat < "${staticPasswdFile}" + ${lib.concatStringsSep "\n" (lib.mapAttrsToList genPasswdEntry systemUsers)} + EOF + + # Prepare initial passwd-file for dynamic users + # (used for lookup during actual passwd-file generation) + cat < "${initialPasswdFile}" + ${lib.concatStringsSep "\n" (lib.mapAttrsToList genPasswdEntry normalUsers)} + EOF + + # Check for existence of dynamic passwd-file + touch "${dovecotDynamicPasswdFile}" + if (! test -f "${dovecotDynamicPasswdFile}"); then + echo "${dovecotDynamicPasswdFile} exists and is no regular file" + exit 1 + fi + # Ensure that only configured users are actually present and remove any others + truncate -s 0 "${dovecotDynamicPasswdFile}-filtered" + for u in ${builtins.toString (lib.mapAttrsToList (user: value: value.name) normalUsers)}; do + if grep -q "^$u:" "${dovecotDynamicPasswdFile}"; then + # User already has some password set -> Keep currently set password + grep "^$u:" "${dovecotDynamicPasswdFile}" >> "${dovecotDynamicPasswdFile}-filtered" + else + # User has no password set, yet -> Take password from initialPasswdFile + grep "^$u:" "${initialPasswdFile}" >> "${dovecotDynamicPasswdFile}-filtered" + fi + done + mv "${dovecotDynamicPasswdFile}-filtered" "${dovecotDynamicPasswdFile}" + + # Prepare userdb-file + cat < "${userdbFile}" + ${lib.concatStringsSep "\n" (lib.mapAttrsToList genUserdbEntry cfg.accounts)} + EOF + ''; + + genMaildir = pkgs.writeScript "generate-maildir" '' + #!${pkgs.stdenv.shell} + + # Create mail directory and set permissions accordingly. + umask 007 + mkdir -p ${cfg.mailDirectory} + chgrp "${cfg.vmailGroupName}" ${cfg.mailDirectory} + chmod 02770 ${cfg.mailDirectory} + ''; +in { + options.mailsystem.dovecot.dhparamSize = lib.mkOption { + type = lib.types.int; + default = 2048; + description = "The bit size for the prime that is used during a Diffie-Hellman key exchange by dovecot."; + }; + + config = lib.mkIf cfg.enable { + assertions = + lib.mapAttrsToList (user: value: [ + { + assertion = value.hashedPasswordFile != null; + message = "A file containing the hashed password for user ${user} needs to be set."; + } + ]) + cfg.accounts; + + services.dovecot2 = { + enable = true; + enableImap = true; + enablePAM = false; + # TODO: enable quota and setup quota warnings + #enableQuota = true; + mailUser = cfg.vmailUserName; + mailGroup = cfg.vmailGroupName; + mailLocation = "maildir:~/Maildir"; + + sslServerCert = sslCertPath; + sslServerKey = sslKeyPath; + + enableLmtp = true; + + modules = [ + # sieves + managesieve + pkgs.dovecot_pigeonhole + ]; + + # enable managesieve + protocols = ["sieve"]; + + pluginSettings = { + sieve = "file:~/sieve;active=~/.dovecot.sieve"; + }; + + # TODO: move configuration to default.nix? + mailboxes = { + Drafts = { + auto = "subscribe"; + specialUse = "Drafts"; + }; + Junk = { + auto = "subscribe"; + specialUse = "Junk"; + }; + Trash = { + auto = "subscribe"; + specialUse = "Trash"; + }; + Sent = { + auto = "subscribe"; + specialUse = "Sent"; + }; + }; + + extraConfig = '' + service imap-login { + inet_listener imap { + # Using starttls for encryption shifts the responsibility for + # encrypted communication over to the client in the face of MITM. + # However, it has shown that clients are often not enforcing it. + port = 0 # clients should use starttls instaed + } + inet_listener imaps { + port = 993 + ssl = yes + } + } + + protocol imap { + mail_max_userip_connections = 100 + mail_plugins = $mail_plugins imap_sieve + } + + mail_access_groups = ${cfg.vmailGroupName} + ssl = required + ssl_min_protocol = TLSv1.2 + ssl_prefer_server_ciphers = yes + + service lmtp { + unix_listener dovecot-lmtp { + user = ${postfixCfg.user} + group = ${postfixCfg.group} + mode = 0600 + } + } + + recipient_delimiter = + + lmtp_save_to_detail_mailbox = no + + protocol lmtp { + mail_plugins = $mail_plugins sieve + } + + # Passwords stored among two passwd-files: One for users allowed to + # change their password and one for any other user (mostly system + # users) with immutable passwords. + passdb { + driver = passwd-file + args = ${dovecotDynamicPasswdFile} + } + passdb { + driver = passwd-file + args = ${staticPasswdFile} + } + + userdb { + driver = passwd-file + args = ${userdbFile} + default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory}/%d/%n + } + + service auth { + unix_listener auth { + user = ${postfixCfg.user} + group = ${postfixCfg.group} + mode = 0660 + } + } + + auth_mechanisms = plain login + + namespace inbox { + separator = / + inbox = yes + } + + lda_mailbox_autosubscribe = yes + lda_mailbox_autocreate = yes + ''; + }; + + security.dhparams.params.dovecot2.bits = cfg.dovecot.dhparamSize; + + systemd.services.dovecot2 = { + preStart = '' + ${genAuthDbsScript} + ${genMaildir} + ''; + wants = sslCertService; + after = sslCertService; + }; + systemd.services.postfix.restartTriggers = [genAuthDbsScript]; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [ + 993 # imaps + 4190 #managesieve + ]; + }; + }; +} diff --git a/mailsystem/nginx.nix b/mailsystem/nginx.nix index 2bb294b..e780b4a 100644 --- a/mailsystem/nginx.nix +++ b/mailsystem/nginx.nix @@ -16,5 +16,9 @@ in { }; networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443]; + + security.acme.certs."${cfg.fqdn}".reloadServices = [ + "dovecot2.service" + ]; }; } From b7fac23bd14195bb62cb4b480a7727dc888ea71e Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Wed, 4 Dec 2024 21:39:42 +0100 Subject: [PATCH 02/61] mailsystem: Add basic postfix configuration --- mailsystem/default.nix | 74 +++++++++++++++- mailsystem/nginx.nix | 1 + mailsystem/postfix.nix | 194 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 mailsystem/postfix.nix diff --git a/mailsystem/default.nix b/mailsystem/default.nix index 51d6fbd..dbc0e29 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -1,4 +1,10 @@ -{lib, ...}: { +{ + config, + lib, + ... +}: let + cfg = config.mailsystem; +in { options.mailsystem = { enable = lib.mkEnableOption "nixos-mailsystem"; @@ -14,6 +20,32 @@ description = "Fully qualified domain name of the mail server."; }; + reverseFqdn = lib.mkOption { + type = lib.types.str; + default = cfg.fqdn; + defaultText = lib.literalMD "{option}`mailsystem.fqdn`"; + example = "server.example.com"; + description = '' + Fully qualified domain name used by the server to identify + with other servers. + + This needs to be set to the same value of the server's IP reverse DNS. + ''; + }; + + domains = lib.mkOption { + type = lib.types.listOf lib.types.str; + example = ["example.com"]; + default = []; + description = "List of domains to be served by the mail server"; + }; + + messageSizeLimit = lib.mkOption { + type = lib.types.int; + default = 64 * 1024 * 1024; + description = "Maximum accepted mail size"; + }; + vmailUID = lib.mkOption { type = lib.types.int; default = 5000; @@ -60,6 +92,17 @@ ''; }; + aliases = lib.mkOption { + type = with lib.types; listOf types.str; + example = ["abuse@example.com" "postmaster@example.com"]; + default = []; + description = '' + A list of aliases of this login account. + Note: Use list entries like "@example.com" to create a catchAll + that allows sending from all email addresses in these domain. + ''; + }; + isSystemUser = lib.mkOption { type = lib.types.bool; default = false; @@ -83,11 +126,40 @@ description = "All available login account for the mailsystem."; default = {}; }; + + extraVirtualAliases = lib.mkOption { + type = let + account = lib.mkOptionType { + name = "Login Account"; + check = account: builtins.elem account (builtins.attrNames cfg.accounts); + }; + in + with lib.types; attrsOf (either account (nonEmptyListOf account)); + example = { + "info@example.com" = "user1@example.com"; + "postmaster@example.com" = "user1@example.com"; + "abuse@example.com" = "user1@example.com"; + "multi@example.com" = ["user1@example.com" "user2@example.com"]; + }; + description = '' + Virtual Aliases. A virtual alias `"info@example.com" = "user1@example.com"` means that + all mail to `info@example.com` is forwarded to `user1@example.com`. Note + that it is expected that `postmaster@example.com` and `abuse@example.com` is + forwarded to some valid email address. (Alternatively you can create login + accounts for `postmaster` and (or) `abuse`). Furthermore, it also allows + the user `user1@example.com` to send emails as `info@example.com`. + It's also possible to create an alias for multiple accounts. In this + example all mails for `multi@example.com` will be forwarded to both + `user1@example.com` and `user2@example.com`. + ''; + default = {}; + }; }; imports = [ ./dovecot.nix ./nginx.nix + ./postfix.nix ./user.nix ]; } diff --git a/mailsystem/nginx.nix b/mailsystem/nginx.nix index e780b4a..7edd771 100644 --- a/mailsystem/nginx.nix +++ b/mailsystem/nginx.nix @@ -18,6 +18,7 @@ in { networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443]; security.acme.certs."${cfg.fqdn}".reloadServices = [ + "postfix.service" "dovecot2.service" ]; }; diff --git a/mailsystem/postfix.nix b/mailsystem/postfix.nix new file mode 100644 index 0000000..ec42a1c --- /dev/null +++ b/mailsystem/postfix.nix @@ -0,0 +1,194 @@ +{ + config, + lib, + pkgs, + ... +}: +with (import ./common.nix {inherit config;}); let + cfg = config.mailsystem; + + mappedFile = name: "hash:/var/lib/postfix/conf/${name}"; + + attrsToLookupTable = aliases: let + lookupTables = lib.mapAttrsToList (from: to: {"${from}" = to;}) aliases; + in + mergeLookupTables lookupTables; + + lookupTableToString = attrs: let + valueToString = value: lib.concatStringsSep ", " value; + in + lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs); + + mergeLookupTables = tables: lib.zipAttrsWith (n: v: lib.flatten v) tables; + + account_virtual_aliases = mergeLookupTables (lib.flatten (lib.mapAttrsToList + (name: value: let + to = name; + in + map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name)) + cfg.accounts)); + + extra_virtual_aliases = attrsToLookupTable cfg.extraVirtualAliases; + + all_virtual_aliases = mergeLookupTables [account_virtual_aliases extra_virtual_aliases]; + + aliases_file = let + content = lookupTableToString all_virtual_aliases; + in + builtins.toFile "virtual_aliases" content; + + # File containing all mappings of authenticated accounts and their sender mail addresses. + virtual_accounts_file = let + content = lookupTableToString all_virtual_aliases; + in + builtins.toFile "virtual_accounts" content; + + virtual_domains_file = builtins.toFile "virtual_domains" (lib.concatStringsSep "\n" cfg.domains); + + submission_header_cleanup_rules = pkgs.writeText "submission_header_cleanup_rules" '' + # Removes sensitive headers from mails handed in via the submission port. + # See https://thomas-leister.de/mailserver-debian-stretch/ + # Uses "pcre" style regex. + + /^Received:/ IGNORE + /^X-Originating-IP:/ IGNORE + /^X-Mailer:/ IGNORE + /^User-Agent:/ IGNORE + /^X-Enigmail:/ IGNORE + + # Replace the user's submitted hostname with the server's FQDN to hide the + # user's host/network. + /^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}> + ''; + + tls_protocols = "TLSv1.3, TLSv1.2, !TLSv1.1, !TLSv1, !SSLv2, !SSLv3"; + tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL"; +in { + config = lib.mkIf cfg.enable { + services.postfix = { + enable = true; + hostname = "${cfg.reverseFqdn}"; + networksStyle = "host"; + + sslCert = sslCertPath; + sslKey = sslKeyPath; + + enableSubmissions = true; + + # TODO: create function to simplify this? + mapFiles."virtual_aliases" = aliases_file; + mapFiles."virtual_accounts" = virtual_accounts_file; + virtual = lookupTableToString all_virtual_aliases; + + submissionsOptions = { + smtpd_tls_security_level = "encrypt"; + smtpd_sasl_auth_enable = "yes"; + smtpd_sasl_type = "dovecot"; + smtpd_sasl_path = "/run/dovecot2/auth"; + smtpd_sasl_security_options = "noanonymous"; + smtpd_sasl_local_domain = "$myhostname"; + smtpd_client_restrictions = "permit_sasl_authenticated,reject"; + # use mappedFile -> different path? + smtpd_sender_login_maps = "hash:/etc/postfix/virtual_accounts"; + smtpd_sender_restrictions = "reject_sender_login_mismatch"; + smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject"; + cleanup_service_name = "submission-header-cleanup"; + }; + + config = { + mydestination = ""; + recipient_delimiter = "+"; + smtpd_banner = "${cfg.fqdn} ESMTP NO UCE"; + disable_vrfy_command = true; + message_size_limit = toString cfg.messageSizeLimit; + + virtual_uid_maps = "static:${toString cfg.vmailUID}"; + virtual_gid_maps = "static:${toString cfg.vmailUID}"; + virtual_mailbox_base = cfg.mailDirectory; + virtual_mailbox_domains = virtual_domains_file; + virtual_mailbox_maps = [ + (mappedFile "virtual_aliases") + ]; + virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp"; + # Avoid leakage of X-Original-To, X-Delivered-To headers between recipients + lmtp_destination_recipient_limit = "1"; + + # sasl with dovecot (enforce authentication via dovecot) + smtpd_sasl_type = "dovecot"; + smtpd_sasl_path = "/run/dovecot2/auth"; + smtpd_sasl_auth_enable = true; + smtpd_relay_restrictions = [ + "permit_mynetworks" + "permit_sasl_authenticated" + "reject_unauth_destination" + ]; + + # TLS settings, inspired by https://github.com/jeaye/nix-files + # Submission by mail clients is handled in submissionOptions + smtpd_tls_security_level = "may"; + + # Disable obsolete protocols + smtpd_tls_protocols = tls_protocols; + smtp_tls_protocols = tls_protocols; + smtpd_tls_mandatory_protocols = tls_protocols; + smtp_tls_mandatory_protocols = tls_protocols; + + smtp_tls_ciphers = "high"; + smtpd_tls_ciphers = "high"; + smtp_tls_mandatory_ciphers = "high"; + smtpd_tls_mandatory_ciphers = "high"; + + # Disable deprecated ciphers + smtpd_tls_mandatory_exclude_ciphers = tls_exclude_ciphers; + smtpd_tls_exclude_ciphers = tls_exclude_ciphers; + smtp_tls_mandatory_exclude_ciphers = tls_exclude_ciphers; + smtp_tls_exclude_ciphers = tls_exclude_ciphers; + + tls_preempt_cipherlist = true; + + # Allowing AUTH on a non-encrypted connection poses a security risk + smtpd_tls_auth_only = true; + # Log only a summary message on TLS handshake completion + smtpd_tls_loglevel = "1"; + + # Configure a non-blocking source of randomness + tls_random_source = "dev:/dev/urandom"; + + # Fix for https://www.postfix.org/smtp-smuggling.html + smtpd_forbid_bare_newline = "yes"; + smtpd_forbid_bare_newline_exclusions = "$mynetworks"; + }; + + masterConfig = { + "lmtp" = { + # Add headers when delivering, see http://www.postfix.org/smtp.8.html + # D => Delivered-To, O => X-Original-To, R => Return-Path + args = ["flags=O"]; + }; + "submission-header-cleanup" = { + type = "unix"; + private = false; + chroot = false; + maxproc = 0; + command = "cleanup"; + args = ["-o" "header_checks=pcre:${submission_header_cleanup_rules}"]; + }; + }; + }; + + systemd.services.postfix = { + wants = sslCertService; + after = + ["dovecot2.service"] + ++ sslCertService; + requires = ["dovecot2.service"]; + }; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [ + 25 # smtp + 465 # submissions + ]; + }; + }; +} From d35763a8a2cc6ebf000cae2063986b7751626149 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 5 Dec 2024 14:10:04 +0100 Subject: [PATCH 03/61] mailsystem: Configure rspamd as spam filter --- mailsystem/common.nix | 3 ++ mailsystem/default.nix | 3 ++ mailsystem/kresd.nix | 11 ++++++ mailsystem/postfix.nix | 14 ++++++- mailsystem/redis.nix | 27 +++++++++++++ mailsystem/rspamd.nix | 88 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 mailsystem/kresd.nix create mode 100644 mailsystem/redis.nix create mode 100644 mailsystem/rspamd.nix diff --git a/mailsystem/common.nix b/mailsystem/common.nix index 2dd170c..f74539a 100644 --- a/mailsystem/common.nix +++ b/mailsystem/common.nix @@ -7,4 +7,7 @@ in rec { dovecotDynamicStateDir = "/var/lib/dovecot"; dovecotDynamicPasswdFile = "${dovecotDynamicStateDir}/passwd"; + + rspamdProxySocket = "/run/rspamd-proxy.sock"; + rspamdControllerSocket = "/run/rspamd-controller.sock"; } diff --git a/mailsystem/default.nix b/mailsystem/default.nix index dbc0e29..ae970a1 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -158,8 +158,11 @@ in { imports = [ ./dovecot.nix + ./kresd.nix ./nginx.nix ./postfix.nix + ./redis.nix + ./rspamd.nix ./user.nix ]; } diff --git a/mailsystem/kresd.nix b/mailsystem/kresd.nix new file mode 100644 index 0000000..448b644 --- /dev/null +++ b/mailsystem/kresd.nix @@ -0,0 +1,11 @@ +{ + config, + lib, + ... +}: let + cfg = config.mailsystem; +in { + config = lib.mkIf cfg.enable { + services.kresd.enable = true; + }; +} diff --git a/mailsystem/postfix.nix b/mailsystem/postfix.nix index ec42a1c..f0afb8d 100644 --- a/mailsystem/postfix.nix +++ b/mailsystem/postfix.nix @@ -154,6 +154,16 @@ in { # Configure a non-blocking source of randomness tls_random_source = "dev:/dev/urandom"; + smtpd_milters = [ + "unix:${rspamdProxySocket}" + ]; + # Also use milter for outgoing mails (for e.g., dkim) + non_smtpd_milters = [ + "unix:${rspamdProxySocket}" + ]; + milter_protocol = "6"; + milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer}"; + # Fix for https://www.postfix.org/smtp-smuggling.html smtpd_forbid_bare_newline = "yes"; smtpd_forbid_bare_newline_exclusions = "$mynetworks"; @@ -179,9 +189,9 @@ in { systemd.services.postfix = { wants = sslCertService; after = - ["dovecot2.service"] + ["dovecot2.service" "rspamd.service"] ++ sslCertService; - requires = ["dovecot2.service"]; + requires = ["dovecot2.service" "rspamd.service"]; }; networking.firewall = lib.mkIf cfg.openFirewall { diff --git a/mailsystem/redis.nix b/mailsystem/redis.nix new file mode 100644 index 0000000..658093d --- /dev/null +++ b/mailsystem/redis.nix @@ -0,0 +1,27 @@ +{ + config, + lib, + pkgs, + ... +}: let + cfg = config.mailsystem; + redisCfg = config.services.redis.servers.rspamd; + rspamdCfg = config.services.rspamd; +in { + config = lib.mkIf cfg.enable { + services.redis.servers.rspamd = { + enable = true; + # Don't accept connections via tcp + port = 0; + unixSocketPerm = 600; + }; + + # TODO: Run commands as service user instead of as root? + systemd.services.redis-rspamd.serviceConfig.ExecStartPost = + "+" + + pkgs.writeShellScript "redis-rspamd-postStart" '' + ${pkgs.acl.bin}/bin/setfacl -m "u:${rspamdCfg.user}:x" "${builtins.dirOf redisCfg.unixSocket}" + ${pkgs.acl.bin}/bin/setfacl -m "u:${rspamdCfg.user}:rw" "${redisCfg.unixSocket}" + ''; + }; +} diff --git a/mailsystem/rspamd.nix b/mailsystem/rspamd.nix new file mode 100644 index 0000000..7211f2b --- /dev/null +++ b/mailsystem/rspamd.nix @@ -0,0 +1,88 @@ +{ + config, + lib, + pkgs, + ... +}: +with (import ./common.nix {inherit config;}); let + cfg = config.mailsystem; + nginxcfg = config.services.nginx; + postfixCfg = config.services.postfix; + redisCfg = config.services.redis.servers.rspamd; + rspamdCfg = config.services.rspamd; + + genSystemdSocketCfg = name: socketPath: additionalUser: { + description = "rspamd ${name} worker socket"; + listenStreams = [socketPath]; + requiredBy = ["rspamd.service"]; + socketConfig = { + Service = "rspamd.service"; + SocketUser = rspamdCfg.user; + SocketMode = 0600; + ExecStartPost = + lib.mkIf (additionalUser != "") + ''${pkgs.acl.bin}/bin/setfacl -m "u:${additionalUser}:rw" "${socketPath}"''; + }; + }; +in { + config = lib.mkIf cfg.enable { + services.rspamd = { + enable = true; + overrides = { + "classifier-bayes.conf" = { + text = '' + autolearn { + spam_threshold = 6.0 # When to learn spam (score >= threshold) + ham_threshold = -2.0 # When to learn ham (score <= threshold) + } + ''; + }; + "milter_headers.conf" = { + text = '' + # Add headers related to spam-detection + extended_spam_headers = true; + ''; + }; + "redis.conf" = { + text = '' + servers = "${redisCfg.unixSocket}"; + ''; + }; + }; + + workers = { + rspamd_proxy = { + bindSockets = ["systemd:rspamd-proxy.socket"]; + count = 1; # Do not spawn too many processes of this type + extraConfig = '' + milter = yes; # Enable milter mode + timeout = 120s; # Needed for Milter usually + + upstream "local" { + default = yes; # Self-scan upstreams are always default + self_scan = yes; # Enable self-scan + } + ''; + }; + + controller = { + count = 1; + bindSockets = ["systemd:rspamd-controller.socket"]; + extraConfig = '' + static_dir = "''${WWWDIR}"; # Serve the web UI static assets + ''; + }; + }; + }; + + systemd.sockets = { + rspamd-proxy = genSystemdSocketCfg "proxy" rspamdProxySocket postfixCfg.user; + rspamd-controller = genSystemdSocketCfg "controller" rspamdControllerSocket ""; + }; + + systemd.services.rspamd = { + requires = ["redis-rspamd.service"]; + after = ["redis-rspamd.service"]; + }; + }; +} From 0ce3ecae52560fe0e2315977e65d5982687ba5f8 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 5 Dec 2024 14:58:21 +0100 Subject: [PATCH 04/61] mailsystem: dovecot: Autolearn ham/spam when moving mails --- mailsystem/dovecot.nix | 56 +++++++++++++++++++++++++++- mailsystem/dovecot/report-ham.sieve | 15 ++++++++ mailsystem/dovecot/report-spam.sieve | 7 ++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 mailsystem/dovecot/report-ham.sieve create mode 100644 mailsystem/dovecot/report-spam.sieve diff --git a/mailsystem/dovecot.nix b/mailsystem/dovecot.nix index 460e0ef..4f17464 100644 --- a/mailsystem/dovecot.nix +++ b/mailsystem/dovecot.nix @@ -92,6 +92,14 @@ with (import ./common.nix {inherit config;}); let chgrp "${cfg.vmailGroupName}" ${cfg.mailDirectory} chmod 02770 ${cfg.mailDirectory} ''; + + junkMailboxes = builtins.attrNames (lib.filterAttrs (n: v: v ? "specialUse" && v.specialUse == "Junk") dovecot2Cfg.mailboxes); + junkMailboxNumber = builtins.length junkMailboxes; + # The assertion guarantees that there is exactly one Junk mailbox. + junkMailboxName = + if junkMailboxNumber == 1 + then builtins.elemAt junkMailboxes 0 + else ""; in { options.mailsystem.dovecot.dhparamSize = lib.mkOption { type = lib.types.int; @@ -101,12 +109,18 @@ in { config = lib.mkIf cfg.enable { assertions = - lib.mapAttrsToList (user: value: [ + [ { + assertion = junkMailboxNumber == 1; + message = "mailnix requires exactly one dovecot mailbox with the 'special use' flag to 'Junk' (${builtins.toString junkMailboxNumber} have been found)"; + } + ] + ++ lib.mapAttrsToList ( + user: value: { assertion = value.hashedPasswordFile != null; message = "A file containing the hashed password for user ${user} needs to be set."; } - ]) + ) cfg.accounts; services.dovecot2 = { @@ -136,6 +150,44 @@ in { sieve = "file:~/sieve;active=~/.dovecot.sieve"; }; + sieve = { + extensions = [ + "fileinto" + "mailbox" + ]; + + scripts.after = builtins.toFile "spam.sieve" '' + require "fileinto"; + require "mailbox"; + + if header :is "X-Spam" "Yes" { + fileinto :create "${junkMailboxName}"; + stop; + } + ''; + + pipeBins = map lib.getExe [ + (pkgs.writeShellScriptBin "learn-ham.sh" + "exec ${pkgs.rspamd}/bin/rspamc -h ${rspamdControllerSocket} learn_ham") + (pkgs.writeShellScriptBin "learn-spam.sh" + "exec ${pkgs.rspamd}/bin/rspamc -h ${rspamdControllerSocket} learn_spam") + ]; + }; + + imapsieve.mailbox = [ + { + name = junkMailboxName; + causes = ["COPY" "APPEND"]; + before = ./dovecot/report-spam.sieve; + } + { + name = "*"; + from = junkMailboxName; + causes = ["COPY"]; + before = ./dovecot/report-ham.sieve; + } + ]; + # TODO: move configuration to default.nix? mailboxes = { Drafts = { diff --git a/mailsystem/dovecot/report-ham.sieve b/mailsystem/dovecot/report-ham.sieve new file mode 100644 index 0000000..6217a90 --- /dev/null +++ b/mailsystem/dovecot/report-ham.sieve @@ -0,0 +1,15 @@ +require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; + +if environment :matches "imap.mailbox" "*" { + set "mailbox" "${1}"; +} + +if string "${mailbox}" "Trash" { + stop; +} + +if environment :matches "imap.user" "*" { + set "username" "${1}"; +} + +pipe :copy "learn-ham.sh" [ "${username}" ]; diff --git a/mailsystem/dovecot/report-spam.sieve b/mailsystem/dovecot/report-spam.sieve new file mode 100644 index 0000000..9d4c74b --- /dev/null +++ b/mailsystem/dovecot/report-spam.sieve @@ -0,0 +1,7 @@ +require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; + +if environment :matches "imap.user" "*" { + set "username" "${1}"; +} + +pipe :copy "learn-spam.sh" [ "${username}" ]; From 558367638427152f4304a398cc4b3047885ecb01 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 5 Dec 2024 15:38:11 +0100 Subject: [PATCH 05/61] mailsystem: rspamd: Add configuration options to make rspamd's web ui accessible --- mailsystem/rspamd.nix | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/mailsystem/rspamd.nix b/mailsystem/rspamd.nix index 7211f2b..6418a9b 100644 --- a/mailsystem/rspamd.nix +++ b/mailsystem/rspamd.nix @@ -25,7 +25,27 @@ with (import ./common.nix {inherit config;}); let }; }; in { + options.mailsystem.rspamd.webUi = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to enable the rspamd webui on `https://${config.mailsystem.fqdn}/rspamd`"; + }; + + basicAuthFile = lib.mkOption { + type = lib.types.str; + description = "Path to basic auth file"; + }; + }; + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = !cfg.rspamd.webUi.enable || cfg.rspamd.webUi.basicAuthFile != null; + message = "Setting basicAuthFile is required if rspamd's web interface is enabled"; + } + ]; + services.rspamd = { enable = true; overrides = { @@ -48,6 +68,12 @@ in { servers = "${redisCfg.unixSocket}"; ''; }; + "worker-controller.inc" = lib.mkIf cfg.rspamd.webUi.enable { + text = '' + secure_ip = "0.0.0.0/0"; + secure_ip = "::/0"; + ''; + }; }; workers = { @@ -77,12 +103,25 @@ in { systemd.sockets = { rspamd-proxy = genSystemdSocketCfg "proxy" rspamdProxySocket postfixCfg.user; - rspamd-controller = genSystemdSocketCfg "controller" rspamdControllerSocket ""; + rspamd-controller = genSystemdSocketCfg "controller" rspamdControllerSocket ( + lib.optionalString cfg.rspamd.webUi.enable nginxCfg.user + ); }; systemd.services.rspamd = { requires = ["redis-rspamd.service"]; after = ["redis-rspamd.service"]; }; + + services.nginx = lib.mkIf cfg.rspamd.webUi.enable { + enable = true; + virtualHosts."${cfg.fqdn}" = { + forceSSL = true; + locations."/rspamd" = { + proxyPass = "http://unix:${rspamdControllerSocket}:/"; + basicAuthFile = cfg.rspamd.webUi.basicAuthFile; + }; + }; + }; }; } From 3d0e0dd95c89c7710f94ad2dda40d3bd4fd81c7f Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 5 Dec 2024 15:52:03 +0100 Subject: [PATCH 06/61] mailsystem: Add configuration for roundcube as webmail interface --- mailsystem/default.nix | 1 + mailsystem/roundcube.nix | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 mailsystem/roundcube.nix diff --git a/mailsystem/default.nix b/mailsystem/default.nix index ae970a1..9642088 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -162,6 +162,7 @@ in { ./nginx.nix ./postfix.nix ./redis.nix + ./roundcube.nix ./rspamd.nix ./user.nix ]; diff --git a/mailsystem/roundcube.nix b/mailsystem/roundcube.nix new file mode 100644 index 0000000..bebffaf --- /dev/null +++ b/mailsystem/roundcube.nix @@ -0,0 +1,56 @@ +{ + config, + lib, + pkgs, + ... +}: +with (import ./common.nix {inherit config;}); let + cfg = config.mailsystem; + roundcubeCfg = config.mailsystem.roundcube; +in { + options.mailsystem.roundcube = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to enable roundcube in order to provide a webmail interface"; + }; + hostName = lib.mkOption { + type = lib.types.str; + default = cfg.fqdn; + description = "FQDN to be used by roundcube. Defaults to {option}`mailsystem.fqdn`."; + }; + passwordHashingAlgorithm = lib.mkOption { + type = lib.types.str; + default = "BLF-CRYPT"; + description = "Password hashing algorithm to be used with `doveadm pw`"; + }; + }; + + config = lib.mkIf (cfg.enable && roundcubeCfg.enable) { + services.roundcube = { + enable = true; + hostName = roundcubeCfg.hostName; + plugins = ["managesieve" "password"]; + extraConfig = '' + // Use starttls for authentication + $config['smtp_host'] = "tls://${cfg.fqdn}"; + $config['smtp_user'] = "%u"; + $config['smtp_pass'] = "%p"; + + $config['managesieve_host'] = "localhost"; + + $config['password_driver'] = "dovecot_passwdfile"; + $config['password_confirm_current'] = true; + $config['password_minimum_length'] = 8; + $config['password_algorithm'] = "dovecot"; + // Enables saving the new password even if it machtes the old password. Useful + // for upgrading the stored passwords after the encryption scheme has changed. + $config['password_force_save'] = true; + $config['password_dovecot_passwdfile_path'] = "${pkgs.dovecot}/bin/doveadm pw"; + $config['password_dovecotpw'] = "${dovecotDynamicPasswdFile}"; + $config['password_dovecotpw_method'] = "${roundcubeCfg.passwordHashingAlgorithm}"; + $config['password_dovecotpw_with_method'] = true; + ''; + }; + }; +} From 6d6b856bee52de6baa07327cc9e27d21360c328c Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 5 Dec 2024 15:53:57 +0100 Subject: [PATCH 07/61] flake.nix: Actually expose mailsystem as flake module --- flake.nix | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flake.nix b/flake.nix index fc7359a..3d105bb 100644 --- a/flake.nix +++ b/flake.nix @@ -45,5 +45,10 @@ alejandra.enable = true; }; }; + + flake.flakeModules = rec { + default = mailsystem; + mailsystem = import ./mailsystem; + }; }; } From a592881b8b85a294b48f822188ca19c2e35e4605 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 5 Dec 2024 16:04:01 +0100 Subject: [PATCH 08/61] mailsystem: Add option to use selfsigned certificates in preparation for testing --- mailsystem/common.nix | 18 +++++++++++++++--- mailsystem/default.nix | 14 ++++++++++++++ mailsystem/nginx.nix | 35 ++++++++++++++++++++--------------- mailsystem/rspamd.nix | 2 ++ mailsystem/selfsigned.nix | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 18 deletions(-) create mode 100644 mailsystem/selfsigned.nix diff --git a/mailsystem/common.nix b/mailsystem/common.nix index f74539a..ab7174c 100644 --- a/mailsystem/common.nix +++ b/mailsystem/common.nix @@ -1,9 +1,21 @@ {config, ...}: let cfg = config.mailsystem; in rec { - sslCertPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem"; - sslKeyPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem"; - sslCertService = ["acme-finished-${cfg.fqdn}.target"]; + certificateDirectory = "/var/certs"; + sslCertPath = + if cfg.certificateScheme == "acme" + then "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem" + else "${certificateDirectory}/cert-${cfg.fqdn}.pem"; + + sslKeyPath = + if cfg.certificateScheme == "acme" + then "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem" + else "${certificateDirectory}/key-${cfg.fqdn}.pem"; + + sslCertService = + if cfg.certificateScheme == "acme" + then ["acme-finished-${cfg.fqdn}.target"] + else ["mailsystem-selfsigned-certificate.service"]; dovecotDynamicStateDir = "/var/lib/dovecot"; dovecotDynamicPasswdFile = "${dovecotDynamicStateDir}/passwd"; diff --git a/mailsystem/default.nix b/mailsystem/default.nix index 9642088..1ac270e 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -154,6 +154,19 @@ in { ''; default = {}; }; + + certificateScheme = lib.mkOption { + type = lib.types.enum ["acme" "selfsigned"]; + default = "acme"; + description = '' + The scheme to use for managing TLS certificates: + + 1. `acme`: The server retrieves letsencrypt certificates via NixOS's acme module using nginx. + 2. `selfsigned`: The server creates self-signed certificates on the fly (intended for testing). + ''; + internal = true; + visible = false; + }; }; imports = [ @@ -164,6 +177,7 @@ in { ./redis.nix ./roundcube.nix ./rspamd.nix + ./selfsigned.nix ./user.nix ]; } diff --git a/mailsystem/nginx.nix b/mailsystem/nginx.nix index 7edd771..03e8f26 100644 --- a/mailsystem/nginx.nix +++ b/mailsystem/nginx.nix @@ -3,23 +3,28 @@ pkgs, lib, ... -}: let +}: +with (import ./common.nix {inherit config;}); let cfg = config.mailsystem; in { - config = lib.mkIf cfg.enable { - services.nginx = { - enable = true; - virtualHosts."${cfg.fqdn}" = { - forceSSL = true; - enableACME = true; + config = + lib.mkIf cfg.enable { + services.nginx = { + enable = true; + virtualHosts."${cfg.fqdn}" = { + forceSSL = true; + enableACME = cfg.certificateScheme == "acme"; + sslCertificate = lib.mkIf (cfg.certificateScheme == "selfsigned") sslCertPath; + sslCertificateKey = lib.mkIf (cfg.certificateScheme == "selfsigned") sslKeyPath; + }; }; + + networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443]; + } + // lib.mkIf (cfg.enable && cfg.certificateScheme == "acme") { + security.acme.certs."${cfg.fqdn}".reloadServices = [ + "postfix.service" + "dovecot2.service" + ]; }; - - networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443]; - - security.acme.certs."${cfg.fqdn}".reloadServices = [ - "postfix.service" - "dovecot2.service" - ]; - }; } diff --git a/mailsystem/rspamd.nix b/mailsystem/rspamd.nix index 6418a9b..46cb0c4 100644 --- a/mailsystem/rspamd.nix +++ b/mailsystem/rspamd.nix @@ -121,6 +121,8 @@ in { proxyPass = "http://unix:${rspamdControllerSocket}:/"; basicAuthFile = cfg.rspamd.webUi.basicAuthFile; }; + sslCertificate = lib.mkIf (cfg.certificateScheme == "selfsigned") sslCertPath; + sslCertificateKey = lib.mkIf (cfg.certificateScheme == "selfsigned") sslKeyPath; }; }; }; diff --git a/mailsystem/selfsigned.nix b/mailsystem/selfsigned.nix new file mode 100644 index 0000000..4506fcf --- /dev/null +++ b/mailsystem/selfsigned.nix @@ -0,0 +1,33 @@ +{ + config, + pkgs, + lib, + ... +}: +with (import ./common.nix {inherit config;}); let + cfg = config.mailsystem; +in { + config = lib.mkIf (cfg.enable && cfg.certificateScheme == "selfsigned") { + systemd.services.mailsystem-selfsigned-certificate = { + after = ["local-fs.target"]; + script = '' + # Create certificates if they do not exist yet + dir="${certificateDirectory}" + fqdn="${cfg.fqdn}" + [[ $fqdn == /* ]] && fqdn=$(< "$fqdn") + key="${sslKeyPath}" + cert="${sslCertPath}" + + if [[ ! -f $key || ! -f $cert ]]; then + mkdir -p "$dir" + (umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 4096) && + "${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" -days 3650 -out "$cert" + fi + ''; + serviceConfig = { + Type = "oneshot"; + PrivateTmp = true; + }; + }; + }; +} From 9687dbaae197a9c0d145d083c6fadd3e7977cad8 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 7 Dec 2024 02:35:16 +0100 Subject: [PATCH 09/61] Add minimal (internal) tests --- flake.nix | 9 +++++++++ tests/common/server.nix | 13 +++++++++++++ tests/internal.nix | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 tests/common/server.nix create mode 100644 tests/internal.nix diff --git a/flake.nix b/flake.nix index 3d105bb..0952f09 100644 --- a/flake.nix +++ b/flake.nix @@ -32,6 +32,15 @@ system, ... }: { + checks = let + tests = ["internal"]; + genTest = testName: { + "name" = testName; + "value" = import (./tests + "/${testName}.nix") {inherit pkgs;}; + }; + in + pkgs.lib.listToAttrs (map genTest tests); + devShells.default = pkgs.mkShell { packages = with pkgs; [ alejandra diff --git a/tests/common/server.nix b/tests/common/server.nix new file mode 100644 index 0000000..9a47284 --- /dev/null +++ b/tests/common/server.nix @@ -0,0 +1,13 @@ +{...}: { + imports = [./../../mailsystem]; + config = { + virtualisation.memorySize = 1024; + mailsystem = { + enable = true; + + roundcube.enable = false; + rspamd.webUi.enable = false; + certificateScheme = "selfsigned"; + }; + }; +} diff --git a/tests/internal.nix b/tests/internal.nix new file mode 100644 index 0000000..a22a67d --- /dev/null +++ b/tests/internal.nix @@ -0,0 +1,40 @@ +{pkgs, ...}: +pkgs.nixosTest { + name = "internal"; + nodes.machine = {...}: { + imports = [./common/server.nix]; + mailsystem = { + fqdn = "mail.example.com"; + domains = ["example.com"]; + accounts = {}; + vmailUserName = "vmail"; + vmailGroupName = "vmail"; + vmailUID = 5000; + }; + }; + testScript = {nodes, ...}: let + pkgs = nodes.machine.nixpkgs.pkgs; + in '' + machine.start() + machine.wait_for_unit("multi-user.target") + + with subtest("imap is only available via port 993 and is encrypted"): + machine.wait_for_closed_port(143) + machine.wait_for_open_port(993) + machine.succeed( + "echo | ${pkgs.openssl}/bin/openssl s_client -connect localhost:993 | grep 'New, TLS'" + ) + + with subtest("smtp is only available via port 465 and is encrypted"): + machine.wait_for_closed_port(587) + machine.wait_for_open_port(465) + machine.succeed( + "echo | ${pkgs.openssl}/bin/openssl s_client -connect localhost:465 | grep 'New, TLS'" + ) + + with subtest("`postfix check` succeeds"): + machine.succeed( + "${pkgs.postfix}/bin/postfix check" + ) + ''; +} From 5c280dcedbd8c77fc80c522ff05040ff20fe80fa Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 7 Dec 2024 02:37:57 +0100 Subject: [PATCH 10/61] tests: minimal: Configure and verify vmail user/group/uid/gid --- tests/internal.nix | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/internal.nix b/tests/internal.nix index a22a67d..8fccbfb 100644 --- a/tests/internal.nix +++ b/tests/internal.nix @@ -36,5 +36,15 @@ pkgs.nixosTest { machine.succeed( "${pkgs.postfix}/bin/postfix check" ) + + with subtest("vmail uid is set correctly"): + machine.succeed( + "[ $(getent passwd vmail | cut -d: -f3) -eq 5000 ]" + ) + + with subtest("vmail gid is set correctly"): + machine.succeed( + "[ $(getent group vmail | cut -d: -f3) -eq 5000 ]" + ) ''; } From b81e8f00bb90ce04e2093d62500c7201e87772eb Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 7 Dec 2024 12:36:51 +0100 Subject: [PATCH 11/61] flake.nix: Rename pre-commit-hooks-nix into git-hooks-nix Cachix has renamed their project. --- flake.lock | 58 +++++++++++++++++++++++++++--------------------------- flake.nix | 6 +++--- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/flake.lock b/flake.lock index 9769102..c12806c 100644 --- a/flake.lock +++ b/flake.lock @@ -36,10 +36,33 @@ "type": "github" } }, + "git-hooks-nix": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ], + "nixpkgs-stable": "nixpkgs-stable" + }, + "locked": { + "lastModified": 1733318908, + "narHash": "sha256-SVQVsbafSM1dJ4fpgyBqLZ+Lft+jcQuMtEL3lQWx2Sk=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "6f4e2a2112050951a314d2733a994fbab94864c6", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, "gitignore": { "inputs": { "nixpkgs": [ - "pre-commit-hooks-nix", + "git-hooks-nix", "nixpkgs" ] }, @@ -75,11 +98,11 @@ }, "nixpkgs-stable": { "locked": { - "lastModified": 1720386169, - "narHash": "sha256-NGKVY4PjzwAa4upkGtAMz1npHGoRzWotlSnVlqI40mo=", + "lastModified": 1730741070, + "narHash": "sha256-edm8WG19kWozJ/GqyYx2VjW99EdhjKwbY3ZwdlPAAlo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "194846768975b7ad2c4988bdb82572c00222c0d7", + "rev": "d063c1dd113c91ab27959ba540c0d9753409edf3", "type": "github" }, "original": { @@ -89,34 +112,11 @@ "type": "github" } }, - "pre-commit-hooks-nix": { - "inputs": { - "flake-compat": "flake-compat", - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ], - "nixpkgs-stable": "nixpkgs-stable" - }, - "locked": { - "lastModified": 1732021966, - "narHash": "sha256-mnTbjpdqF0luOkou8ZFi2asa1N3AA2CchR/RqCNmsGE=", - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "3308484d1a443fc5bc92012435d79e80458fe43c", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, "root": { "inputs": { "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs", - "pre-commit-hooks-nix": "pre-commit-hooks-nix" + "git-hooks-nix": "git-hooks-nix", + "nixpkgs": "nixpkgs" } } }, diff --git a/flake.nix b/flake.nix index 0952f09..89362b8 100644 --- a/flake.nix +++ b/flake.nix @@ -5,8 +5,8 @@ nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; - pre-commit-hooks-nix.url = "github:cachix/git-hooks.nix"; - pre-commit-hooks-nix.inputs.nixpkgs.follows = "nixpkgs"; + git-hooks-nix.url = "github:cachix/git-hooks.nix"; + git-hooks-nix.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = { @@ -21,7 +21,7 @@ "aarch64-linux" ]; imports = [ - inputs.pre-commit-hooks-nix.flakeModule + inputs.git-hooks-nix.flakeModule ]; perSystem = { From a033432bb8dd42ca2b35a6cd3edc4fec8da44f48 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 7 Dec 2024 12:55:51 +0100 Subject: [PATCH 12/61] flake.nix: Add and configure treefmt-nix for `nix fmt` --- flake.lock | 23 ++++++++++++++++++++++- flake.nix | 17 +++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index c12806c..43c3d18 100644 --- a/flake.lock +++ b/flake.lock @@ -116,7 +116,28 @@ "inputs": { "flake-parts": "flake-parts", "git-hooks-nix": "git-hooks-nix", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "treefmt-nix": "treefmt-nix" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1733440889, + "narHash": "sha256-qKL3vjO+IXFQ0nTinFDqNq/sbbnnS5bMI1y0xX215fU=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "50862ba6a8a0255b87377b9d2d4565e96f29b410", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index 89362b8..87b3dd8 100644 --- a/flake.nix +++ b/flake.nix @@ -5,6 +5,8 @@ nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; + treefmt-nix.url = "github:numtide/treefmt-nix"; + treefmt-nix.inputs.nixpkgs.follows = "nixpkgs"; git-hooks-nix.url = "github:cachix/git-hooks.nix"; git-hooks-nix.inputs.nixpkgs.follows = "nixpkgs"; }; @@ -21,6 +23,7 @@ "aarch64-linux" ]; imports = [ + inputs.treefmt-nix.flakeModule inputs.git-hooks-nix.flakeModule ]; @@ -43,7 +46,7 @@ devShells.default = pkgs.mkShell { packages = with pkgs; [ - alejandra + self'.formatter.outPath # Add all formatters to environment ]; shellHook = '' ${config.pre-commit.installationScript} @@ -51,7 +54,17 @@ }; pre-commit.settings.hooks = { - alejandra.enable = true; + treefmt.enable = true; + }; + + treefmt = { + programs = { + alejandra.enable = true; + }; + settings.global.excludes = [ + ".envrc" + "*.sieve" + ]; }; }; From b630755ea8741b2eb23227f181c7ca051e968cae Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Wed, 25 Dec 2024 02:07:32 +0100 Subject: [PATCH 13/61] tests: common: Add lib.nix containing various helpers for testing mailsystem behaviour --- tests/common/lib.nix | 77 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/common/lib.nix diff --git a/tests/common/lib.nix b/tests/common/lib.nix new file mode 100644 index 0000000..1930be9 --- /dev/null +++ b/tests/common/lib.nix @@ -0,0 +1,77 @@ +{pkgs, ...}: let + lib = pkgs.lib; +in rec { + waitForRspamd = node: let + inherit (import ../../mailsystem/common.nix {inherit (node) config;}) rspamdProxySocket; + in "set +e; timeout 1 ${node.nixpkgs.pkgs.netcat}/bin/nc -U ${rspamdProxySocket} < /dev/null; [ $? -eq 124 ]"; + + mkHashedPasswordFile = password: + pkgs.runCommand "mk-password-hash-${password}" { + buildInputs = [pkgs.mkpasswd]; + inherit password; + } '' + echo "$password" | mkpasswd -sm bcrypt > $out + ''; + + mkAccounts = accounts: + lib.concatMapAttrs (_: account: { + ${account.address} = + { + hashedPasswordFile = "${mkHashedPasswordFile account.password}"; + } + // builtins.removeAttrs account ["address" "password"]; + }) + accounts; + + mkSendMail = smtpSettings: accounts: accountName: fromAddr: recipient: body: let + account = accounts.${accountName}; + senderAddr = + if fromAddr == "" + then account.address + else fromAddr; + msmtprc = pkgs.writeText "msmtprc" '' + account default + auth on + tls on + tls_starttls off + tls_certcheck off + host ${smtpSettings.address} + port ${toString smtpSettings.port} + from ${senderAddr} + user ${account.address} + password ${account.password} + ''; + mail = pkgs.writeText "mail-${account.address}-${recipient}" '' + From: <${account.address}> + To: <${recipient}> + ${body} + ''; + in "${pkgs.msmtp}/bin/msmtp -C ${msmtprc} ${recipient} < ${mail} >&2"; + + pendingPostqueue = "[ \"$(postqueue -p)\" != \"Mail queue is empty\" ]"; + cleanupMail = "rm $HOME/mail/*"; + + # mkRecvMail requires procmail to be setup correctly. This is ensured by + # importing ./server.nix + mkRecvMail = imapAddr: accounts: accountName: let + mkFetchmailRcScript = imapAddr: account: + pkgs.writeScript "mk-fetchmailrc-${account.address}" '' + umask 077 + readonly out=$(mktemp) + cat < "$out" + poll ${imapAddr} with proto IMAP + user '${account.address}' there with password '${account.password}' is 'root' here + mda procmail + EOF + echo $out + ''; + fetchmailrc = mkFetchmailRcScript imapAddr accounts.${accountName}; + in "${pkgs.fetchmail}/bin/fetchmail -f $(${fetchmailrc}) --ssl --nosslcertck -v"; + + checkLogs = node: '' + ${node}.fail("journalctl -u postfix | grep -i error >&2") + ${node}.fail("journalctl -u postfix | grep -i warning >&2") + ${node}.fail("journalctl -u dovecot2 | grep -i error >&2") + ${node}.fail("journalctl -u dovecot2 | grep -i warning >&2") + ''; +} From fb834ec7ee3744e8dbb34274b543bc5bf98d0e0e Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Wed, 25 Dec 2024 12:39:48 +0100 Subject: [PATCH 14/61] tests: Add basic tests for sending/receiving mails and verification of headers --- flake.nix | 2 +- tests/basic.nix | 83 +++++++++++++++++++++++++++++++++++++++++ tests/common/client.nix | 17 +++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/basic.nix create mode 100644 tests/common/client.nix diff --git a/flake.nix b/flake.nix index 87b3dd8..3066fc6 100644 --- a/flake.nix +++ b/flake.nix @@ -36,7 +36,7 @@ ... }: { checks = let - tests = ["internal"]; + tests = ["internal" "basic"]; genTest = testName: { "name" = testName; "value" = import (./tests + "/${testName}.nix") {inherit pkgs;}; diff --git a/tests/basic.nix b/tests/basic.nix new file mode 100644 index 0000000..615d2a1 --- /dev/null +++ b/tests/basic.nix @@ -0,0 +1,83 @@ +{pkgs, ...}: +with (import ./common/lib.nix {inherit pkgs;}); let + accounts = { + "normal" = { + address = "user1@example.com"; + password = "secret-password1"; + }; + "normal2" = { + address = "user2@example.com"; + password = "secret-password2"; + }; + }; +in + pkgs.nixosTest { + name = "basic"; + nodes = { + server = {pkgs, ...}: { + imports = [./common/server.nix]; + environment.systemPackages = with pkgs; [netcat]; + mailsystem = { + fqdn = "mail.example.com"; + domains = ["example.com"]; + accounts = mkAccounts accounts; + }; + }; + client = {...}: { + imports = [./common/client.nix]; + }; + }; + testScript = {nodes, ...}: let + serverAddr = nodes.server.networking.primaryIPAddress; + clientAddr = nodes.client.networking.primaryIPAddress; + smtpSettings = { + address = serverAddr; + port = 465; + }; + sendMail = mkSendMail smtpSettings accounts; + recvMail = mkRecvMail serverAddr accounts; + cfg = nodes.server.mailsystem; + in '' + start_all() + + server.wait_for_unit("multi-user.target") + client.wait_for_unit("multi-user.target") + server.wait_until_succeeds("${waitForRspamd nodes.server}") + + with subtest("imap works and retrieves no new mails"): + # fetchmail returns EXIT_CODE 1 when no new mail is available + client.succeed("${recvMail "normal"} || [ $? -eq 1 ] >&2") + + with subtest("send succeeds for normal user"): + client.succeed("${sendMail "normal" "" accounts."normal2".address '' + Message-ID: <123456asdf@host.local.network> + Subject: Testmail1 + + Hello User2, + this is some text! + ''}") + # give the mail server some time to process the mail + server.wait_until_fails('${pendingPostqueue}') + + with subtest("mail can be retrieved via imap"): + client.succeed("${recvMail "normal2"} >&2") + + with subtest("mail header contains no sensitive information"): + client.fail("grep '${clientAddr}' $HOME/mail/*") + client.succeed("grep '^Message-ID:.*@${cfg.fqdn}>$' $HOME/mail/*") + + with subtest("mail header contains correct fqdn in received from"): + client.succeed("grep 'Received: from ${cfg.fqdn}' $HOME/mail/*") + + with subtest("user cannot forge from-address"): + client.fail("${sendMail "normal" "someotheraddress@example.com" accounts."normal2".address '' + Subject: I actually do not own this from-address + + Hello User2, + I'm pretending to be someotheraddress@example.com and the mailserver should reject this attempt. + ''}") + + with subtest("server issues no warnings nor errors"): + ${checkLogs "server"} + ''; + } diff --git a/tests/common/client.nix b/tests/common/client.nix new file mode 100644 index 0000000..82dd41b --- /dev/null +++ b/tests/common/client.nix @@ -0,0 +1,17 @@ +{pkgs, ...}: { + config = { + # added to the environment for manual verification via interactiveDriver if necessary + environment.systemPackages = with pkgs; [fetchmail msmtp procmail openssl]; + systemd.tmpfiles.settings."10-mailtest" = let + dirPerms = { + user = "root"; + mode = "0700"; + }; + in { + "/root/mail".d = dirPerms; + "/root/.procmailrc"."L+".argument = "${pkgs.writeText ".procmailrc" '' + DEFAULT=$HOME/mail + ''}"; + }; + }; +} From 48bd6b8981ea7935e115e5b4d480ab79eba5593d Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 28 Dec 2024 00:34:13 +0100 Subject: [PATCH 15/61] tests: Add various tests for alias functionality --- flake.nix | 2 +- tests/aliases.nix | 195 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 tests/aliases.nix diff --git a/flake.nix b/flake.nix index 3066fc6..80dcfc5 100644 --- a/flake.nix +++ b/flake.nix @@ -36,7 +36,7 @@ ... }: { checks = let - tests = ["internal" "basic"]; + tests = ["internal" "basic" "aliases"]; genTest = testName: { "name" = testName; "value" = import (./tests + "/${testName}.nix") {inherit pkgs;}; diff --git a/tests/aliases.nix b/tests/aliases.nix new file mode 100644 index 0000000..e35124a --- /dev/null +++ b/tests/aliases.nix @@ -0,0 +1,195 @@ +{pkgs, ...}: +with (import ./common/lib.nix {inherit pkgs;}); let + accounts = { + "normal" = { + address = "user1@example.com"; + password = "secret-password1"; + }; + "normal2" = { + address = "user2@example.com"; + password = "secret-password2;"; + }; + "alias" = { + address = "user3@example.com"; + aliases = ["alias@example.com"]; + password = "secret-password3"; + }; + "extra-alias" = { + address = "user4@example.com"; + password = "secret-password4;"; + }; + "multi-alias1" = { + address = "multi-alias1@example.com"; + aliases = ["multi-alias@example.com"]; + password = "secret-password5;"; + }; + "multi-alias2" = { + address = "multi-alias2@example.com"; + aliases = ["multi-alias@example.com"]; + password = "secret-password6;"; + }; + "catchall" = { + address = "catchall@example.com"; + aliases = ["@example.com"]; + password = "secret-password7;"; + }; + "otherdomain" = { + address = "otherdomain@example.com"; + aliases = ["user@otherdomain.com"]; + password = "secret-password8;"; + }; + }; +in + pkgs.nixosTest { + name = "aliases"; + nodes = { + server = {pkgs, ...}: { + imports = [./common/server.nix]; + environment.systemPackages = with pkgs; [netcat]; + mailsystem = { + fqdn = "mail.example.com"; + domains = ["example.com" "otherdomain.com"]; + accounts = mkAccounts accounts; + extraVirtualAliases = { + "extra-alias@example.com" = accounts."extra-alias".address; + }; + }; + }; + client = {...}: { + imports = [./common/client.nix]; + }; + }; + testScript = {nodes, ...}: let + serverAddr = nodes.server.networking.primaryIPAddress; + clientAddr = nodes.client.networking.primaryIPAddress; + smtpSettings = { + address = serverAddr; + port = 465; + }; + sendMail = mkSendMail smtpSettings accounts; + recvMail = mkRecvMail serverAddr accounts; + cfg = nodes.server.mailsystem; + in '' + start_all() + + server.wait_for_unit("multi-user.target") + client.wait_for_unit("multi-user.target") + server.wait_until_succeeds("${waitForRspamd nodes.server}") + + with subtest("send mail from aliased address"): + client.succeed("${sendMail "alias" "alias@example.com" accounts."normal".address '' + Subject: Testmail1 + + Hello User1, + this is a mail dispatched using an alias instead of the normal address. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "normal"} >&2") + + with subtest("receive mail on aliased address"): + client.succeed("${sendMail "normal" "" "alias@example.com" '' + Subject: Testmail2 + + Hello alias-User, + this mail should reach you on your aliased address alias@example.com. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "alias"} >&2") + + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 1 when no new mail is available + # (as alias is the more specific address, catchall shouldn't receive the mail) + client.fail("${recvMail "catchall"} >&2") + + with subtest("receive mail on all accounts with same alias"): + client.succeed("${sendMail "normal" "" "multi-alias@example.com" '' + Subject: Testmail3 + + Hello multi-alias-Users, + this mail should reach you on your aliased address multi-alias@example.com. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "multi-alias1"} >&2") + + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "multi-alias2"} >&2") + + with subtest("send mail to catchAll-alias"): + # send email to non-existent account + client.succeed("${sendMail "normal" "" "somerandomaddress@example.com" '' + Subject: Catchall-Test + + Hello Catchall-User, + this is mail is directed at an address with no explicit user-account behind it. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "catchall"} >&2") + + with subtest("send mail from catchAll-account with a non-existing account behind"): + # send email from non-existent account + client.succeed("${sendMail "catchall" "somerandomaddress@example.com" accounts."normal2".address '' + Subject: Catchall-Test2 + + Hello User2, + this is mail is sent from an address with no explicit user-account behind it. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "normal2"} >&2") + + with subtest("catchAll-account cannot send mail from an address with an existing account behind"): + # send email to non-existent account + client.fail("${sendMail "catchall" accounts."normal".address accounts."normal2".address '' + Subject: Catchall-Test3 + + Hello User2, + this is mail should not be possible to be sent as it is dispatched by a catchall-account using an address with a user-account behind it. + ''}") + + with subtest("send mail from aliased address of other domain"): + client.succeed("${sendMail "otherdomain" "user@otherdomain.com" accounts."normal".address '' + Subject: Dispatch from other domain + + Hello User1, + this is a mail dispatched using an alias to a different domain instead of the normal address. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "normal"} >&2") + + with subtest("receive mail on aliased address of other domain"): + client.succeed("${sendMail "normal" "" "user@otherdomain.com" '' + Subject: Reception from other domain + + Hello otherdomain-User, + this mail should reach you on your aliased address user@otherdomain.com. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "otherdomain"} >&2") + + with subtest("mail incoming on extraVirtualAlias"): + client.succeed("${sendMail "normal" "" "extra-alias@example.com" '' + Subject: extraVirtualAliases-Test + + Hello User4, + this is mail is sent to you by using an extraVirtualAlias as recipient. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "extra-alias"} >&2") + ''; + } From c90e09d125209db4e99b4acef7654cf010939640 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 28 Dec 2024 00:40:32 +0100 Subject: [PATCH 16/61] Add configuration option to alias entire domains and respective tests --- mailsystem/default.nix | 16 ++++++++++++++++ mailsystem/postfix.nix | 20 +++++++++++++++++++- tests/aliases.nix | 37 ++++++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/mailsystem/default.nix b/mailsystem/default.nix index 1ac270e..af2913c 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -127,6 +127,22 @@ in { default = {}; }; + virtualDomainAliases = lib.mkOption { + type = with lib.types; attrsOf str; + example = { + "@aliasdomain.com" = "@domain.com"; + }; + description = '' + Virtual aliasing of domains. A virtual alias `"@aliasdomain.com" = "@domain.com"` + means that all mail directed at `aliasdomain.com` are forwarded to `domain.com`. + This also entails, that any account or alias of `domain.com` is partially valid + for `aliasdomain.com`. For example, `user@domain.com` can receive mails at + `user@aliasdomain.com`. However, if `user@domain.com` shall be able to dispatch + mails using `user@aliasdomain.com`, an explicit alias needs to be configured. + ''; + default = {}; + }; + extraVirtualAliases = lib.mkOption { type = let account = lib.mkOptionType { diff --git a/mailsystem/postfix.nix b/mailsystem/postfix.nix index f0afb8d..3328aca 100644 --- a/mailsystem/postfix.nix +++ b/mailsystem/postfix.nix @@ -28,9 +28,18 @@ with (import ./common.nix {inherit config;}); let map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name)) cfg.accounts)); + virtual_domain_aliases = let + alias_domains = + lib.concatMapAttrs (src: dst: { + "@${src}" = "@${dst}"; + }) + cfg.virtualDomainAliases; + in + attrsToLookupTable alias_domains; + extra_virtual_aliases = attrsToLookupTable cfg.extraVirtualAliases; - all_virtual_aliases = mergeLookupTables [account_virtual_aliases extra_virtual_aliases]; + all_virtual_aliases = mergeLookupTables [account_virtual_aliases virtual_domain_aliases extra_virtual_aliases]; aliases_file = let content = lookupTableToString all_virtual_aliases; @@ -65,6 +74,15 @@ with (import ./common.nix {inherit config;}); let tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL"; in { config = lib.mkIf cfg.enable { + assertions = + lib.mapAttrsToList ( + src: dst: { + assertion = (builtins.elem src cfg.domains) && (builtins.elem dst cfg.domains); + message = "Both aliased domain (${src}) and actual domain (${dst}) need to be managed by the mailserver."; + } + ) + cfg.virtualDomainAliases; + services.postfix = { enable = true; hostname = "${cfg.reverseFqdn}"; diff --git a/tests/aliases.nix b/tests/aliases.nix index e35124a..cd3aae3 100644 --- a/tests/aliases.nix +++ b/tests/aliases.nix @@ -48,8 +48,11 @@ in environment.systemPackages = with pkgs; [netcat]; mailsystem = { fqdn = "mail.example.com"; - domains = ["example.com" "otherdomain.com"]; + domains = ["example.com" "aliased.com" "otherdomain.com"]; accounts = mkAccounts accounts; + virtualDomainAliases = { + "aliased.com" = "example.com"; + }; extraVirtualAliases = { "extra-alias@example.com" = accounts."extra-alias".address; }; @@ -191,5 +194,37 @@ in client.execute("${cleanupMail}") # fetchmail returns EXIT_CODE 0 when it retrieves mail client.succeed("${recvMail "extra-alias"} >&2") + + with subtest("receiving mail on aliased domain using normal account"): + client.succeed("${sendMail "normal" "" "user2@aliased.com" '' + Subject: aliasedDomain with normal account + + Hello User2, + this is mail is sent to you by using your address @example.org. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "normal2"} >&2") + + with subtest("receiving mail on aliased domain using catchall-account"): + client.succeed("${sendMail "normal" "" "somerandomaddress@aliased.com" '' + Subject: aliasedDomain using catchall-account + + Hello Catchall-User, + this is mail is sent to you by using an address without any user-account behind it for neither @example.com nor @aliased.com. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "catchall"} >&2") + + with subtest("sending mail from aliased domain fails"): + client.fail("${sendMail "normal" "user1@aliased.com" accounts."normal2".address '' + Subject: aliasedDomain + + Hello User2, + this mail should not be dispatched to you as I'm using "my" address @aliased.com. + ''}") ''; } From 1e75d07d566536fa8d0f53dc608b63c51469e455 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 28 Dec 2024 01:42:00 +0100 Subject: [PATCH 17/61] foobar --- mailsystem/default.nix | 10 ++++++++++ mailsystem/dovecot.nix | 8 +++++++- mailsystem/postfix.nix | 5 +++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/mailsystem/default.nix b/mailsystem/default.nix index af2913c..afd69bc 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -103,6 +103,16 @@ in { ''; }; + quota = lib.mkOption { + type = with lib.types; nullOr types.str; + default = null; + example = "2G"; + description = '' + Sets quota for the this login account. The size has to be suffixed with `k/M/G/T`. + Not setting a quota results in a standard quota of `100G`. + ''; + }; + isSystemUser = lib.mkOption { type = lib.types.bool; default = false; diff --git a/mailsystem/dovecot.nix b/mailsystem/dovecot.nix index 4f17464..60a3d8e 100644 --- a/mailsystem/dovecot.nix +++ b/mailsystem/dovecot.nix @@ -19,7 +19,13 @@ with (import ./common.nix {inherit config;}); let systemUsers = lib.filterAttrs (user: value: value.isSystemUser) cfg.accounts; normalUsers = lib.filterAttrs (user: value: !value.isSystemUser) cfg.accounts; - genUserdbEntry = user: value: "${user}:::::::"; + genUserdbEntry = user: value: + "${user}:::::::" + + ( + if lib.isString value.quota + then "userdb_quota_rule=*:storage=${value.quota}" + else "" + ); genPasswdEntry = user: value: "${user}:${"$(head -n 1 ${value.hashedPasswordFile})"}::::::"; genAuthDbsScript = pkgs.writeScript "generate-dovecot-auth-dbs" '' diff --git a/mailsystem/postfix.nix b/mailsystem/postfix.nix index 3328aca..c1f41da 100644 --- a/mailsystem/postfix.nix +++ b/mailsystem/postfix.nix @@ -141,6 +141,11 @@ in { "reject_unauth_destination" ]; + # quota checking # TODO: wo ist hier quota?? + # smtpd_recipient_restrictions = [ + # "check_policy_service inet:localhost:12340" # XXX + # ]; + # TLS settings, inspired by https://github.com/jeaye/nix-files # Submission by mail clients is handled in submissionOptions smtpd_tls_security_level = "may"; From c738037669415c1080615c749cac6c78adea6720 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 30 Nov 2024 19:35:15 +0100 Subject: [PATCH 18/61] mailsystem: Add minimal dovecot configuration --- mailsystem/common.nix | 3 + mailsystem/default.nix | 47 ++++++++ mailsystem/dovecot.nix | 255 +++++++++++++++++++++++++++++++++++++++++ mailsystem/nginx.nix | 4 + 4 files changed, 309 insertions(+) create mode 100644 mailsystem/dovecot.nix diff --git a/mailsystem/common.nix b/mailsystem/common.nix index 6d9df16..2dd170c 100644 --- a/mailsystem/common.nix +++ b/mailsystem/common.nix @@ -4,4 +4,7 @@ in rec { sslCertPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem"; sslKeyPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem"; sslCertService = ["acme-finished-${cfg.fqdn}.target"]; + + dovecotDynamicStateDir = "/var/lib/dovecot"; + dovecotDynamicPasswdFile = "${dovecotDynamicStateDir}/passwd"; } diff --git a/mailsystem/default.nix b/mailsystem/default.nix index abc5b73..874fa8e 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -37,9 +37,56 @@ default = "/var/vmail"; description = "Storage location for all mail."; }; + + accounts = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule ({name, ...}: { + options = { + name = lib.mkOption { + type = lib.types.str; + example = "user1@example.com"; + description = "Username"; + }; + + hashedPasswordFile = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + example = "/run/secrets/user1-passwordhash"; + description = '' + A file containing the user's hashed password. Use `mkpasswd` as follows + + ``` + nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' + ``` + ''; + }; + + isSystemUser = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + System users are not allowed to change their password and are + cannot receive any mails (-> send-only). Mails sent to such an + account will be rejected. + ''; + }; + }; + config.name = lib.mkDefault name; + })); + example = { + user1 = { + hashedPassword = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/"; + }; + user2 = { + hashedPassword = "$6$oE0ZNv2n7Vk9gOf$9xcZWCCLGdMflIfuA0vR1Q1Xblw6RZqPrP94mEit2/81/7AKj2bqUai5yPyWE.QYPyv6wLMHZvjw3Rlg7yTCD/"; + }; + }; + description = "All available accounts for the mailsystem."; + default = {}; + }; }; imports = [ + ./dovecot.nix ./nginx.nix ./user.nix ]; diff --git a/mailsystem/dovecot.nix b/mailsystem/dovecot.nix new file mode 100644 index 0000000..460e0ef --- /dev/null +++ b/mailsystem/dovecot.nix @@ -0,0 +1,255 @@ +{ + config, + lib, + pkgs, + ... +}: +with (import ./common.nix {inherit config;}); let + cfg = config.mailsystem; + postfixCfg = config.services.postfix; + dovecot2Cfg = config.services.dovecot2; + + runtimeStateDir = "/run/dovecot2"; + + staticPasswdFile = "${runtimeStateDir}/passwd"; + initialPasswdFile = "${runtimeStateDir}/initial-passwd"; + userdbFile = "${runtimeStateDir}/userdb"; + # dovecotDynamicStateDir and dovecotDynamicPasswdFile are defined in common.nix + + systemUsers = lib.filterAttrs (user: value: value.isSystemUser) cfg.accounts; + normalUsers = lib.filterAttrs (user: value: !value.isSystemUser) cfg.accounts; + + genUserdbEntry = user: value: "${user}:::::::"; + genPasswdEntry = user: value: "${user}:${"$(head -n 1 ${value.hashedPasswordFile})"}::::::"; + + genAuthDbsScript = pkgs.writeScript "generate-dovecot-auth-dbs" '' + #!${pkgs.stdenv.shell} + set -euo pipefail + + if (! test -d "${runtimeStateDir}"); then + mkdir "${runtimeStateDir}" + chmod 755 "${runtimeStateDir}" + fi + + if (! test -d "${dovecotDynamicStateDir}"); then + mkdir "${dovecotDynamicStateDir}" + chmod 755 "${dovecotDynamicStateDir}" + fi + + # Ensure passwd files are not world-readable at any time + umask 077 + + # Ensure we have a file for every user's (initial) password hash. + for f in ${builtins.toString (lib.mapAttrsToList (user: value: value.hashedPasswordFile) cfg.accounts)}; do + if [ ! -f "$f" ]; then + echo "Expected password hash file $f does not exist!" + exit 1 + fi + done + + # Prepare static passwd-file for system users + cat < "${staticPasswdFile}" + ${lib.concatStringsSep "\n" (lib.mapAttrsToList genPasswdEntry systemUsers)} + EOF + + # Prepare initial passwd-file for dynamic users + # (used for lookup during actual passwd-file generation) + cat < "${initialPasswdFile}" + ${lib.concatStringsSep "\n" (lib.mapAttrsToList genPasswdEntry normalUsers)} + EOF + + # Check for existence of dynamic passwd-file + touch "${dovecotDynamicPasswdFile}" + if (! test -f "${dovecotDynamicPasswdFile}"); then + echo "${dovecotDynamicPasswdFile} exists and is no regular file" + exit 1 + fi + # Ensure that only configured users are actually present and remove any others + truncate -s 0 "${dovecotDynamicPasswdFile}-filtered" + for u in ${builtins.toString (lib.mapAttrsToList (user: value: value.name) normalUsers)}; do + if grep -q "^$u:" "${dovecotDynamicPasswdFile}"; then + # User already has some password set -> Keep currently set password + grep "^$u:" "${dovecotDynamicPasswdFile}" >> "${dovecotDynamicPasswdFile}-filtered" + else + # User has no password set, yet -> Take password from initialPasswdFile + grep "^$u:" "${initialPasswdFile}" >> "${dovecotDynamicPasswdFile}-filtered" + fi + done + mv "${dovecotDynamicPasswdFile}-filtered" "${dovecotDynamicPasswdFile}" + + # Prepare userdb-file + cat < "${userdbFile}" + ${lib.concatStringsSep "\n" (lib.mapAttrsToList genUserdbEntry cfg.accounts)} + EOF + ''; + + genMaildir = pkgs.writeScript "generate-maildir" '' + #!${pkgs.stdenv.shell} + + # Create mail directory and set permissions accordingly. + umask 007 + mkdir -p ${cfg.mailDirectory} + chgrp "${cfg.vmailGroupName}" ${cfg.mailDirectory} + chmod 02770 ${cfg.mailDirectory} + ''; +in { + options.mailsystem.dovecot.dhparamSize = lib.mkOption { + type = lib.types.int; + default = 2048; + description = "The bit size for the prime that is used during a Diffie-Hellman key exchange by dovecot."; + }; + + config = lib.mkIf cfg.enable { + assertions = + lib.mapAttrsToList (user: value: [ + { + assertion = value.hashedPasswordFile != null; + message = "A file containing the hashed password for user ${user} needs to be set."; + } + ]) + cfg.accounts; + + services.dovecot2 = { + enable = true; + enableImap = true; + enablePAM = false; + # TODO: enable quota and setup quota warnings + #enableQuota = true; + mailUser = cfg.vmailUserName; + mailGroup = cfg.vmailGroupName; + mailLocation = "maildir:~/Maildir"; + + sslServerCert = sslCertPath; + sslServerKey = sslKeyPath; + + enableLmtp = true; + + modules = [ + # sieves + managesieve + pkgs.dovecot_pigeonhole + ]; + + # enable managesieve + protocols = ["sieve"]; + + pluginSettings = { + sieve = "file:~/sieve;active=~/.dovecot.sieve"; + }; + + # TODO: move configuration to default.nix? + mailboxes = { + Drafts = { + auto = "subscribe"; + specialUse = "Drafts"; + }; + Junk = { + auto = "subscribe"; + specialUse = "Junk"; + }; + Trash = { + auto = "subscribe"; + specialUse = "Trash"; + }; + Sent = { + auto = "subscribe"; + specialUse = "Sent"; + }; + }; + + extraConfig = '' + service imap-login { + inet_listener imap { + # Using starttls for encryption shifts the responsibility for + # encrypted communication over to the client in the face of MITM. + # However, it has shown that clients are often not enforcing it. + port = 0 # clients should use starttls instaed + } + inet_listener imaps { + port = 993 + ssl = yes + } + } + + protocol imap { + mail_max_userip_connections = 100 + mail_plugins = $mail_plugins imap_sieve + } + + mail_access_groups = ${cfg.vmailGroupName} + ssl = required + ssl_min_protocol = TLSv1.2 + ssl_prefer_server_ciphers = yes + + service lmtp { + unix_listener dovecot-lmtp { + user = ${postfixCfg.user} + group = ${postfixCfg.group} + mode = 0600 + } + } + + recipient_delimiter = + + lmtp_save_to_detail_mailbox = no + + protocol lmtp { + mail_plugins = $mail_plugins sieve + } + + # Passwords stored among two passwd-files: One for users allowed to + # change their password and one for any other user (mostly system + # users) with immutable passwords. + passdb { + driver = passwd-file + args = ${dovecotDynamicPasswdFile} + } + passdb { + driver = passwd-file + args = ${staticPasswdFile} + } + + userdb { + driver = passwd-file + args = ${userdbFile} + default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory}/%d/%n + } + + service auth { + unix_listener auth { + user = ${postfixCfg.user} + group = ${postfixCfg.group} + mode = 0660 + } + } + + auth_mechanisms = plain login + + namespace inbox { + separator = / + inbox = yes + } + + lda_mailbox_autosubscribe = yes + lda_mailbox_autocreate = yes + ''; + }; + + security.dhparams.params.dovecot2.bits = cfg.dovecot.dhparamSize; + + systemd.services.dovecot2 = { + preStart = '' + ${genAuthDbsScript} + ${genMaildir} + ''; + wants = sslCertService; + after = sslCertService; + }; + systemd.services.postfix.restartTriggers = [genAuthDbsScript]; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [ + 993 # imaps + 4190 #managesieve + ]; + }; + }; +} diff --git a/mailsystem/nginx.nix b/mailsystem/nginx.nix index 2bb294b..e780b4a 100644 --- a/mailsystem/nginx.nix +++ b/mailsystem/nginx.nix @@ -16,5 +16,9 @@ in { }; networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443]; + + security.acme.certs."${cfg.fqdn}".reloadServices = [ + "dovecot2.service" + ]; }; } From b80550209973d031a64d2b5bc15648eb06772740 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Wed, 4 Dec 2024 21:39:42 +0100 Subject: [PATCH 19/61] mailsystem: Add basic postfix configuration --- mailsystem/default.nix | 71 ++++++++++++++- mailsystem/nginx.nix | 1 + mailsystem/postfix.nix | 194 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 mailsystem/postfix.nix diff --git a/mailsystem/default.nix b/mailsystem/default.nix index 874fa8e..db16958 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -1,4 +1,10 @@ -{lib, ...}: { +{ + config, + lib, + ... +}: let + cfg = config.mailsystem; +in { options.mailsystem = { enable = lib.mkEnableOption "nixos-mailsystem"; @@ -14,6 +20,32 @@ description = "Fully qualified domain name of the mail server."; }; + reverseFqdn = lib.mkOption { + type = lib.types.str; + default = cfg.fqdn; + defaultText = lib.literalMD "{option}`mailsystem.fqdn`"; + example = "server.example.com"; + description = '' + Fully qualified domain name used by the server to identify + with other servers. + + This needs to be set to the same value of the server's IP reverse DNS. + ''; + }; + + domains = lib.mkOption { + type = lib.types.listOf lib.types.str; + example = ["example.com"]; + default = []; + description = "List of domains to be served by the mail server"; + }; + + messageSizeLimit = lib.mkOption { + type = lib.types.int; + default = 64 * 1024 * 1024; + description = "Maximum accepted mail size"; + }; + vmailUID = lib.mkOption { type = lib.types.int; default = 5000; @@ -60,6 +92,17 @@ ''; }; + aliases = lib.mkOption { + type = with lib.types; listOf types.str; + example = ["abuse@example.com" "postmaster@example.com"]; + default = []; + description = '' + A list of aliases of this login account. + Note: Use list entries like "@example.com" to create a catchAll + that allows sending from all email addresses in these domain. + ''; + }; + isSystemUser = lib.mkOption { type = lib.types.bool; default = false; @@ -83,11 +126,37 @@ description = "All available accounts for the mailsystem."; default = {}; }; + + extraVirtualAliases = lib.mkOption { + type = let + account = lib.mkOptionType { + name = "Mail Account"; + check = account: builtins.elem account (builtins.attrNames cfg.accounts); + }; + in + with lib.types; attrsOf (either account (nonEmptyListOf account)); + example = { + "info@example.com" = "user1@example.com"; + "postmaster@example.com" = "user1@example.com"; + "abuse@example.com" = "user1@example.com"; + "multi@example.com" = ["user1@example.com" "user2@example.com"]; + }; + description = '' + Virtual account aliases. A virtual alias `"info@example.com" = "user1@example.com"` + means that all mail to `info@example.com` is forwarded to `user1@example.com`. + Furthermore, it also allows the user `user1@example.com` to send emails as + `info@example.com`. It is also possible to create an alias for multiple accounts. + In this example, all mails for `multi@example.com` will be forwarded to both + `user1@example.com` and `user2@example.com`. + ''; + default = {}; + }; }; imports = [ ./dovecot.nix ./nginx.nix + ./postfix.nix ./user.nix ]; } diff --git a/mailsystem/nginx.nix b/mailsystem/nginx.nix index e780b4a..7edd771 100644 --- a/mailsystem/nginx.nix +++ b/mailsystem/nginx.nix @@ -18,6 +18,7 @@ in { networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443]; security.acme.certs."${cfg.fqdn}".reloadServices = [ + "postfix.service" "dovecot2.service" ]; }; diff --git a/mailsystem/postfix.nix b/mailsystem/postfix.nix new file mode 100644 index 0000000..ec42a1c --- /dev/null +++ b/mailsystem/postfix.nix @@ -0,0 +1,194 @@ +{ + config, + lib, + pkgs, + ... +}: +with (import ./common.nix {inherit config;}); let + cfg = config.mailsystem; + + mappedFile = name: "hash:/var/lib/postfix/conf/${name}"; + + attrsToLookupTable = aliases: let + lookupTables = lib.mapAttrsToList (from: to: {"${from}" = to;}) aliases; + in + mergeLookupTables lookupTables; + + lookupTableToString = attrs: let + valueToString = value: lib.concatStringsSep ", " value; + in + lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs); + + mergeLookupTables = tables: lib.zipAttrsWith (n: v: lib.flatten v) tables; + + account_virtual_aliases = mergeLookupTables (lib.flatten (lib.mapAttrsToList + (name: value: let + to = name; + in + map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name)) + cfg.accounts)); + + extra_virtual_aliases = attrsToLookupTable cfg.extraVirtualAliases; + + all_virtual_aliases = mergeLookupTables [account_virtual_aliases extra_virtual_aliases]; + + aliases_file = let + content = lookupTableToString all_virtual_aliases; + in + builtins.toFile "virtual_aliases" content; + + # File containing all mappings of authenticated accounts and their sender mail addresses. + virtual_accounts_file = let + content = lookupTableToString all_virtual_aliases; + in + builtins.toFile "virtual_accounts" content; + + virtual_domains_file = builtins.toFile "virtual_domains" (lib.concatStringsSep "\n" cfg.domains); + + submission_header_cleanup_rules = pkgs.writeText "submission_header_cleanup_rules" '' + # Removes sensitive headers from mails handed in via the submission port. + # See https://thomas-leister.de/mailserver-debian-stretch/ + # Uses "pcre" style regex. + + /^Received:/ IGNORE + /^X-Originating-IP:/ IGNORE + /^X-Mailer:/ IGNORE + /^User-Agent:/ IGNORE + /^X-Enigmail:/ IGNORE + + # Replace the user's submitted hostname with the server's FQDN to hide the + # user's host/network. + /^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}> + ''; + + tls_protocols = "TLSv1.3, TLSv1.2, !TLSv1.1, !TLSv1, !SSLv2, !SSLv3"; + tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL"; +in { + config = lib.mkIf cfg.enable { + services.postfix = { + enable = true; + hostname = "${cfg.reverseFqdn}"; + networksStyle = "host"; + + sslCert = sslCertPath; + sslKey = sslKeyPath; + + enableSubmissions = true; + + # TODO: create function to simplify this? + mapFiles."virtual_aliases" = aliases_file; + mapFiles."virtual_accounts" = virtual_accounts_file; + virtual = lookupTableToString all_virtual_aliases; + + submissionsOptions = { + smtpd_tls_security_level = "encrypt"; + smtpd_sasl_auth_enable = "yes"; + smtpd_sasl_type = "dovecot"; + smtpd_sasl_path = "/run/dovecot2/auth"; + smtpd_sasl_security_options = "noanonymous"; + smtpd_sasl_local_domain = "$myhostname"; + smtpd_client_restrictions = "permit_sasl_authenticated,reject"; + # use mappedFile -> different path? + smtpd_sender_login_maps = "hash:/etc/postfix/virtual_accounts"; + smtpd_sender_restrictions = "reject_sender_login_mismatch"; + smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject"; + cleanup_service_name = "submission-header-cleanup"; + }; + + config = { + mydestination = ""; + recipient_delimiter = "+"; + smtpd_banner = "${cfg.fqdn} ESMTP NO UCE"; + disable_vrfy_command = true; + message_size_limit = toString cfg.messageSizeLimit; + + virtual_uid_maps = "static:${toString cfg.vmailUID}"; + virtual_gid_maps = "static:${toString cfg.vmailUID}"; + virtual_mailbox_base = cfg.mailDirectory; + virtual_mailbox_domains = virtual_domains_file; + virtual_mailbox_maps = [ + (mappedFile "virtual_aliases") + ]; + virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp"; + # Avoid leakage of X-Original-To, X-Delivered-To headers between recipients + lmtp_destination_recipient_limit = "1"; + + # sasl with dovecot (enforce authentication via dovecot) + smtpd_sasl_type = "dovecot"; + smtpd_sasl_path = "/run/dovecot2/auth"; + smtpd_sasl_auth_enable = true; + smtpd_relay_restrictions = [ + "permit_mynetworks" + "permit_sasl_authenticated" + "reject_unauth_destination" + ]; + + # TLS settings, inspired by https://github.com/jeaye/nix-files + # Submission by mail clients is handled in submissionOptions + smtpd_tls_security_level = "may"; + + # Disable obsolete protocols + smtpd_tls_protocols = tls_protocols; + smtp_tls_protocols = tls_protocols; + smtpd_tls_mandatory_protocols = tls_protocols; + smtp_tls_mandatory_protocols = tls_protocols; + + smtp_tls_ciphers = "high"; + smtpd_tls_ciphers = "high"; + smtp_tls_mandatory_ciphers = "high"; + smtpd_tls_mandatory_ciphers = "high"; + + # Disable deprecated ciphers + smtpd_tls_mandatory_exclude_ciphers = tls_exclude_ciphers; + smtpd_tls_exclude_ciphers = tls_exclude_ciphers; + smtp_tls_mandatory_exclude_ciphers = tls_exclude_ciphers; + smtp_tls_exclude_ciphers = tls_exclude_ciphers; + + tls_preempt_cipherlist = true; + + # Allowing AUTH on a non-encrypted connection poses a security risk + smtpd_tls_auth_only = true; + # Log only a summary message on TLS handshake completion + smtpd_tls_loglevel = "1"; + + # Configure a non-blocking source of randomness + tls_random_source = "dev:/dev/urandom"; + + # Fix for https://www.postfix.org/smtp-smuggling.html + smtpd_forbid_bare_newline = "yes"; + smtpd_forbid_bare_newline_exclusions = "$mynetworks"; + }; + + masterConfig = { + "lmtp" = { + # Add headers when delivering, see http://www.postfix.org/smtp.8.html + # D => Delivered-To, O => X-Original-To, R => Return-Path + args = ["flags=O"]; + }; + "submission-header-cleanup" = { + type = "unix"; + private = false; + chroot = false; + maxproc = 0; + command = "cleanup"; + args = ["-o" "header_checks=pcre:${submission_header_cleanup_rules}"]; + }; + }; + }; + + systemd.services.postfix = { + wants = sslCertService; + after = + ["dovecot2.service"] + ++ sslCertService; + requires = ["dovecot2.service"]; + }; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [ + 25 # smtp + 465 # submissions + ]; + }; + }; +} From 9149f03384f7b3fc5a2e6047108eabe58283e1bc Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 5 Dec 2024 14:10:04 +0100 Subject: [PATCH 20/61] mailsystem: Configure rspamd as spam filter --- mailsystem/common.nix | 3 ++ mailsystem/default.nix | 3 ++ mailsystem/kresd.nix | 11 ++++++ mailsystem/postfix.nix | 14 ++++++- mailsystem/redis.nix | 27 +++++++++++++ mailsystem/rspamd.nix | 87 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 mailsystem/kresd.nix create mode 100644 mailsystem/redis.nix create mode 100644 mailsystem/rspamd.nix diff --git a/mailsystem/common.nix b/mailsystem/common.nix index 2dd170c..f74539a 100644 --- a/mailsystem/common.nix +++ b/mailsystem/common.nix @@ -7,4 +7,7 @@ in rec { dovecotDynamicStateDir = "/var/lib/dovecot"; dovecotDynamicPasswdFile = "${dovecotDynamicStateDir}/passwd"; + + rspamdProxySocket = "/run/rspamd-proxy.sock"; + rspamdControllerSocket = "/run/rspamd-controller.sock"; } diff --git a/mailsystem/default.nix b/mailsystem/default.nix index db16958..16ea299 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -155,8 +155,11 @@ in { imports = [ ./dovecot.nix + ./kresd.nix ./nginx.nix ./postfix.nix + ./redis.nix + ./rspamd.nix ./user.nix ]; } diff --git a/mailsystem/kresd.nix b/mailsystem/kresd.nix new file mode 100644 index 0000000..448b644 --- /dev/null +++ b/mailsystem/kresd.nix @@ -0,0 +1,11 @@ +{ + config, + lib, + ... +}: let + cfg = config.mailsystem; +in { + config = lib.mkIf cfg.enable { + services.kresd.enable = true; + }; +} diff --git a/mailsystem/postfix.nix b/mailsystem/postfix.nix index ec42a1c..f0afb8d 100644 --- a/mailsystem/postfix.nix +++ b/mailsystem/postfix.nix @@ -154,6 +154,16 @@ in { # Configure a non-blocking source of randomness tls_random_source = "dev:/dev/urandom"; + smtpd_milters = [ + "unix:${rspamdProxySocket}" + ]; + # Also use milter for outgoing mails (for e.g., dkim) + non_smtpd_milters = [ + "unix:${rspamdProxySocket}" + ]; + milter_protocol = "6"; + milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer}"; + # Fix for https://www.postfix.org/smtp-smuggling.html smtpd_forbid_bare_newline = "yes"; smtpd_forbid_bare_newline_exclusions = "$mynetworks"; @@ -179,9 +189,9 @@ in { systemd.services.postfix = { wants = sslCertService; after = - ["dovecot2.service"] + ["dovecot2.service" "rspamd.service"] ++ sslCertService; - requires = ["dovecot2.service"]; + requires = ["dovecot2.service" "rspamd.service"]; }; networking.firewall = lib.mkIf cfg.openFirewall { diff --git a/mailsystem/redis.nix b/mailsystem/redis.nix new file mode 100644 index 0000000..658093d --- /dev/null +++ b/mailsystem/redis.nix @@ -0,0 +1,27 @@ +{ + config, + lib, + pkgs, + ... +}: let + cfg = config.mailsystem; + redisCfg = config.services.redis.servers.rspamd; + rspamdCfg = config.services.rspamd; +in { + config = lib.mkIf cfg.enable { + services.redis.servers.rspamd = { + enable = true; + # Don't accept connections via tcp + port = 0; + unixSocketPerm = 600; + }; + + # TODO: Run commands as service user instead of as root? + systemd.services.redis-rspamd.serviceConfig.ExecStartPost = + "+" + + pkgs.writeShellScript "redis-rspamd-postStart" '' + ${pkgs.acl.bin}/bin/setfacl -m "u:${rspamdCfg.user}:x" "${builtins.dirOf redisCfg.unixSocket}" + ${pkgs.acl.bin}/bin/setfacl -m "u:${rspamdCfg.user}:rw" "${redisCfg.unixSocket}" + ''; + }; +} diff --git a/mailsystem/rspamd.nix b/mailsystem/rspamd.nix new file mode 100644 index 0000000..d9f3b51 --- /dev/null +++ b/mailsystem/rspamd.nix @@ -0,0 +1,87 @@ +{ + config, + lib, + pkgs, + ... +}: +with (import ./common.nix {inherit config;}); let + cfg = config.mailsystem; + postfixCfg = config.services.postfix; + redisCfg = config.services.redis.servers.rspamd; + rspamdCfg = config.services.rspamd; + + genSystemdSocketCfg = name: socketPath: additionalUser: { + description = "rspamd ${name} worker socket"; + listenStreams = [socketPath]; + requiredBy = ["rspamd.service"]; + socketConfig = { + Service = "rspamd.service"; + SocketUser = rspamdCfg.user; + SocketMode = 0600; + ExecStartPost = + lib.mkIf (additionalUser != "") + ''${pkgs.acl.bin}/bin/setfacl -m "u:${additionalUser}:rw" "${socketPath}"''; + }; + }; +in { + config = lib.mkIf cfg.enable { + services.rspamd = { + enable = true; + overrides = { + "classifier-bayes.conf" = { + text = '' + autolearn { + spam_threshold = 6.0 # When to learn spam (score >= threshold) + ham_threshold = -2.0 # When to learn ham (score <= threshold) + } + ''; + }; + "milter_headers.conf" = { + text = '' + # Add headers related to spam-detection + extended_spam_headers = true; + ''; + }; + "redis.conf" = { + text = '' + servers = "${redisCfg.unixSocket}"; + ''; + }; + }; + + workers = { + rspamd_proxy = { + bindSockets = ["systemd:rspamd-proxy.socket"]; + count = 1; # Do not spawn too many processes of this type + extraConfig = '' + milter = yes; # Enable milter mode + timeout = 120s; # Needed for Milter usually + + upstream "local" { + default = yes; # Self-scan upstreams are always default + self_scan = yes; # Enable self-scan + } + ''; + }; + + controller = { + count = 1; + bindSockets = ["systemd:rspamd-controller.socket"]; + extraConfig = '' + static_dir = "''${WWWDIR}"; # Serve the web UI static assets + ''; + }; + }; + }; + + systemd.sockets = { + rspamd-proxy = genSystemdSocketCfg "proxy" rspamdProxySocket postfixCfg.user; + rspamd-controller = genSystemdSocketCfg "controller" rspamdControllerSocket ""; + }; + + systemd.services.rspamd = { + requires = ["redis-rspamd.service"]; + after = ["redis-rspamd.service"]; + }; + }; +} From aacf9a9b8c17f7a8d3e95a93c51acc0dd16f4d00 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 5 Dec 2024 14:58:21 +0100 Subject: [PATCH 21/61] mailsystem: dovecot: Autolearn ham/spam when moving mails --- mailsystem/dovecot.nix | 56 +++++++++++++++++++++++++++- mailsystem/dovecot/report-ham.sieve | 15 ++++++++ mailsystem/dovecot/report-spam.sieve | 7 ++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 mailsystem/dovecot/report-ham.sieve create mode 100644 mailsystem/dovecot/report-spam.sieve diff --git a/mailsystem/dovecot.nix b/mailsystem/dovecot.nix index 460e0ef..4f17464 100644 --- a/mailsystem/dovecot.nix +++ b/mailsystem/dovecot.nix @@ -92,6 +92,14 @@ with (import ./common.nix {inherit config;}); let chgrp "${cfg.vmailGroupName}" ${cfg.mailDirectory} chmod 02770 ${cfg.mailDirectory} ''; + + junkMailboxes = builtins.attrNames (lib.filterAttrs (n: v: v ? "specialUse" && v.specialUse == "Junk") dovecot2Cfg.mailboxes); + junkMailboxNumber = builtins.length junkMailboxes; + # The assertion guarantees that there is exactly one Junk mailbox. + junkMailboxName = + if junkMailboxNumber == 1 + then builtins.elemAt junkMailboxes 0 + else ""; in { options.mailsystem.dovecot.dhparamSize = lib.mkOption { type = lib.types.int; @@ -101,12 +109,18 @@ in { config = lib.mkIf cfg.enable { assertions = - lib.mapAttrsToList (user: value: [ + [ { + assertion = junkMailboxNumber == 1; + message = "mailnix requires exactly one dovecot mailbox with the 'special use' flag to 'Junk' (${builtins.toString junkMailboxNumber} have been found)"; + } + ] + ++ lib.mapAttrsToList ( + user: value: { assertion = value.hashedPasswordFile != null; message = "A file containing the hashed password for user ${user} needs to be set."; } - ]) + ) cfg.accounts; services.dovecot2 = { @@ -136,6 +150,44 @@ in { sieve = "file:~/sieve;active=~/.dovecot.sieve"; }; + sieve = { + extensions = [ + "fileinto" + "mailbox" + ]; + + scripts.after = builtins.toFile "spam.sieve" '' + require "fileinto"; + require "mailbox"; + + if header :is "X-Spam" "Yes" { + fileinto :create "${junkMailboxName}"; + stop; + } + ''; + + pipeBins = map lib.getExe [ + (pkgs.writeShellScriptBin "learn-ham.sh" + "exec ${pkgs.rspamd}/bin/rspamc -h ${rspamdControllerSocket} learn_ham") + (pkgs.writeShellScriptBin "learn-spam.sh" + "exec ${pkgs.rspamd}/bin/rspamc -h ${rspamdControllerSocket} learn_spam") + ]; + }; + + imapsieve.mailbox = [ + { + name = junkMailboxName; + causes = ["COPY" "APPEND"]; + before = ./dovecot/report-spam.sieve; + } + { + name = "*"; + from = junkMailboxName; + causes = ["COPY"]; + before = ./dovecot/report-ham.sieve; + } + ]; + # TODO: move configuration to default.nix? mailboxes = { Drafts = { diff --git a/mailsystem/dovecot/report-ham.sieve b/mailsystem/dovecot/report-ham.sieve new file mode 100644 index 0000000..6217a90 --- /dev/null +++ b/mailsystem/dovecot/report-ham.sieve @@ -0,0 +1,15 @@ +require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; + +if environment :matches "imap.mailbox" "*" { + set "mailbox" "${1}"; +} + +if string "${mailbox}" "Trash" { + stop; +} + +if environment :matches "imap.user" "*" { + set "username" "${1}"; +} + +pipe :copy "learn-ham.sh" [ "${username}" ]; diff --git a/mailsystem/dovecot/report-spam.sieve b/mailsystem/dovecot/report-spam.sieve new file mode 100644 index 0000000..9d4c74b --- /dev/null +++ b/mailsystem/dovecot/report-spam.sieve @@ -0,0 +1,7 @@ +require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; + +if environment :matches "imap.user" "*" { + set "username" "${1}"; +} + +pipe :copy "learn-spam.sh" [ "${username}" ]; From aff4f9117fc916a4bb1b440e15fcb86e3cd23325 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 5 Dec 2024 15:38:11 +0100 Subject: [PATCH 22/61] mailsystem: rspamd: Add configuration options to make rspamd's web ui accessible --- mailsystem/rspamd.nix | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/mailsystem/rspamd.nix b/mailsystem/rspamd.nix index d9f3b51..9117072 100644 --- a/mailsystem/rspamd.nix +++ b/mailsystem/rspamd.nix @@ -6,6 +6,7 @@ }: with (import ./common.nix {inherit config;}); let cfg = config.mailsystem; + nginxCfg = config.services.nginx; postfixCfg = config.services.postfix; redisCfg = config.services.redis.servers.rspamd; rspamdCfg = config.services.rspamd; @@ -24,7 +25,27 @@ with (import ./common.nix {inherit config;}); let }; }; in { + options.mailsystem.rspamd.webUi = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to enable the rspamd webui on `https://${config.mailsystem.fqdn}/rspamd`"; + }; + + basicAuthFile = lib.mkOption { + type = lib.types.str; + description = "Path to basic auth file (entries can be generated using htpasswd)"; + }; + }; + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = !cfg.rspamd.webUi.enable || cfg.rspamd.webUi.basicAuthFile != null; + message = "Setting basicAuthFile is required if rspamd's web interface is enabled"; + } + ]; + services.rspamd = { enable = true; overrides = { @@ -47,6 +68,12 @@ in { servers = "${redisCfg.unixSocket}"; ''; }; + "worker-controller.inc" = lib.mkIf cfg.rspamd.webUi.enable { + text = '' + secure_ip = "0.0.0.0/0"; + secure_ip = "::/0"; + ''; + }; }; workers = { @@ -76,12 +103,25 @@ in { systemd.sockets = { rspamd-proxy = genSystemdSocketCfg "proxy" rspamdProxySocket postfixCfg.user; - rspamd-controller = genSystemdSocketCfg "controller" rspamdControllerSocket ""; + rspamd-controller = genSystemdSocketCfg "controller" rspamdControllerSocket ( + lib.optionalString cfg.rspamd.webUi.enable nginxCfg.user + ); }; systemd.services.rspamd = { requires = ["redis-rspamd.service"]; after = ["redis-rspamd.service"]; }; + + services.nginx = lib.mkIf cfg.rspamd.webUi.enable { + enable = true; + virtualHosts."${cfg.fqdn}" = { + forceSSL = true; + locations."/rspamd" = { + proxyPass = "http://unix:${rspamdControllerSocket}:/"; + basicAuthFile = cfg.rspamd.webUi.basicAuthFile; + }; + }; + }; }; } From c8a44b9b48da35c708686528938b4bd8abcc1395 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 5 Dec 2024 15:52:03 +0100 Subject: [PATCH 23/61] mailsystem: Add configuration for roundcube as webmail interface --- mailsystem/default.nix | 1 + mailsystem/roundcube.nix | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 mailsystem/roundcube.nix diff --git a/mailsystem/default.nix b/mailsystem/default.nix index 16ea299..7cc0dc6 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -159,6 +159,7 @@ in { ./nginx.nix ./postfix.nix ./redis.nix + ./roundcube.nix ./rspamd.nix ./user.nix ]; diff --git a/mailsystem/roundcube.nix b/mailsystem/roundcube.nix new file mode 100644 index 0000000..bebffaf --- /dev/null +++ b/mailsystem/roundcube.nix @@ -0,0 +1,56 @@ +{ + config, + lib, + pkgs, + ... +}: +with (import ./common.nix {inherit config;}); let + cfg = config.mailsystem; + roundcubeCfg = config.mailsystem.roundcube; +in { + options.mailsystem.roundcube = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to enable roundcube in order to provide a webmail interface"; + }; + hostName = lib.mkOption { + type = lib.types.str; + default = cfg.fqdn; + description = "FQDN to be used by roundcube. Defaults to {option}`mailsystem.fqdn`."; + }; + passwordHashingAlgorithm = lib.mkOption { + type = lib.types.str; + default = "BLF-CRYPT"; + description = "Password hashing algorithm to be used with `doveadm pw`"; + }; + }; + + config = lib.mkIf (cfg.enable && roundcubeCfg.enable) { + services.roundcube = { + enable = true; + hostName = roundcubeCfg.hostName; + plugins = ["managesieve" "password"]; + extraConfig = '' + // Use starttls for authentication + $config['smtp_host'] = "tls://${cfg.fqdn}"; + $config['smtp_user'] = "%u"; + $config['smtp_pass'] = "%p"; + + $config['managesieve_host'] = "localhost"; + + $config['password_driver'] = "dovecot_passwdfile"; + $config['password_confirm_current'] = true; + $config['password_minimum_length'] = 8; + $config['password_algorithm'] = "dovecot"; + // Enables saving the new password even if it machtes the old password. Useful + // for upgrading the stored passwords after the encryption scheme has changed. + $config['password_force_save'] = true; + $config['password_dovecot_passwdfile_path'] = "${pkgs.dovecot}/bin/doveadm pw"; + $config['password_dovecotpw'] = "${dovecotDynamicPasswdFile}"; + $config['password_dovecotpw_method'] = "${roundcubeCfg.passwordHashingAlgorithm}"; + $config['password_dovecotpw_with_method'] = true; + ''; + }; + }; +} From 8a64eb9287c9170955a24c22ab4c6a3e7a07bb9d Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 5 Dec 2024 15:53:57 +0100 Subject: [PATCH 24/61] flake.nix: Actually expose mailsystem as nixosModule --- flake.nix | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flake.nix b/flake.nix index fc7359a..abac2ed 100644 --- a/flake.nix +++ b/flake.nix @@ -45,5 +45,10 @@ alejandra.enable = true; }; }; + + flake.nixosModules = rec { + default = mailsystem; + mailsystem = import ./mailsystem; + }; }; } From e185d301ff9b1b41ba239e912061685d28eb0d6a Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 5 Dec 2024 16:04:01 +0100 Subject: [PATCH 25/61] mailsystem: Add option to use selfsigned certificates in preparation for testing --- mailsystem/common.nix | 18 +++++++++++++++--- mailsystem/default.nix | 14 ++++++++++++++ mailsystem/nginx.nix | 35 ++++++++++++++++++++--------------- mailsystem/rspamd.nix | 2 ++ mailsystem/selfsigned.nix | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 18 deletions(-) create mode 100644 mailsystem/selfsigned.nix diff --git a/mailsystem/common.nix b/mailsystem/common.nix index f74539a..ab7174c 100644 --- a/mailsystem/common.nix +++ b/mailsystem/common.nix @@ -1,9 +1,21 @@ {config, ...}: let cfg = config.mailsystem; in rec { - sslCertPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem"; - sslKeyPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem"; - sslCertService = ["acme-finished-${cfg.fqdn}.target"]; + certificateDirectory = "/var/certs"; + sslCertPath = + if cfg.certificateScheme == "acme" + then "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem" + else "${certificateDirectory}/cert-${cfg.fqdn}.pem"; + + sslKeyPath = + if cfg.certificateScheme == "acme" + then "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem" + else "${certificateDirectory}/key-${cfg.fqdn}.pem"; + + sslCertService = + if cfg.certificateScheme == "acme" + then ["acme-finished-${cfg.fqdn}.target"] + else ["mailsystem-selfsigned-certificate.service"]; dovecotDynamicStateDir = "/var/lib/dovecot"; dovecotDynamicPasswdFile = "${dovecotDynamicStateDir}/passwd"; diff --git a/mailsystem/default.nix b/mailsystem/default.nix index 7cc0dc6..8da5133 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -151,6 +151,19 @@ in { ''; default = {}; }; + + certificateScheme = lib.mkOption { + type = lib.types.enum ["acme" "selfsigned"]; + default = "acme"; + description = '' + The scheme to use for managing TLS certificates: + + 1. `acme`: The server retrieves letsencrypt certificates via NixOS's acme module using nginx. + 2. `selfsigned`: The server creates self-signed certificates on the fly (intended for testing). + ''; + internal = true; + visible = false; + }; }; imports = [ @@ -161,6 +174,7 @@ in { ./redis.nix ./roundcube.nix ./rspamd.nix + ./selfsigned.nix ./user.nix ]; } diff --git a/mailsystem/nginx.nix b/mailsystem/nginx.nix index 7edd771..03e8f26 100644 --- a/mailsystem/nginx.nix +++ b/mailsystem/nginx.nix @@ -3,23 +3,28 @@ pkgs, lib, ... -}: let +}: +with (import ./common.nix {inherit config;}); let cfg = config.mailsystem; in { - config = lib.mkIf cfg.enable { - services.nginx = { - enable = true; - virtualHosts."${cfg.fqdn}" = { - forceSSL = true; - enableACME = true; + config = + lib.mkIf cfg.enable { + services.nginx = { + enable = true; + virtualHosts."${cfg.fqdn}" = { + forceSSL = true; + enableACME = cfg.certificateScheme == "acme"; + sslCertificate = lib.mkIf (cfg.certificateScheme == "selfsigned") sslCertPath; + sslCertificateKey = lib.mkIf (cfg.certificateScheme == "selfsigned") sslKeyPath; + }; }; + + networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443]; + } + // lib.mkIf (cfg.enable && cfg.certificateScheme == "acme") { + security.acme.certs."${cfg.fqdn}".reloadServices = [ + "postfix.service" + "dovecot2.service" + ]; }; - - networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443]; - - security.acme.certs."${cfg.fqdn}".reloadServices = [ - "postfix.service" - "dovecot2.service" - ]; - }; } diff --git a/mailsystem/rspamd.nix b/mailsystem/rspamd.nix index 9117072..55079ed 100644 --- a/mailsystem/rspamd.nix +++ b/mailsystem/rspamd.nix @@ -121,6 +121,8 @@ in { proxyPass = "http://unix:${rspamdControllerSocket}:/"; basicAuthFile = cfg.rspamd.webUi.basicAuthFile; }; + sslCertificate = lib.mkIf (cfg.certificateScheme == "selfsigned") sslCertPath; + sslCertificateKey = lib.mkIf (cfg.certificateScheme == "selfsigned") sslKeyPath; }; }; }; diff --git a/mailsystem/selfsigned.nix b/mailsystem/selfsigned.nix new file mode 100644 index 0000000..4506fcf --- /dev/null +++ b/mailsystem/selfsigned.nix @@ -0,0 +1,33 @@ +{ + config, + pkgs, + lib, + ... +}: +with (import ./common.nix {inherit config;}); let + cfg = config.mailsystem; +in { + config = lib.mkIf (cfg.enable && cfg.certificateScheme == "selfsigned") { + systemd.services.mailsystem-selfsigned-certificate = { + after = ["local-fs.target"]; + script = '' + # Create certificates if they do not exist yet + dir="${certificateDirectory}" + fqdn="${cfg.fqdn}" + [[ $fqdn == /* ]] && fqdn=$(< "$fqdn") + key="${sslKeyPath}" + cert="${sslCertPath}" + + if [[ ! -f $key || ! -f $cert ]]; then + mkdir -p "$dir" + (umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 4096) && + "${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" -days 3650 -out "$cert" + fi + ''; + serviceConfig = { + Type = "oneshot"; + PrivateTmp = true; + }; + }; + }; +} From 84542be242ff5c687368866c183c07128bd7a773 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 7 Dec 2024 02:35:16 +0100 Subject: [PATCH 26/61] Add minimal (internal) tests --- flake.nix | 9 +++++++++ tests/common/server.nix | 13 +++++++++++++ tests/internal.nix | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 tests/common/server.nix create mode 100644 tests/internal.nix diff --git a/flake.nix b/flake.nix index abac2ed..8bdd0e5 100644 --- a/flake.nix +++ b/flake.nix @@ -32,6 +32,15 @@ system, ... }: { + checks = let + tests = ["internal"]; + genTest = testName: { + "name" = testName; + "value" = import (./tests + "/${testName}.nix") {inherit pkgs;}; + }; + in + pkgs.lib.listToAttrs (map genTest tests); + devShells.default = pkgs.mkShell { packages = with pkgs; [ alejandra diff --git a/tests/common/server.nix b/tests/common/server.nix new file mode 100644 index 0000000..9a47284 --- /dev/null +++ b/tests/common/server.nix @@ -0,0 +1,13 @@ +{...}: { + imports = [./../../mailsystem]; + config = { + virtualisation.memorySize = 1024; + mailsystem = { + enable = true; + + roundcube.enable = false; + rspamd.webUi.enable = false; + certificateScheme = "selfsigned"; + }; + }; +} diff --git a/tests/internal.nix b/tests/internal.nix new file mode 100644 index 0000000..a22a67d --- /dev/null +++ b/tests/internal.nix @@ -0,0 +1,40 @@ +{pkgs, ...}: +pkgs.nixosTest { + name = "internal"; + nodes.machine = {...}: { + imports = [./common/server.nix]; + mailsystem = { + fqdn = "mail.example.com"; + domains = ["example.com"]; + accounts = {}; + vmailUserName = "vmail"; + vmailGroupName = "vmail"; + vmailUID = 5000; + }; + }; + testScript = {nodes, ...}: let + pkgs = nodes.machine.nixpkgs.pkgs; + in '' + machine.start() + machine.wait_for_unit("multi-user.target") + + with subtest("imap is only available via port 993 and is encrypted"): + machine.wait_for_closed_port(143) + machine.wait_for_open_port(993) + machine.succeed( + "echo | ${pkgs.openssl}/bin/openssl s_client -connect localhost:993 | grep 'New, TLS'" + ) + + with subtest("smtp is only available via port 465 and is encrypted"): + machine.wait_for_closed_port(587) + machine.wait_for_open_port(465) + machine.succeed( + "echo | ${pkgs.openssl}/bin/openssl s_client -connect localhost:465 | grep 'New, TLS'" + ) + + with subtest("`postfix check` succeeds"): + machine.succeed( + "${pkgs.postfix}/bin/postfix check" + ) + ''; +} From 617b116f4c329e3320827ed6c9973f3cbca3ff97 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 7 Dec 2024 02:37:57 +0100 Subject: [PATCH 27/61] tests: minimal: Configure and verify vmail user/group/uid/gid --- tests/internal.nix | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/internal.nix b/tests/internal.nix index a22a67d..8fccbfb 100644 --- a/tests/internal.nix +++ b/tests/internal.nix @@ -36,5 +36,15 @@ pkgs.nixosTest { machine.succeed( "${pkgs.postfix}/bin/postfix check" ) + + with subtest("vmail uid is set correctly"): + machine.succeed( + "[ $(getent passwd vmail | cut -d: -f3) -eq 5000 ]" + ) + + with subtest("vmail gid is set correctly"): + machine.succeed( + "[ $(getent group vmail | cut -d: -f3) -eq 5000 ]" + ) ''; } From d5107df08df5a1592ef37c14ef5d7b0b4593dc8d Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 7 Dec 2024 12:36:51 +0100 Subject: [PATCH 28/61] flake.nix: Rename pre-commit-hooks-nix into git-hooks-nix Cachix has renamed their project. --- flake.lock | 58 +++++++++++++++++++++++++++--------------------------- flake.nix | 6 +++--- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/flake.lock b/flake.lock index 9769102..c12806c 100644 --- a/flake.lock +++ b/flake.lock @@ -36,10 +36,33 @@ "type": "github" } }, + "git-hooks-nix": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ], + "nixpkgs-stable": "nixpkgs-stable" + }, + "locked": { + "lastModified": 1733318908, + "narHash": "sha256-SVQVsbafSM1dJ4fpgyBqLZ+Lft+jcQuMtEL3lQWx2Sk=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "6f4e2a2112050951a314d2733a994fbab94864c6", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, "gitignore": { "inputs": { "nixpkgs": [ - "pre-commit-hooks-nix", + "git-hooks-nix", "nixpkgs" ] }, @@ -75,11 +98,11 @@ }, "nixpkgs-stable": { "locked": { - "lastModified": 1720386169, - "narHash": "sha256-NGKVY4PjzwAa4upkGtAMz1npHGoRzWotlSnVlqI40mo=", + "lastModified": 1730741070, + "narHash": "sha256-edm8WG19kWozJ/GqyYx2VjW99EdhjKwbY3ZwdlPAAlo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "194846768975b7ad2c4988bdb82572c00222c0d7", + "rev": "d063c1dd113c91ab27959ba540c0d9753409edf3", "type": "github" }, "original": { @@ -89,34 +112,11 @@ "type": "github" } }, - "pre-commit-hooks-nix": { - "inputs": { - "flake-compat": "flake-compat", - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ], - "nixpkgs-stable": "nixpkgs-stable" - }, - "locked": { - "lastModified": 1732021966, - "narHash": "sha256-mnTbjpdqF0luOkou8ZFi2asa1N3AA2CchR/RqCNmsGE=", - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "3308484d1a443fc5bc92012435d79e80458fe43c", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, "root": { "inputs": { "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs", - "pre-commit-hooks-nix": "pre-commit-hooks-nix" + "git-hooks-nix": "git-hooks-nix", + "nixpkgs": "nixpkgs" } } }, diff --git a/flake.nix b/flake.nix index 8bdd0e5..d21e5fb 100644 --- a/flake.nix +++ b/flake.nix @@ -5,8 +5,8 @@ nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; - pre-commit-hooks-nix.url = "github:cachix/git-hooks.nix"; - pre-commit-hooks-nix.inputs.nixpkgs.follows = "nixpkgs"; + git-hooks-nix.url = "github:cachix/git-hooks.nix"; + git-hooks-nix.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = { @@ -21,7 +21,7 @@ "aarch64-linux" ]; imports = [ - inputs.pre-commit-hooks-nix.flakeModule + inputs.git-hooks-nix.flakeModule ]; perSystem = { From 6f1964e6f224b147f92def10e07e8e102dbe0a53 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 7 Dec 2024 12:55:51 +0100 Subject: [PATCH 29/61] flake.nix: Add and configure treefmt-nix for `nix fmt` --- flake.lock | 23 ++++++++++++++++++++++- flake.nix | 17 +++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index c12806c..43c3d18 100644 --- a/flake.lock +++ b/flake.lock @@ -116,7 +116,28 @@ "inputs": { "flake-parts": "flake-parts", "git-hooks-nix": "git-hooks-nix", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "treefmt-nix": "treefmt-nix" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1733440889, + "narHash": "sha256-qKL3vjO+IXFQ0nTinFDqNq/sbbnnS5bMI1y0xX215fU=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "50862ba6a8a0255b87377b9d2d4565e96f29b410", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index d21e5fb..46ea842 100644 --- a/flake.nix +++ b/flake.nix @@ -5,6 +5,8 @@ nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; + treefmt-nix.url = "github:numtide/treefmt-nix"; + treefmt-nix.inputs.nixpkgs.follows = "nixpkgs"; git-hooks-nix.url = "github:cachix/git-hooks.nix"; git-hooks-nix.inputs.nixpkgs.follows = "nixpkgs"; }; @@ -21,6 +23,7 @@ "aarch64-linux" ]; imports = [ + inputs.treefmt-nix.flakeModule inputs.git-hooks-nix.flakeModule ]; @@ -43,7 +46,7 @@ devShells.default = pkgs.mkShell { packages = with pkgs; [ - alejandra + self'.formatter.outPath # Add all formatters to environment ]; shellHook = '' ${config.pre-commit.installationScript} @@ -51,7 +54,17 @@ }; pre-commit.settings.hooks = { - alejandra.enable = true; + treefmt.enable = true; + }; + + treefmt = { + programs = { + alejandra.enable = true; + }; + settings.global.excludes = [ + ".envrc" + "*.sieve" + ]; }; }; From e4fa3bee3858d78c4debed0f68701debed7ca2e5 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Wed, 25 Dec 2024 02:07:32 +0100 Subject: [PATCH 30/61] tests: common: Add lib.nix containing various helpers for testing mailsystem behaviour --- tests/common/lib.nix | 77 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 tests/common/lib.nix diff --git a/tests/common/lib.nix b/tests/common/lib.nix new file mode 100644 index 0000000..1930be9 --- /dev/null +++ b/tests/common/lib.nix @@ -0,0 +1,77 @@ +{pkgs, ...}: let + lib = pkgs.lib; +in rec { + waitForRspamd = node: let + inherit (import ../../mailsystem/common.nix {inherit (node) config;}) rspamdProxySocket; + in "set +e; timeout 1 ${node.nixpkgs.pkgs.netcat}/bin/nc -U ${rspamdProxySocket} < /dev/null; [ $? -eq 124 ]"; + + mkHashedPasswordFile = password: + pkgs.runCommand "mk-password-hash-${password}" { + buildInputs = [pkgs.mkpasswd]; + inherit password; + } '' + echo "$password" | mkpasswd -sm bcrypt > $out + ''; + + mkAccounts = accounts: + lib.concatMapAttrs (_: account: { + ${account.address} = + { + hashedPasswordFile = "${mkHashedPasswordFile account.password}"; + } + // builtins.removeAttrs account ["address" "password"]; + }) + accounts; + + mkSendMail = smtpSettings: accounts: accountName: fromAddr: recipient: body: let + account = accounts.${accountName}; + senderAddr = + if fromAddr == "" + then account.address + else fromAddr; + msmtprc = pkgs.writeText "msmtprc" '' + account default + auth on + tls on + tls_starttls off + tls_certcheck off + host ${smtpSettings.address} + port ${toString smtpSettings.port} + from ${senderAddr} + user ${account.address} + password ${account.password} + ''; + mail = pkgs.writeText "mail-${account.address}-${recipient}" '' + From: <${account.address}> + To: <${recipient}> + ${body} + ''; + in "${pkgs.msmtp}/bin/msmtp -C ${msmtprc} ${recipient} < ${mail} >&2"; + + pendingPostqueue = "[ \"$(postqueue -p)\" != \"Mail queue is empty\" ]"; + cleanupMail = "rm $HOME/mail/*"; + + # mkRecvMail requires procmail to be setup correctly. This is ensured by + # importing ./server.nix + mkRecvMail = imapAddr: accounts: accountName: let + mkFetchmailRcScript = imapAddr: account: + pkgs.writeScript "mk-fetchmailrc-${account.address}" '' + umask 077 + readonly out=$(mktemp) + cat < "$out" + poll ${imapAddr} with proto IMAP + user '${account.address}' there with password '${account.password}' is 'root' here + mda procmail + EOF + echo $out + ''; + fetchmailrc = mkFetchmailRcScript imapAddr accounts.${accountName}; + in "${pkgs.fetchmail}/bin/fetchmail -f $(${fetchmailrc}) --ssl --nosslcertck -v"; + + checkLogs = node: '' + ${node}.fail("journalctl -u postfix | grep -i error >&2") + ${node}.fail("journalctl -u postfix | grep -i warning >&2") + ${node}.fail("journalctl -u dovecot2 | grep -i error >&2") + ${node}.fail("journalctl -u dovecot2 | grep -i warning >&2") + ''; +} From 457d91bcca847d6d4503a0e522436af92621d259 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Wed, 25 Dec 2024 12:39:48 +0100 Subject: [PATCH 31/61] tests: Add basic tests for sending/receiving mails and verification of headers --- flake.nix | 2 +- tests/basic.nix | 83 +++++++++++++++++++++++++++++++++++++++++ tests/common/client.nix | 17 +++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/basic.nix create mode 100644 tests/common/client.nix diff --git a/flake.nix b/flake.nix index 46ea842..464a10e 100644 --- a/flake.nix +++ b/flake.nix @@ -36,7 +36,7 @@ ... }: { checks = let - tests = ["internal"]; + tests = ["internal" "basic"]; genTest = testName: { "name" = testName; "value" = import (./tests + "/${testName}.nix") {inherit pkgs;}; diff --git a/tests/basic.nix b/tests/basic.nix new file mode 100644 index 0000000..615d2a1 --- /dev/null +++ b/tests/basic.nix @@ -0,0 +1,83 @@ +{pkgs, ...}: +with (import ./common/lib.nix {inherit pkgs;}); let + accounts = { + "normal" = { + address = "user1@example.com"; + password = "secret-password1"; + }; + "normal2" = { + address = "user2@example.com"; + password = "secret-password2"; + }; + }; +in + pkgs.nixosTest { + name = "basic"; + nodes = { + server = {pkgs, ...}: { + imports = [./common/server.nix]; + environment.systemPackages = with pkgs; [netcat]; + mailsystem = { + fqdn = "mail.example.com"; + domains = ["example.com"]; + accounts = mkAccounts accounts; + }; + }; + client = {...}: { + imports = [./common/client.nix]; + }; + }; + testScript = {nodes, ...}: let + serverAddr = nodes.server.networking.primaryIPAddress; + clientAddr = nodes.client.networking.primaryIPAddress; + smtpSettings = { + address = serverAddr; + port = 465; + }; + sendMail = mkSendMail smtpSettings accounts; + recvMail = mkRecvMail serverAddr accounts; + cfg = nodes.server.mailsystem; + in '' + start_all() + + server.wait_for_unit("multi-user.target") + client.wait_for_unit("multi-user.target") + server.wait_until_succeeds("${waitForRspamd nodes.server}") + + with subtest("imap works and retrieves no new mails"): + # fetchmail returns EXIT_CODE 1 when no new mail is available + client.succeed("${recvMail "normal"} || [ $? -eq 1 ] >&2") + + with subtest("send succeeds for normal user"): + client.succeed("${sendMail "normal" "" accounts."normal2".address '' + Message-ID: <123456asdf@host.local.network> + Subject: Testmail1 + + Hello User2, + this is some text! + ''}") + # give the mail server some time to process the mail + server.wait_until_fails('${pendingPostqueue}') + + with subtest("mail can be retrieved via imap"): + client.succeed("${recvMail "normal2"} >&2") + + with subtest("mail header contains no sensitive information"): + client.fail("grep '${clientAddr}' $HOME/mail/*") + client.succeed("grep '^Message-ID:.*@${cfg.fqdn}>$' $HOME/mail/*") + + with subtest("mail header contains correct fqdn in received from"): + client.succeed("grep 'Received: from ${cfg.fqdn}' $HOME/mail/*") + + with subtest("user cannot forge from-address"): + client.fail("${sendMail "normal" "someotheraddress@example.com" accounts."normal2".address '' + Subject: I actually do not own this from-address + + Hello User2, + I'm pretending to be someotheraddress@example.com and the mailserver should reject this attempt. + ''}") + + with subtest("server issues no warnings nor errors"): + ${checkLogs "server"} + ''; + } diff --git a/tests/common/client.nix b/tests/common/client.nix new file mode 100644 index 0000000..82dd41b --- /dev/null +++ b/tests/common/client.nix @@ -0,0 +1,17 @@ +{pkgs, ...}: { + config = { + # added to the environment for manual verification via interactiveDriver if necessary + environment.systemPackages = with pkgs; [fetchmail msmtp procmail openssl]; + systemd.tmpfiles.settings."10-mailtest" = let + dirPerms = { + user = "root"; + mode = "0700"; + }; + in { + "/root/mail".d = dirPerms; + "/root/.procmailrc"."L+".argument = "${pkgs.writeText ".procmailrc" '' + DEFAULT=$HOME/mail + ''}"; + }; + }; +} From 92d0a6e1f812b7ff54792fbf2f36faa6be8036af Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 28 Dec 2024 00:34:13 +0100 Subject: [PATCH 32/61] tests: Add various tests for alias functionality --- flake.nix | 2 +- tests/aliases.nix | 195 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 tests/aliases.nix diff --git a/flake.nix b/flake.nix index 464a10e..3774a92 100644 --- a/flake.nix +++ b/flake.nix @@ -36,7 +36,7 @@ ... }: { checks = let - tests = ["internal" "basic"]; + tests = ["internal" "basic" "aliases"]; genTest = testName: { "name" = testName; "value" = import (./tests + "/${testName}.nix") {inherit pkgs;}; diff --git a/tests/aliases.nix b/tests/aliases.nix new file mode 100644 index 0000000..e35124a --- /dev/null +++ b/tests/aliases.nix @@ -0,0 +1,195 @@ +{pkgs, ...}: +with (import ./common/lib.nix {inherit pkgs;}); let + accounts = { + "normal" = { + address = "user1@example.com"; + password = "secret-password1"; + }; + "normal2" = { + address = "user2@example.com"; + password = "secret-password2;"; + }; + "alias" = { + address = "user3@example.com"; + aliases = ["alias@example.com"]; + password = "secret-password3"; + }; + "extra-alias" = { + address = "user4@example.com"; + password = "secret-password4;"; + }; + "multi-alias1" = { + address = "multi-alias1@example.com"; + aliases = ["multi-alias@example.com"]; + password = "secret-password5;"; + }; + "multi-alias2" = { + address = "multi-alias2@example.com"; + aliases = ["multi-alias@example.com"]; + password = "secret-password6;"; + }; + "catchall" = { + address = "catchall@example.com"; + aliases = ["@example.com"]; + password = "secret-password7;"; + }; + "otherdomain" = { + address = "otherdomain@example.com"; + aliases = ["user@otherdomain.com"]; + password = "secret-password8;"; + }; + }; +in + pkgs.nixosTest { + name = "aliases"; + nodes = { + server = {pkgs, ...}: { + imports = [./common/server.nix]; + environment.systemPackages = with pkgs; [netcat]; + mailsystem = { + fqdn = "mail.example.com"; + domains = ["example.com" "otherdomain.com"]; + accounts = mkAccounts accounts; + extraVirtualAliases = { + "extra-alias@example.com" = accounts."extra-alias".address; + }; + }; + }; + client = {...}: { + imports = [./common/client.nix]; + }; + }; + testScript = {nodes, ...}: let + serverAddr = nodes.server.networking.primaryIPAddress; + clientAddr = nodes.client.networking.primaryIPAddress; + smtpSettings = { + address = serverAddr; + port = 465; + }; + sendMail = mkSendMail smtpSettings accounts; + recvMail = mkRecvMail serverAddr accounts; + cfg = nodes.server.mailsystem; + in '' + start_all() + + server.wait_for_unit("multi-user.target") + client.wait_for_unit("multi-user.target") + server.wait_until_succeeds("${waitForRspamd nodes.server}") + + with subtest("send mail from aliased address"): + client.succeed("${sendMail "alias" "alias@example.com" accounts."normal".address '' + Subject: Testmail1 + + Hello User1, + this is a mail dispatched using an alias instead of the normal address. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "normal"} >&2") + + with subtest("receive mail on aliased address"): + client.succeed("${sendMail "normal" "" "alias@example.com" '' + Subject: Testmail2 + + Hello alias-User, + this mail should reach you on your aliased address alias@example.com. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "alias"} >&2") + + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 1 when no new mail is available + # (as alias is the more specific address, catchall shouldn't receive the mail) + client.fail("${recvMail "catchall"} >&2") + + with subtest("receive mail on all accounts with same alias"): + client.succeed("${sendMail "normal" "" "multi-alias@example.com" '' + Subject: Testmail3 + + Hello multi-alias-Users, + this mail should reach you on your aliased address multi-alias@example.com. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "multi-alias1"} >&2") + + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "multi-alias2"} >&2") + + with subtest("send mail to catchAll-alias"): + # send email to non-existent account + client.succeed("${sendMail "normal" "" "somerandomaddress@example.com" '' + Subject: Catchall-Test + + Hello Catchall-User, + this is mail is directed at an address with no explicit user-account behind it. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "catchall"} >&2") + + with subtest("send mail from catchAll-account with a non-existing account behind"): + # send email from non-existent account + client.succeed("${sendMail "catchall" "somerandomaddress@example.com" accounts."normal2".address '' + Subject: Catchall-Test2 + + Hello User2, + this is mail is sent from an address with no explicit user-account behind it. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "normal2"} >&2") + + with subtest("catchAll-account cannot send mail from an address with an existing account behind"): + # send email to non-existent account + client.fail("${sendMail "catchall" accounts."normal".address accounts."normal2".address '' + Subject: Catchall-Test3 + + Hello User2, + this is mail should not be possible to be sent as it is dispatched by a catchall-account using an address with a user-account behind it. + ''}") + + with subtest("send mail from aliased address of other domain"): + client.succeed("${sendMail "otherdomain" "user@otherdomain.com" accounts."normal".address '' + Subject: Dispatch from other domain + + Hello User1, + this is a mail dispatched using an alias to a different domain instead of the normal address. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "normal"} >&2") + + with subtest("receive mail on aliased address of other domain"): + client.succeed("${sendMail "normal" "" "user@otherdomain.com" '' + Subject: Reception from other domain + + Hello otherdomain-User, + this mail should reach you on your aliased address user@otherdomain.com. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "otherdomain"} >&2") + + with subtest("mail incoming on extraVirtualAlias"): + client.succeed("${sendMail "normal" "" "extra-alias@example.com" '' + Subject: extraVirtualAliases-Test + + Hello User4, + this is mail is sent to you by using an extraVirtualAlias as recipient. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "extra-alias"} >&2") + ''; + } From 5f49caec49f769888db5343055cf09060485943e Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 28 Dec 2024 00:40:32 +0100 Subject: [PATCH 33/61] Add configuration option to alias entire domains and respective tests --- mailsystem/default.nix | 16 ++++++++++++++++ mailsystem/postfix.nix | 20 +++++++++++++++++++- tests/aliases.nix | 37 ++++++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/mailsystem/default.nix b/mailsystem/default.nix index 8da5133..aadfaad 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -127,6 +127,22 @@ in { default = {}; }; + virtualDomainAliases = lib.mkOption { + type = with lib.types; attrsOf str; + example = { + "aliasdomain.com" = "domain.com"; + }; + description = '' + Virtual aliasing of domains. A virtual alias `"aliasdomain.com" = "domain.com"` + means that all mail directed at `@aliasdomain.com` are forwarded to `@domain.com`. + This also entails, that any account or alias of `domain.com` is partially valid + for `aliasdomain.com`. For example, `user@domain.com` can receive mails at + `user@aliasdomain.com`. However, if `user@domain.com` shall be able to dispatch + mails using `user@aliasdomain.com`, an explicit alias needs to be configured. + ''; + default = {}; + }; + extraVirtualAliases = lib.mkOption { type = let account = lib.mkOptionType { diff --git a/mailsystem/postfix.nix b/mailsystem/postfix.nix index f0afb8d..3328aca 100644 --- a/mailsystem/postfix.nix +++ b/mailsystem/postfix.nix @@ -28,9 +28,18 @@ with (import ./common.nix {inherit config;}); let map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name)) cfg.accounts)); + virtual_domain_aliases = let + alias_domains = + lib.concatMapAttrs (src: dst: { + "@${src}" = "@${dst}"; + }) + cfg.virtualDomainAliases; + in + attrsToLookupTable alias_domains; + extra_virtual_aliases = attrsToLookupTable cfg.extraVirtualAliases; - all_virtual_aliases = mergeLookupTables [account_virtual_aliases extra_virtual_aliases]; + all_virtual_aliases = mergeLookupTables [account_virtual_aliases virtual_domain_aliases extra_virtual_aliases]; aliases_file = let content = lookupTableToString all_virtual_aliases; @@ -65,6 +74,15 @@ with (import ./common.nix {inherit config;}); let tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL"; in { config = lib.mkIf cfg.enable { + assertions = + lib.mapAttrsToList ( + src: dst: { + assertion = (builtins.elem src cfg.domains) && (builtins.elem dst cfg.domains); + message = "Both aliased domain (${src}) and actual domain (${dst}) need to be managed by the mailserver."; + } + ) + cfg.virtualDomainAliases; + services.postfix = { enable = true; hostname = "${cfg.reverseFqdn}"; diff --git a/tests/aliases.nix b/tests/aliases.nix index e35124a..cd3aae3 100644 --- a/tests/aliases.nix +++ b/tests/aliases.nix @@ -48,8 +48,11 @@ in environment.systemPackages = with pkgs; [netcat]; mailsystem = { fqdn = "mail.example.com"; - domains = ["example.com" "otherdomain.com"]; + domains = ["example.com" "aliased.com" "otherdomain.com"]; accounts = mkAccounts accounts; + virtualDomainAliases = { + "aliased.com" = "example.com"; + }; extraVirtualAliases = { "extra-alias@example.com" = accounts."extra-alias".address; }; @@ -191,5 +194,37 @@ in client.execute("${cleanupMail}") # fetchmail returns EXIT_CODE 0 when it retrieves mail client.succeed("${recvMail "extra-alias"} >&2") + + with subtest("receiving mail on aliased domain using normal account"): + client.succeed("${sendMail "normal" "" "user2@aliased.com" '' + Subject: aliasedDomain with normal account + + Hello User2, + this is mail is sent to you by using your address @example.org. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "normal2"} >&2") + + with subtest("receiving mail on aliased domain using catchall-account"): + client.succeed("${sendMail "normal" "" "somerandomaddress@aliased.com" '' + Subject: aliasedDomain using catchall-account + + Hello Catchall-User, + this is mail is sent to you by using an address without any user-account behind it for neither @example.com nor @aliased.com. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "catchall"} >&2") + + with subtest("sending mail from aliased domain fails"): + client.fail("${sendMail "normal" "user1@aliased.com" accounts."normal2".address '' + Subject: aliasedDomain + + Hello User2, + this mail should not be dispatched to you as I'm using "my" address @aliased.com. + ''}") ''; } From e6e91b775a7f38460a9a6a9d2d0c4989110c2223 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 28 Dec 2024 14:55:55 +0100 Subject: [PATCH 34/61] Disable roundcube and rspamd webui by default --- mailsystem/roundcube.nix | 2 +- mailsystem/rspamd.nix | 2 +- tests/common/server.nix | 3 --- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/mailsystem/roundcube.nix b/mailsystem/roundcube.nix index bebffaf..1960785 100644 --- a/mailsystem/roundcube.nix +++ b/mailsystem/roundcube.nix @@ -11,7 +11,7 @@ in { options.mailsystem.roundcube = { enable = lib.mkOption { type = lib.types.bool; - default = true; + default = false; description = "Whether to enable roundcube in order to provide a webmail interface"; }; hostName = lib.mkOption { diff --git a/mailsystem/rspamd.nix b/mailsystem/rspamd.nix index 55079ed..093be1e 100644 --- a/mailsystem/rspamd.nix +++ b/mailsystem/rspamd.nix @@ -28,7 +28,7 @@ in { options.mailsystem.rspamd.webUi = { enable = lib.mkOption { type = lib.types.bool; - default = true; + default = false; description = "Whether to enable the rspamd webui on `https://${config.mailsystem.fqdn}/rspamd`"; }; diff --git a/tests/common/server.nix b/tests/common/server.nix index 9a47284..ff60d5d 100644 --- a/tests/common/server.nix +++ b/tests/common/server.nix @@ -4,9 +4,6 @@ virtualisation.memorySize = 1024; mailsystem = { enable = true; - - roundcube.enable = false; - rspamd.webUi.enable = false; certificateScheme = "selfsigned"; }; }; From 53e2b9f6211fc801338685fc53ebad05da641449 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 28 Dec 2024 16:49:48 +0100 Subject: [PATCH 35/61] mailsystem: nginx: Replace incorrect usage of lib.mkIf with lib.optionalAttrs Evaluation of lib.mkIf and lib.optionalAttrs is slightly different. In this specific case, the usage of lib.mkIf resulted in the defined virtualHost never actually being applied due to an earlier error in the evaluation order. --- mailsystem/nginx.nix | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/mailsystem/nginx.nix b/mailsystem/nginx.nix index 03e8f26..c4f01a0 100644 --- a/mailsystem/nginx.nix +++ b/mailsystem/nginx.nix @@ -11,12 +11,15 @@ in { lib.mkIf cfg.enable { services.nginx = { enable = true; - virtualHosts."${cfg.fqdn}" = { - forceSSL = true; - enableACME = cfg.certificateScheme == "acme"; - sslCertificate = lib.mkIf (cfg.certificateScheme == "selfsigned") sslCertPath; - sslCertificateKey = lib.mkIf (cfg.certificateScheme == "selfsigned") sslKeyPath; - }; + virtualHosts."${cfg.fqdn}" = + { + forceSSL = true; + enableACME = cfg.certificateScheme == "acme"; + } + // lib.optionalAttrs (cfg.certificateScheme == "selfsigned") { + sslCertificate = sslCertPath; + sslCertificateKey = sslKeyPath; + }; }; networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443]; From 35aeb19b24060289490db0b58e4a621cbb8193d0 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 28 Dec 2024 19:10:15 +0100 Subject: [PATCH 36/61] mailsystem: roundcube: Use imaps and submissions instead of disabled starttls variant --- mailsystem/roundcube.nix | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mailsystem/roundcube.nix b/mailsystem/roundcube.nix index 1960785..ba04636 100644 --- a/mailsystem/roundcube.nix +++ b/mailsystem/roundcube.nix @@ -32,8 +32,9 @@ in { hostName = roundcubeCfg.hostName; plugins = ["managesieve" "password"]; extraConfig = '' - // Use starttls for authentication - $config['smtp_host'] = "tls://${cfg.fqdn}"; + // Use implicitly encrypted communications for imap and imap (implicit tls) + $config['imap_host'] = "ssl://${cfg.fqdn}"; + $config['smtp_host'] = "ssl://${cfg.fqdn}"; $config['smtp_user'] = "%u"; $config['smtp_pass'] = "%p"; From 1b26a41aafab37013549865259256fcd743d1bf3 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 28 Dec 2024 20:20:02 +0100 Subject: [PATCH 37/61] mailsystem: rspamd: Ensure proxy headers for webui are configured --- mailsystem/rspamd.nix | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mailsystem/rspamd.nix b/mailsystem/rspamd.nix index 093be1e..7f2a147 100644 --- a/mailsystem/rspamd.nix +++ b/mailsystem/rspamd.nix @@ -120,6 +120,10 @@ in { locations."/rspamd" = { proxyPass = "http://unix:${rspamdControllerSocket}:/"; basicAuthFile = cfg.rspamd.webUi.basicAuthFile; + extraConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + ''; }; sslCertificate = lib.mkIf (cfg.certificateScheme == "selfsigned") sslCertPath; sslCertificateKey = lib.mkIf (cfg.certificateScheme == "selfsigned") sslKeyPath; From cbdbb945129873c4ab56cc4e36c38202e89c879e Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 28 Dec 2024 22:32:41 +0100 Subject: [PATCH 38/61] mailsystem: dovecot: Grant roundcube user access to dynamic passwd file --- mailsystem/dovecot.nix | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mailsystem/dovecot.nix b/mailsystem/dovecot.nix index 4f17464..ac9ea3e 100644 --- a/mailsystem/dovecot.nix +++ b/mailsystem/dovecot.nix @@ -77,6 +77,11 @@ with (import ./common.nix {inherit config;}); let done mv "${dovecotDynamicPasswdFile}-filtered" "${dovecotDynamicPasswdFile}" + ${lib.optionalString cfg.roundcube.enable '' + # Ensure roundcube has access to dynamic passwd file + ${pkgs.acl.bin}/bin/setfacl -m "u:${config.services.phpfpm.pools.roundcube.user}:rw" "${dovecotDynamicPasswdFile}" + ''} + # Prepare userdb-file cat < "${userdbFile}" ${lib.concatStringsSep "\n" (lib.mapAttrsToList genUserdbEntry cfg.accounts)} From 88d2b387c70fe43c5bc75aeb7fbd16ab392e20b8 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 28 Dec 2024 22:42:00 +0100 Subject: [PATCH 39/61] mailsystem: roundcube: Fix mixup with dovecotpw and passwdfile --- mailsystem/roundcube.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mailsystem/roundcube.nix b/mailsystem/roundcube.nix index ba04636..b05fb25 100644 --- a/mailsystem/roundcube.nix +++ b/mailsystem/roundcube.nix @@ -47,8 +47,8 @@ in { // Enables saving the new password even if it machtes the old password. Useful // for upgrading the stored passwords after the encryption scheme has changed. $config['password_force_save'] = true; - $config['password_dovecot_passwdfile_path'] = "${pkgs.dovecot}/bin/doveadm pw"; - $config['password_dovecotpw'] = "${dovecotDynamicPasswdFile}"; + $config['password_dovecot_passwdfile_path'] = "${dovecotDynamicPasswdFile}"; + $config['password_dovecotpw'] = "${pkgs.dovecot}/bin/doveadm pw"; $config['password_dovecotpw_method'] = "${roundcubeCfg.passwordHashingAlgorithm}"; $config['password_dovecotpw_with_method'] = true; ''; From de330a87a44f1a4e4425965d2822cae7e7dabbaf Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sun, 29 Dec 2024 01:27:00 +0100 Subject: [PATCH 40/61] mailsystem: Add configuration options for dkim signatures --- mailsystem/default.nix | 39 ++++++++++++++++++++++++++++ mailsystem/rspamd.nix | 59 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/mailsystem/default.nix b/mailsystem/default.nix index aadfaad..c8eb8c4 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -168,6 +168,45 @@ in { default = {}; }; + dkimSettings = lib.mkOption { + type = with lib.types; + attrsOf (listOf (submodule { + options = { + selector = lib.mkOption { + type = lib.types.str; + example = "mail"; + description = "DKIM Selector"; + }; + keyFile = lib.mkOption { + type = lib.types.path; + example = "/run/secrets/dkim/example.com.mail.key"; + description = '' + Path to DKIM private-key-file. A public-private-pair can be generated as follows: + + ``` + nix-shell -p rspamd --run 'rspamadm dkim_keygen -s "selector" -t ed25519 -d example.com + nix-shell -p rspamd --run 'rspamadm dkim_keygen -s "selector" -b 2048 -d example.com + ``` + ''; + }; + }; + })); + example = { + "example.com" = [ + { + selector = "mail"; + keyFile = "/run/secrets/dkim/example.com.mail.key"; + } + ]; + }; + description = '' + Per-domain DKIM configuration. + This option allows to optionally set one or more DKIM private keys + and their respective selectors for each domain individually. + ''; + default = {}; + }; + certificateScheme = lib.mkOption { type = lib.types.enum ["acme" "selfsigned"]; default = "acme"; diff --git a/mailsystem/rspamd.nix b/mailsystem/rspamd.nix index 7f2a147..847aba4 100644 --- a/mailsystem/rspamd.nix +++ b/mailsystem/rspamd.nix @@ -39,12 +39,27 @@ in { }; config = lib.mkIf cfg.enable { - assertions = [ - { - assertion = !cfg.rspamd.webUi.enable || cfg.rspamd.webUi.basicAuthFile != null; - message = "Setting basicAuthFile is required if rspamd's web interface is enabled"; - } - ]; + assertions = + [ + { + assertion = !cfg.rspamd.webUi.enable || cfg.rspamd.webUi.basicAuthFile != null; + message = "Setting basicAuthFile is required if rspamd's web interface is enabled"; + } + ] + ++ lib.mapAttrsToList ( + domain: dkimList: { + assertion = builtins.elem domain cfg.domains; + message = "Domain ${domain} as per `config.mailsystem.dkimSettings` needs to be managed by the mailserver."; + } + ) + cfg.dkimSettings + ++ lib.mapAttrsToList ( + domain: dkimList: { + assertion = dkimList != []; + message = "Entry ${domain} as per `config.mailsystem.dkimSettings` must not be an empty list."; + } + ) + cfg.dkimSettings; services.rspamd = { enable = true; @@ -57,6 +72,38 @@ in { } ''; }; + "dkim_signing.conf" = let + genDkimSelectorList = entry: '' + { + path: "${entry.keyFile}"; + selector: "${entry.selector}"; + } + ''; + genDkimDomainCfg = domain: domainSettings: '' + ${domain} { + selectors [ + ${lib.concatStringsSep "\n" (map genDkimSelectorList domainSettings)} + ] + } + ''; + in { + text = + '' + sign_authenticated = true; + use_esld = true; + use_domain = "header"; + check_pubkey = true; + allow_username_mismatch = true; + allow_hdrfrom_mismatch = true; + allow_hdrfrom_mismatch_sign_networks = true; + + '' + + lib.optionalString (cfg.dkimSettings != {}) '' + domain { + ${lib.concatStringsSep "\n" (lib.mapAttrsToList genDkimDomainCfg cfg.dkimSettings)} + } + ''; + }; "milter_headers.conf" = { text = '' # Add headers related to spam-detection From 55183f5585a497d6b219ba6a14c6f8021e3d73c3 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sun, 29 Dec 2024 15:47:28 +0100 Subject: [PATCH 41/61] tests: Add tests for rspamd-related functionality --- flake.nix | 2 +- tests/aliases.nix | 1 - tests/rspamd.nix | 175 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 tests/rspamd.nix diff --git a/flake.nix b/flake.nix index 3774a92..abac9b8 100644 --- a/flake.nix +++ b/flake.nix @@ -36,7 +36,7 @@ ... }: { checks = let - tests = ["internal" "basic" "aliases"]; + tests = ["internal" "basic" "aliases" "rspamd"]; genTest = testName: { "name" = testName; "value" = import (./tests + "/${testName}.nix") {inherit pkgs;}; diff --git a/tests/aliases.nix b/tests/aliases.nix index cd3aae3..842991d 100644 --- a/tests/aliases.nix +++ b/tests/aliases.nix @@ -71,7 +71,6 @@ in }; sendMail = mkSendMail smtpSettings accounts; recvMail = mkRecvMail serverAddr accounts; - cfg = nodes.server.mailsystem; in '' start_all() diff --git a/tests/rspamd.nix b/tests/rspamd.nix new file mode 100644 index 0000000..ef8e6ea --- /dev/null +++ b/tests/rspamd.nix @@ -0,0 +1,175 @@ +{pkgs, ...}: +with (import ./common/lib.nix {inherit pkgs;}); let + lib = pkgs.lib; + accounts = { + "normal" = { + address = "user@example.com"; + password = "secret-password1"; + }; + "normal2" = { + address = "user@example.org"; + password = "secret-password2;"; + }; + }; + genDkimSecret = domain: name: type: + pkgs.runCommand "mk-dkim-secrets-${domain}-${selector}" { + buildInputs = [pkgs.rspamd]; + inherit domain name type; + } '' + rspamadm dkim_keygen -d $domain -s $name -t $type ${lib.optionalString (type == "rsa") "-b 2048"} -k $out + ''; + + mkDkimSettings = domains: selectors: + lib.listToAttrs ( + map (domain: + lib.nameValuePair domain (map (entry: { + selector = entry.name; + keyFile = genDkimSecret domain entry.name entry.type; + }) + selectors)) + domains + ); +in + pkgs.nixosTest { + name = "rspamd"; + nodes = { + server = {pkgs, ...}: { + imports = [./common/server.nix]; + mailsystem = { + fqdn = "mail.example.com"; + domains = ["example.com" "example.org"]; + accounts = mkAccounts accounts; + dkimSettings = mkDkimSettings ["example.com" "example.org"] [ + { + name = "elliptic"; + type = "ed25519"; + } + { + name = "selector"; + type = "rsa"; + } + ]; + }; + }; + client = {...}: { + imports = [./common/client.nix]; + }; + }; + testScript = {nodes, ...}: let + cfg = nodes.server.mailsystem; + serverAddr = nodes.server.networking.primaryIPAddress; + clientAddr = nodes.client.networking.primaryIPAddress; + smtpSettings = { + address = serverAddr; + port = 465; + }; + sendMail = mkSendMail smtpSettings accounts; + recvMail = mkRecvMail serverAddr accounts; + test-mark-spam = accountName: + pkgs.writeScript "imap-mark-spam" '' + #!${pkgs.python3.interpreter} + import imaplib + + with imaplib.IMAP4_SSL('${serverAddr}') as imap: + imap.login('${accounts."${accountName}".address}', '${accounts."${accountName}".password}') + imap.select() + status, [response] = imap.search(None, 'ALL') + msg_ids = response.decode("utf-8").split(' ') + print(msg_ids) + assert status == 'OK' + assert len(msg_ids) == 1 + + imap.copy(','.join(msg_ids), 'Junk') + for num in msg_ids: + imap.store(num, '+FLAGS', '\\Deleted') + imap.expunge() + + imap.select('Junk') + status, [response] = imap.search(None, 'ALL') + msg_ids = response.decode("utf-8").split(' ') + print(msg_ids) + assert status == 'OK' + assert len(msg_ids) == 1 + + imap.close() + ''; + test-mark-ham = accountName: + pkgs.writeScript "imap-mark-ham" '' + #!${pkgs.python3.interpreter} + import imaplib + + with imaplib.IMAP4_SSL('${serverAddr}') as imap: + imap.login('${accounts."${accountName}".address}', '${accounts."${accountName}".password}') + imap.select('Junk') + status, [response] = imap.search(None, 'ALL') + msg_ids = response.decode("utf-8").split(' ') + print(msg_ids) + assert status == 'OK' + assert len(msg_ids) == 1 + + imap.copy(','.join(msg_ids), 'INBOX') + for num in msg_ids: + imap.store(num, '+FLAGS', '\\Deleted') + imap.expunge() + + imap.select('INBOX') + status, [response] = imap.search(None, 'ALL') + msg_ids = response.decode("utf-8").split(' ') + print(msg_ids) + assert status == 'OK' + assert len(msg_ids) == 1 + + imap.close() + ''; + in '' + start_all() + + server.wait_for_unit("multi-user.target") + client.wait_for_unit("multi-user.target") + server.wait_until_succeeds("${waitForRspamd nodes.server}") + + with subtest("rspamd configuration is valid"): + server.succeed("${pkgs.rspamd}/bin/rspamadm configtest >&2") + + with subtest("rspamd rejects spam"): + client.fail("${sendMail "normal" "" accounts."normal2".address '' + Subject: GTUBE-Test + + Hello User2, + this is a mail containing a GTUBE pattern that should result in the rejection of this mail. + + XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X + ''}") + + with subtest("imap sieve junk trainer"): + client.succeed("${sendMail "normal" "" accounts."normal2".address '' + Subject: Testmail + + Hello User2, + this is a testmail. + ''}") + server.wait_until_fails('${pendingPostqueue}') + + client.succeed("${test-mark-spam "normal2"} >&2") + server.wait_until_succeeds("journalctl -u dovecot2 | grep -i learn-spam.sh >&2") + client.succeed("${test-mark-ham "normal2"} >&2") + server.wait_until_succeeds("journalctl -u dovecot2 | grep -i learn-ham.sh >&2") + + with subtest("dkim signing"): + client.succeed("${sendMail "normal2" "" accounts."normal".address '' + Subject: Testmail + + Hello User1, + this is also a testmail. + ''}") + server.wait_until_fails('${pendingPostqueue}') + client.execute("${cleanupMail}") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("${recvMail "normal"} >&2") + + client.succeed("cat ~/mail/* >&2") + # make sure the mail has all configured dkim signatures + client.succeed("grep ${(builtins.elemAt cfg.dkimSettings."example.com" 0).selector} ~/mail/*") + client.succeed("grep ${(builtins.elemAt cfg.dkimSettings."example.com" 1).selector} ~/mail/*") + ''; + } From 30532bbfcac94423fd05bd1203aa8e3193cdf6be Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 2 Jan 2025 23:06:14 +0100 Subject: [PATCH 42/61] Actually reject mails sent to system accounts and add respective testcase --- mailsystem/default.nix | 10 ++++++++++ mailsystem/postfix.nix | 7 +++++++ tests/basic.nix | 13 +++++++++++++ 3 files changed, 30 insertions(+) diff --git a/mailsystem/default.nix b/mailsystem/default.nix index c8eb8c4..7d6231f 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -112,7 +112,17 @@ in { account will be rejected. ''; }; + + rejectMessage = lib.mkOption { + type = lib.types.str; + default = "This account cannot receive emails."; + description = '' + The message that will be returned to the sender when an email is + sent to a system account. + ''; + }; }; + config.name = lib.mkDefault name; })); example = { diff --git a/mailsystem/postfix.nix b/mailsystem/postfix.nix index 3328aca..708ad31 100644 --- a/mailsystem/postfix.nix +++ b/mailsystem/postfix.nix @@ -54,6 +54,9 @@ with (import ./common.nix {inherit config;}); let virtual_domains_file = builtins.toFile "virtual_domains" (lib.concatStringsSep "\n" cfg.domains); + denied_recipients = map (account: "${account.name} REJECT ${account.rejectMessage}") (lib.filter (account: account.isSystemUser) (lib.attrValues cfg.accounts)); + denied_recipients_file = builtins.toFile "denied_recipients" (lib.concatStringsSep "\n" denied_recipients); + submission_header_cleanup_rules = pkgs.writeText "submission_header_cleanup_rules" '' # Removes sensitive headers from mails handed in via the submission port. # See https://thomas-leister.de/mailserver-debian-stretch/ @@ -96,6 +99,7 @@ in { # TODO: create function to simplify this? mapFiles."virtual_aliases" = aliases_file; mapFiles."virtual_accounts" = virtual_accounts_file; + mapFiles."denied_recipients" = denied_recipients_file; virtual = lookupTableToString all_virtual_aliases; submissionsOptions = { @@ -140,6 +144,9 @@ in { "permit_sasl_authenticated" "reject_unauth_destination" ]; + smtpd_recipient_restrictions = [ + "check_recipient_access ${mappedFile "denied_recipients"}" + ]; # TLS settings, inspired by https://github.com/jeaye/nix-files # Submission by mail clients is handled in submissionOptions diff --git a/tests/basic.nix b/tests/basic.nix index 615d2a1..70bd13f 100644 --- a/tests/basic.nix +++ b/tests/basic.nix @@ -9,6 +9,11 @@ with (import ./common/lib.nix {inherit pkgs;}); let address = "user2@example.com"; password = "secret-password2"; }; + "system" = { + address = "system@example.com"; + password = "secret-password3"; + isSystemUser = true; + }; }; in pkgs.nixosTest { @@ -77,6 +82,14 @@ in I'm pretending to be someotheraddress@example.com and the mailserver should reject this attempt. ''}") + with subtest("mail sent to system-account is rejected"): + client.fail("${sendMail "normal" "someotheraddress@example.com" accounts."system".address '' + Subject: Mail to system-account + + Hello System user, + this mail should never reach you as it should be rejected by postfix. + ''}") + with subtest("server issues no warnings nor errors"): ${checkLogs "server"} ''; From 28796695afce5d02b8164af5a9189a16dcff8c67 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 2 Jan 2025 23:09:06 +0100 Subject: [PATCH 43/61] Add github actions workflow for CI tests --- .github/workflows/test.yml | 17 +++++++++++++++++ flake.nix | 1 + 2 files changed, 18 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2d29d94 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: "Test" +on: + pull_request: + push: + branches: + - main + schedule: + - cron: '42 7 * * *' +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Nix + uses: cachix/install-nix-action@v30 + - name: Execute unit tests + run: nix flake check diff --git a/flake.nix b/flake.nix index abac9b8..476a7d4 100644 --- a/flake.nix +++ b/flake.nix @@ -59,6 +59,7 @@ treefmt = { programs = { + actionlint.enable = true; alejandra.enable = true; }; settings.global.excludes = [ From faf6f549b0be2b1f2971ddce0bf7f6571d8f921e Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Mon, 6 Jan 2025 23:29:40 +0100 Subject: [PATCH 44/61] Remove accounts..aliases and rename extraVirtualAliases to virtualAccountAliases In order to simplify configuration and reduce configuration variability, this commit removes the option to directly add aliases to each single mail account. Instead, aliaes should be centrally configured using the option 'virtualAccountAliases'. --- mailsystem/default.nix | 13 +------------ mailsystem/postfix.nix | 18 ++++-------------- tests/aliases.nix | 37 ++++++++++--------------------------- 3 files changed, 15 insertions(+), 53 deletions(-) diff --git a/mailsystem/default.nix b/mailsystem/default.nix index 7d6231f..2dc96d9 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -92,17 +92,6 @@ in { ''; }; - aliases = lib.mkOption { - type = with lib.types; listOf types.str; - example = ["abuse@example.com" "postmaster@example.com"]; - default = []; - description = '' - A list of aliases of this login account. - Note: Use list entries like "@example.com" to create a catchAll - that allows sending from all email addresses in these domain. - ''; - }; - isSystemUser = lib.mkOption { type = lib.types.bool; default = false; @@ -153,7 +142,7 @@ in { default = {}; }; - extraVirtualAliases = lib.mkOption { + virtualAccountAliases = lib.mkOption { type = let account = lib.mkOptionType { name = "Mail Account"; diff --git a/mailsystem/postfix.nix b/mailsystem/postfix.nix index 708ad31..3549afe 100644 --- a/mailsystem/postfix.nix +++ b/mailsystem/postfix.nix @@ -21,25 +21,15 @@ with (import ./common.nix {inherit config;}); let mergeLookupTables = tables: lib.zipAttrsWith (n: v: lib.flatten v) tables; - account_virtual_aliases = mergeLookupTables (lib.flatten (lib.mapAttrsToList - (name: value: let - to = name; - in - map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name)) - cfg.accounts)); + virtual_accounts = mergeLookupTables (lib.map (name: {"${name}" = name;}) (lib.attrNames cfg.accounts)); + virtual_account_aliases = attrsToLookupTable cfg.virtualAccountAliases; virtual_domain_aliases = let - alias_domains = - lib.concatMapAttrs (src: dst: { - "@${src}" = "@${dst}"; - }) - cfg.virtualDomainAliases; + alias_domains = lib.mapAttrs' (src: dst: lib.nameValuePair "@${src}" "@${dst}") cfg.virtualDomainAliases; in attrsToLookupTable alias_domains; - extra_virtual_aliases = attrsToLookupTable cfg.extraVirtualAliases; - - all_virtual_aliases = mergeLookupTables [account_virtual_aliases virtual_domain_aliases extra_virtual_aliases]; + all_virtual_aliases = mergeLookupTables [virtual_accounts virtual_account_aliases virtual_domain_aliases]; aliases_file = let content = lookupTableToString all_virtual_aliases; diff --git a/tests/aliases.nix b/tests/aliases.nix index 842991d..ba063e1 100644 --- a/tests/aliases.nix +++ b/tests/aliases.nix @@ -1,5 +1,6 @@ {pkgs, ...}: with (import ./common/lib.nix {inherit pkgs;}); let + lib = pkgs.lib; accounts = { "normal" = { address = "user1@example.com"; @@ -11,32 +12,23 @@ with (import ./common/lib.nix {inherit pkgs;}); let }; "alias" = { address = "user3@example.com"; - aliases = ["alias@example.com"]; password = "secret-password3"; }; - "extra-alias" = { - address = "user4@example.com"; - password = "secret-password4;"; - }; "multi-alias1" = { address = "multi-alias1@example.com"; - aliases = ["multi-alias@example.com"]; - password = "secret-password5;"; + password = "secret-password4;"; }; "multi-alias2" = { address = "multi-alias2@example.com"; - aliases = ["multi-alias@example.com"]; - password = "secret-password6;"; + password = "secret-password5;"; }; "catchall" = { address = "catchall@example.com"; - aliases = ["@example.com"]; - password = "secret-password7;"; + password = "secret-password6;"; }; "otherdomain" = { address = "otherdomain@example.com"; - aliases = ["user@otherdomain.com"]; - password = "secret-password8;"; + password = "secret-password7;"; }; }; in @@ -53,8 +45,11 @@ in virtualDomainAliases = { "aliased.com" = "example.com"; }; - extraVirtualAliases = { - "extra-alias@example.com" = accounts."extra-alias".address; + virtualAccountAliases = { + "alias@example.com" = accounts."alias".address; + "multi-alias@example.com" = lib.map (x: accounts.${x}.address) ["multi-alias1" "multi-alias2"]; + "@example.com" = accounts."catchall".address; + "user@otherdomain.com" = accounts."otherdomain".address; }; }; }; @@ -182,18 +177,6 @@ in # fetchmail returns EXIT_CODE 0 when it retrieves mail client.succeed("${recvMail "otherdomain"} >&2") - with subtest("mail incoming on extraVirtualAlias"): - client.succeed("${sendMail "normal" "" "extra-alias@example.com" '' - Subject: extraVirtualAliases-Test - - Hello User4, - this is mail is sent to you by using an extraVirtualAlias as recipient. - ''}") - server.wait_until_fails('${pendingPostqueue}') - client.execute("${cleanupMail}") - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.succeed("${recvMail "extra-alias"} >&2") - with subtest("receiving mail on aliased domain using normal account"): client.succeed("${sendMail "normal" "" "user2@aliased.com" '' Subject: aliasedDomain with normal account From a1e87f70fa60525127b79f37956b0e437b617bce Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 22 Feb 2025 19:52:13 +0100 Subject: [PATCH 45/61] Merge options virtualAccountAliases and virtualDomainAliases into virtualAliases --- mailsystem/default.nix | 50 +++++++++++++++++++++--------------------- mailsystem/postfix.nix | 48 +++++++++++++++++----------------------- tests/aliases.nix | 8 +++---- 3 files changed, 49 insertions(+), 57 deletions(-) diff --git a/mailsystem/default.nix b/mailsystem/default.nix index 2dc96d9..cae1a62 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -126,43 +126,43 @@ in { default = {}; }; - virtualDomainAliases = lib.mkOption { - type = with lib.types; attrsOf str; - example = { - "aliasdomain.com" = "domain.com"; - }; - description = '' - Virtual aliasing of domains. A virtual alias `"aliasdomain.com" = "domain.com"` - means that all mail directed at `@aliasdomain.com` are forwarded to `@domain.com`. - This also entails, that any account or alias of `domain.com` is partially valid - for `aliasdomain.com`. For example, `user@domain.com` can receive mails at - `user@aliasdomain.com`. However, if `user@domain.com` shall be able to dispatch - mails using `user@aliasdomain.com`, an explicit alias needs to be configured. - ''; - default = {}; - }; - - virtualAccountAliases = lib.mkOption { + virtualAliases = lib.mkOption { type = let + isAccount = value: builtins.elem value (builtins.attrNames cfg.accounts); + isDomain = value: !(lib.hasInfix "@" value) && (builtins.elem value cfg.domains); account = lib.mkOptionType { name = "Mail Account"; - check = account: builtins.elem account (builtins.attrNames cfg.accounts); + check = isAccount; + }; + accountOrDomain = lib.mkOptionType { + name = "Mail Account or Domain"; + check = value: (isAccount value) || (isDomain value); }; in - with lib.types; attrsOf (either account (nonEmptyListOf account)); + with lib.types; attrsOf (either (nonEmptyListOf account) accountOrDomain); example = { "info@example.com" = "user1@example.com"; "postmaster@example.com" = "user1@example.com"; "abuse@example.com" = "user1@example.com"; "multi@example.com" = ["user1@example.com" "user2@example.com"]; + "aliasdomain.com" = "domain.com"; }; description = '' - Virtual account aliases. A virtual alias `"info@example.com" = "user1@example.com"` - means that all mail to `info@example.com` is forwarded to `user1@example.com`. - Furthermore, it also allows the user `user1@example.com` to send emails as - `info@example.com`. It is also possible to create an alias for multiple accounts. - In this example, all mails for `multi@example.com` will be forwarded to both - `user1@example.com` and `user2@example.com`. + Virtual account and domain aliases. A virtual alias means, that all mail directed + at a given target are forwarded to the specified other destinations, too. + + For account aliases, this means that, e.g., `"user1@example.com"` receives all mail + sent to `"info@example.com"`. In addition, `"user1@example.com"` is also able to + impersonate `"info@example.com"` when sending mails. It is also possible to create + an alias for multiple accounts. In this example, all mails for `"multi@example.com"` + will be forwarded to both + `"user1@example.com"` and `"user2@example.com"`. + + For domain aliases, this means that all mails directed at an aliased domain, e.g., + `"aliasdomain.com"` are forwarded to `"domain.com"` This also entails, that any + account or alias of `"domain.com"` receives mails directed at `"aliasdomain.com"`. + However, if `"user@domain.com"` shall be able to send mails using + `"user@aliasdomain.com"`, an explicit alias needs to be configured. ''; default = {}; }; diff --git a/mailsystem/postfix.nix b/mailsystem/postfix.nix index 3549afe..e5ebbfa 100644 --- a/mailsystem/postfix.nix +++ b/mailsystem/postfix.nix @@ -15,33 +15,27 @@ with (import ./common.nix {inherit config;}); let mergeLookupTables lookupTables; lookupTableToString = attrs: let - valueToString = value: lib.concatStringsSep ", " value; + isDomain = value: !(lib.hasInfix "@" value); + valueToString = value: + if (isDomain value) + then "@${value}" + else value; + listToString = list: lib.concatStringsSep ", " (map valueToString list); in - lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs); + lib.concatStringsSep "\n" (lib.mapAttrsToList (name: list: "${valueToString name} ${listToString list}") attrs); mergeLookupTables = tables: lib.zipAttrsWith (n: v: lib.flatten v) tables; virtual_accounts = mergeLookupTables (lib.map (name: {"${name}" = name;}) (lib.attrNames cfg.accounts)); - virtual_account_aliases = attrsToLookupTable cfg.virtualAccountAliases; - - virtual_domain_aliases = let - alias_domains = lib.mapAttrs' (src: dst: lib.nameValuePair "@${src}" "@${dst}") cfg.virtualDomainAliases; - in - attrsToLookupTable alias_domains; - - all_virtual_aliases = mergeLookupTables [virtual_accounts virtual_account_aliases virtual_domain_aliases]; + virtual_aliases = attrsToLookupTable cfg.virtualAliases; + all_virtual_aliases = mergeLookupTables [virtual_accounts virtual_aliases]; + # File containing all mappings of aliases/authenticated accounts and their sender mail addresses. aliases_file = let content = lookupTableToString all_virtual_aliases; in builtins.toFile "virtual_aliases" content; - # File containing all mappings of authenticated accounts and their sender mail addresses. - virtual_accounts_file = let - content = lookupTableToString all_virtual_aliases; - in - builtins.toFile "virtual_accounts" content; - virtual_domains_file = builtins.toFile "virtual_domains" (lib.concatStringsSep "\n" cfg.domains); denied_recipients = map (account: "${account.name} REJECT ${account.rejectMessage}") (lib.filter (account: account.isSystemUser) (lib.attrValues cfg.accounts)); @@ -67,14 +61,15 @@ with (import ./common.nix {inherit config;}); let tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL"; in { config = lib.mkIf cfg.enable { - assertions = - lib.mapAttrsToList ( - src: dst: { - assertion = (builtins.elem src cfg.domains) && (builtins.elem dst cfg.domains); - message = "Both aliased domain (${src}) and actual domain (${dst}) need to be managed by the mailserver."; - } - ) - cfg.virtualDomainAliases; + assertions = let + isDomain = value: !lib.hasInfix "@" value; + aliasedDomains = builtins.filter isDomain (builtins.attrNames cfg.virtualAliases); + in + map (domain: { + assertion = builtins.elem domain cfg.domains; + message = "The domain to be aliased (${domain}) must be managed by the mailserver."; + }) + aliasedDomains; services.postfix = { enable = true; @@ -86,9 +81,7 @@ in { enableSubmissions = true; - # TODO: create function to simplify this? mapFiles."virtual_aliases" = aliases_file; - mapFiles."virtual_accounts" = virtual_accounts_file; mapFiles."denied_recipients" = denied_recipients_file; virtual = lookupTableToString all_virtual_aliases; @@ -100,8 +93,7 @@ in { smtpd_sasl_security_options = "noanonymous"; smtpd_sasl_local_domain = "$myhostname"; smtpd_client_restrictions = "permit_sasl_authenticated,reject"; - # use mappedFile -> different path? - smtpd_sender_login_maps = "hash:/etc/postfix/virtual_accounts"; + smtpd_sender_login_maps = mappedFile "virtual_aliases"; smtpd_sender_restrictions = "reject_sender_login_mismatch"; smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject"; cleanup_service_name = "submission-header-cleanup"; diff --git a/tests/aliases.nix b/tests/aliases.nix index ba063e1..4db867d 100644 --- a/tests/aliases.nix +++ b/tests/aliases.nix @@ -42,13 +42,13 @@ in fqdn = "mail.example.com"; domains = ["example.com" "aliased.com" "otherdomain.com"]; accounts = mkAccounts accounts; - virtualDomainAliases = { + virtualAliases = { + # domain aliases "aliased.com" = "example.com"; - }; - virtualAccountAliases = { + # account aliases "alias@example.com" = accounts."alias".address; "multi-alias@example.com" = lib.map (x: accounts.${x}.address) ["multi-alias1" "multi-alias2"]; - "@example.com" = accounts."catchall".address; + "example.com" = accounts."catchall".address; "user@otherdomain.com" = accounts."otherdomain".address; }; }; From ce2784e17d0d7f189ef946dee0fcf5b060f35b13 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 22 Feb 2025 20:11:04 +0100 Subject: [PATCH 46/61] flake.lock: Update inputs --- flake.lock | 43 +++++++++++++------------------------------ 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/flake.lock b/flake.lock index 43c3d18..1f363dc 100644 --- a/flake.lock +++ b/flake.lock @@ -23,11 +23,11 @@ ] }, "locked": { - "lastModified": 1730504689, - "narHash": "sha256-hgmguH29K2fvs9szpq2r3pz2/8cJd2LPS+b4tfNFCwE=", + "lastModified": 1738453229, + "narHash": "sha256-7H9XgNiGLKN1G1CgRh0vUL4AheZSYzPm+zmZ7vxbJdo=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "506278e768c2a08bec68eb62932193e341f55c90", + "rev": "32ea77a06711b758da0ad9bd6a844c5740a87abd", "type": "github" }, "original": { @@ -42,15 +42,14 @@ "gitignore": "gitignore", "nixpkgs": [ "nixpkgs" - ], - "nixpkgs-stable": "nixpkgs-stable" + ] }, "locked": { - "lastModified": 1733318908, - "narHash": "sha256-SVQVsbafSM1dJ4fpgyBqLZ+Lft+jcQuMtEL3lQWx2Sk=", + "lastModified": 1737465171, + "narHash": "sha256-R10v2hoJRLq8jcL4syVFag7nIGE7m13qO48wRIukWNg=", "owner": "cachix", "repo": "git-hooks.nix", - "rev": "6f4e2a2112050951a314d2733a994fbab94864c6", + "rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17", "type": "github" }, "original": { @@ -82,11 +81,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1731755305, - "narHash": "sha256-v5P3dk5JdiT+4x69ZaB18B8+Rcu3TIOrcdG4uEX7WZ8=", + "lastModified": 1739923778, + "narHash": "sha256-BqUY8tz0AQ4to2Z4+uaKczh81zsGZSYxjgvtw+fvIfM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "057f63b6dc1a2c67301286152eb5af20747a9cb4", + "rev": "36864ed72f234b9540da4cf7a0c49e351d30d3f1", "type": "github" }, "original": { @@ -96,22 +95,6 @@ "type": "github" } }, - "nixpkgs-stable": { - "locked": { - "lastModified": 1730741070, - "narHash": "sha256-edm8WG19kWozJ/GqyYx2VjW99EdhjKwbY3ZwdlPAAlo=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "d063c1dd113c91ab27959ba540c0d9753409edf3", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-24.05", - "repo": "nixpkgs", - "type": "github" - } - }, "root": { "inputs": { "flake-parts": "flake-parts", @@ -127,11 +110,11 @@ ] }, "locked": { - "lastModified": 1733440889, - "narHash": "sha256-qKL3vjO+IXFQ0nTinFDqNq/sbbnnS5bMI1y0xX215fU=", + "lastModified": 1739829690, + "narHash": "sha256-mL1szCeIsjh6Khn3nH2cYtwO5YXG6gBiTw1A30iGeDU=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "50862ba6a8a0255b87377b9d2d4565e96f29b410", + "rev": "3d0579f5cc93436052d94b73925b48973a104204", "type": "github" }, "original": { From 955a0ec8ba038958e40e1005bbde9d95516ef918 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 22 Feb 2025 20:11:39 +0100 Subject: [PATCH 47/61] Add package 'mailnix' for assisting in generation of dovecot/postfix files --- flake.lock | 16 + flake.nix | 21 +- pkgs/mailnix/Cargo.lock | 749 ++++++++++++++++++++++++++++++++++++ pkgs/mailnix/Cargo.toml | 11 + pkgs/mailnix/src/cli.rs | 22 ++ pkgs/mailnix/src/config.rs | 199 ++++++++++ pkgs/mailnix/src/dovecot.rs | 55 +++ pkgs/mailnix/src/main.rs | 33 ++ pkgs/mailnix/src/postfix.rs | 32 ++ 9 files changed, 1136 insertions(+), 2 deletions(-) create mode 100644 pkgs/mailnix/Cargo.lock create mode 100644 pkgs/mailnix/Cargo.toml create mode 100644 pkgs/mailnix/src/cli.rs create mode 100644 pkgs/mailnix/src/config.rs create mode 100644 pkgs/mailnix/src/dovecot.rs create mode 100644 pkgs/mailnix/src/main.rs create mode 100644 pkgs/mailnix/src/postfix.rs diff --git a/flake.lock b/flake.lock index 1f363dc..a989bb3 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,20 @@ { "nodes": { + "crane": { + "locked": { + "lastModified": 1739936662, + "narHash": "sha256-x4syUjNUuRblR07nDPeLDP7DpphaBVbUaSoeZkFbGSk=", + "owner": "ipetkov", + "repo": "crane", + "rev": "19de14aaeb869287647d9461cbd389187d8ecdb7", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, "flake-compat": { "flake": false, "locked": { @@ -97,6 +112,7 @@ }, "root": { "inputs": { + "crane": "crane", "flake-parts": "flake-parts", "git-hooks-nix": "git-hooks-nix", "nixpkgs": "nixpkgs", diff --git a/flake.nix b/flake.nix index 476a7d4..21280e1 100644 --- a/flake.nix +++ b/flake.nix @@ -7,6 +7,7 @@ flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; treefmt-nix.url = "github:numtide/treefmt-nix"; treefmt-nix.inputs.nixpkgs.follows = "nixpkgs"; + crane.url = "github:ipetkov/crane"; git-hooks-nix.url = "github:cachix/git-hooks.nix"; git-hooks-nix.inputs.nixpkgs.follows = "nixpkgs"; }; @@ -23,6 +24,7 @@ "aarch64-linux" ]; imports = [ + flake-parts.flakeModules.easyOverlay inputs.treefmt-nix.flakeModule inputs.git-hooks-nix.flakeModule ]; @@ -34,7 +36,10 @@ pkgs, system, ... - }: { + }: let + craneLib = inputs.crane.mkLib pkgs; + pkgs = nixpkgs.legacyPackages.${system}.extend self.overlays.default; + in { checks = let tests = ["internal" "basic" "aliases" "rspamd"]; genTest = testName: { @@ -44,9 +49,20 @@ in pkgs.lib.listToAttrs (map genTest tests); - devShells.default = pkgs.mkShell { + packages = rec { + default = mailnix; + mailnix = craneLib.buildPackage { + src = craneLib.cleanCargoSource ./pkgs/mailnix; + }; + }; + overlayAttrs = { + mailnix = config.packages.mailnix; + }; + + devShells.default = craneLib.devShell { packages = with pkgs; [ self'.formatter.outPath # Add all formatters to environment + mailnix ]; shellHook = '' ${config.pre-commit.installationScript} @@ -61,6 +77,7 @@ programs = { actionlint.enable = true; alejandra.enable = true; + rustfmt.enable = true; }; settings.global.excludes = [ ".envrc" diff --git a/pkgs/mailnix/Cargo.lock b/pkgs/mailnix/Cargo.lock new file mode 100644 index 0000000..9bd1eef --- /dev/null +++ b/pkgs/mailnix/Cargo.lock @@ -0,0 +1,749 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.96" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "cc" +version = "1.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-targets", +] + +[[package]] +name = "clap" +version = "4.5.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92b7b18d71fad5313a1e320fa9897994228ce274b60faa4d694fe0ea89cd9e6d" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a35db2071778a7344791a4fb4f95308b5673d219dee3ae348b86642574ecc90c" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +dependencies = [ + "equivalent", + "hashbrown 0.15.2", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.169" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + +[[package]] +name = "log" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" + +[[package]] +name = "mailnix" +version = "0.1.0" +dependencies = [ + "clap", + "regex", + "serde", + "serde_with", + "serde_yml", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustversion" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" + +[[package]] +name = "ryu" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" + +[[package]] +name = "serde" +version = "1.0.218" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.218" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.139" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.7.1", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap 2.7.1", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "time" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/pkgs/mailnix/Cargo.toml b/pkgs/mailnix/Cargo.toml new file mode 100644 index 0000000..accee63 --- /dev/null +++ b/pkgs/mailnix/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "mailnix" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = { version = "4.5.28", features = ["derive"] } +regex = "1.11.1" +serde = { version = "1.0.217", features = ["derive", "rc"] } +serde_with = "3.12.0" +serde_yml = "0.0.12" diff --git a/pkgs/mailnix/src/cli.rs b/pkgs/mailnix/src/cli.rs new file mode 100644 index 0000000..ab995fa --- /dev/null +++ b/pkgs/mailnix/src/cli.rs @@ -0,0 +1,22 @@ +use clap::{Parser, Subcommand}; + +#[derive(Subcommand, Debug)] +pub enum Commands { + Check, + GenerateUserdb, + GenerateStaticPassdb, + UpdateDynamicPassdb { path: String }, + GenerateAliases, + GenerateDeniedRecipients, + GenerateDomains, +} + +#[derive(Parser, Debug)] +#[command(author, version, about)] +pub struct Cli { + pub config_path: String, + pub additional_config_path: Option, + + #[command(subcommand)] + pub command: Commands, +} diff --git a/pkgs/mailnix/src/config.rs b/pkgs/mailnix/src/config.rs new file mode 100644 index 0000000..0fc016d --- /dev/null +++ b/pkgs/mailnix/src/config.rs @@ -0,0 +1,199 @@ +use serde::Deserialize; +use serde_with::formats::PreferMany; +use serde_with::{serde_as, MapPreventDuplicates, OneOrMany}; + +use regex::Regex; +use std::collections::{HashMap, HashSet}; +use std::error::Error; +use std::fs::{self, File}; +use std::io::BufReader; +use std::path::Path; + +#[serde_as] +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + #[serde(default)] + pub domains: Vec, + + #[serde(default)] + #[serde_as(deserialize_as = "MapPreventDuplicates<_, _>")] + pub accounts: HashMap, + + #[serde(default)] + #[serde_as(deserialize_as = "MapPreventDuplicates<_, OneOrMany<_, PreferMany>>")] + pub aliases: HashMap>, +} + +fn default_reject_message() -> String { + "This account cannot receive emails.".to_string() +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AccountConfig { + #[serde(rename(deserialize = "hashedPassword"))] + #[serde(skip)] + pub hashed_password: String, + + #[serde(rename(deserialize = "hashedPassword"))] + hashed_password_: Option, + + #[serde(rename(deserialize = "hashedPasswordFile"))] + hashed_password_file_: Option, + + #[serde(default, rename(deserialize = "isSystemUser"))] + pub is_system_user: bool, + + #[serde( + default = "default_reject_message", + rename(deserialize = "rejectMessage") + )] + pub reject_message: String, +} + +fn sanitize_vec(vec: &mut [String]) -> Result<(), Box> { + let mut seen = HashSet::new(); + for item in vec.iter_mut() { + *item = item.to_lowercase(); + if !seen.insert(item.clone()) { + return Err(format!("Duplicate entry ({item}) detected, aborting...").into()); + } + } + Ok(()) +} + +fn sanitize_map_keys(map: &mut HashMap) -> Result<(), Box> { + let mut new_map = HashMap::::new(); + let mut seen = HashSet::new(); + for (k, v) in map.iter() { + let new_k = k.to_lowercase(); + new_map.insert(new_k.clone(), v.clone()); + if !seen.insert(new_k.clone()) { + return Err(format!("Duplicate entry ({new_k}) detected, aborting...").into()); + } + } + *map = new_map; + Ok(()) +} + +impl Config { + pub fn load>(path: P) -> Result> { + let file = File::open(path)?; + let reader = BufReader::new(file); + + let mut cfg: Config = serde_yml::from_reader(reader)?; + cfg.sanitize()?; + for (name, acc) in cfg.accounts.iter_mut() { + match ( + acc.hashed_password_.clone(), + acc.hashed_password_file_.clone(), + ) { + (Some(_hash), Some(_file)) => { + return Err("Account {name} has both HashedPassword and HashedPasswordFile set, aborting...".into()); + } + (Some(hash), None) => { + acc.hashed_password = hash; + } + (None, Some(file)) => { + let hash = fs::read_to_string(file.clone()).unwrap_or_else(|err| { + panic!("Account ({name}): Reading {file} failed: {err:?}") + }); + acc.hashed_password = hash.trim().to_string(); + } + (None, None) => { + return Err("Account {name} is missing HashedPassword or HashedPasswordFile, aborting...".into()); + } + } + } + Ok(cfg) + } + + fn sanitize(&mut self) -> Result<(), Box> { + // standardize capitalization, ... + sanitize_vec(&mut self.domains).unwrap_or_else(|err| panic!("domain: {err:?}")); + sanitize_map_keys(&mut self.accounts).unwrap_or_else(|err| panic!("accounts: {err:?}")); + sanitize_map_keys(&mut self.aliases).unwrap_or_else(|err| panic!("aliases: {err:?}")); + for (from, dests) in self.aliases.iter_mut() { + sanitize_vec(&mut *dests).unwrap_or_else(|err| panic!("aliases ({from}): {err:?}")); + } + Ok(()) + } + + pub fn check(&self) -> Result<(), Box> { + // check whether all account domains exist + let re = Regex::new(r"^(?P.*)@(?P.*)$").unwrap(); + for name in self.accounts.keys() { + let caps = re + .captures(name) + .ok_or("Mail address regex does not match") + .unwrap(); + if !self.domains.contains(&caps["domain"].to_string()) { + panic!("Domain of account \"{name}\" does not exist"); + } + } + + // check whether aliases have corresponding accounts/domains + for (from, dests) in self.aliases.iter() { + let is_domain = from.contains("@"); + if is_domain && !self.accounts.contains_key(from) { + panic!("Aliased from-account \"{from}\" does not exist"); + } else if !is_domain && !self.domains.contains(from) { + panic!("Aliased from-domain \"{from}\" does not exist"); + } + for dest in dests.iter() { + let is_domain = dest.contains("@"); + if is_domain && !self.accounts.contains_key(dest) { + panic!("Aliased dest-account \"{dest}\" does not exist"); + } else if !is_domain && !self.domains.contains(dest) { + panic!("Aliased dest-domain \"{dest}\" does not exist"); + } + } + } + Ok(()) + } + + pub fn merge>(&mut self, path: P) -> Result<(), Box> { + let mut other = Config::load(path).unwrap(); + + if !self + .domains + .iter() + .all(|domain| other.domains.contains(domain)) + { + return Err( + "domains: Duplicate entry during merge detected, aborting..." + .to_string() + .into(), + ); + } + self.domains.append(&mut other.domains); + + if !self + .accounts + .keys() + .all(|name| other.accounts.contains_key(name)) + { + return Err( + "accounts: Duplicate entry during merge detected, aborting..." + .to_string() + .into(), + ); + } + self.accounts.extend(other.accounts); + + if !self + .aliases + .keys() + .all(|alias| other.aliases.contains_key(alias)) + { + return Err( + "aliases: Duplicate entry during merge detected, aborting..." + .to_string() + .into(), + ); + } + self.aliases.extend(other.aliases); + + Ok(()) + } +} diff --git a/pkgs/mailnix/src/dovecot.rs b/pkgs/mailnix/src/dovecot.rs new file mode 100644 index 0000000..65b3daf --- /dev/null +++ b/pkgs/mailnix/src/dovecot.rs @@ -0,0 +1,55 @@ +use regex::Regex; +use std::collections::HashMap; +use std::error::Error; +use std::fs; +use std::path::Path; + +use crate::config::{AccountConfig, Config}; + +pub fn generate_userdb(cfg: Config) { + for name in cfg.accounts.into_keys() { + println!("{}:::::::", name); + } +} + +pub fn generate_static_passdb(cfg: Config) { + let system_accounts = cfg + .accounts + .into_iter() + .filter(|(_, acc)| acc.is_system_user); + for (name, _) in system_accounts { + println!("{}:::::::", name); + } +} + +pub fn update_dynamic_passdb>(cfg: Config, path: P) -> Result<(), Box> { + // create hashmap of all accounts with their initial passdb-lines + let mut accounts: HashMap = cfg + .accounts + .into_iter() + .filter(|(_, acc)| !acc.is_system_user) + .collect(); + eprintln!("settings: {:#?}", accounts); + + // load current passdb and update account password hashes + if path.as_ref().exists() { + let re = Regex::new(r"^(?P.*):(?P.*)::::::$").unwrap(); + + let curr_passdb = fs::read_to_string(path.as_ref()).unwrap(); + eprintln!("current passdb: {curr_passdb}"); + for line in curr_passdb.lines() { + //let caps = re.captures(line).ok_or_else(panic!("Regex does not match").unwrap(); + let caps = re + .captures(line) + .unwrap_or_else(|| panic!("Regex does not match line: {line}")); + accounts.entry(caps["name"].to_string()).and_modify(|e| { + e.hashed_password = caps["hashed_password"].to_string(); + }); + } + } + + for (name, acc) in accounts.into_iter() { + println!("{}:{}::::::", name, acc.hashed_password); + } + Ok(()) +} diff --git a/pkgs/mailnix/src/main.rs b/pkgs/mailnix/src/main.rs new file mode 100644 index 0000000..86847ab --- /dev/null +++ b/pkgs/mailnix/src/main.rs @@ -0,0 +1,33 @@ +mod cli; +mod config; +mod dovecot; +mod postfix; + +use crate::{ + cli::{Cli, Commands}, + config::Config, +}; + +use clap::Parser; + +fn main() { + let args = Cli::parse(); + let mut cfg = Config::load(args.config_path).unwrap(); + if let Some(additional_cfg_path) = args.additional_config_path { + cfg.merge(additional_cfg_path).unwrap(); + } + let cfg = cfg; + cfg.check().unwrap(); + + match &args.command { + Commands::Check => println!("Check: {:#?}", cfg), + Commands::GenerateUserdb => dovecot::generate_userdb(cfg), + Commands::GenerateStaticPassdb => dovecot::generate_static_passdb(cfg), + Commands::UpdateDynamicPassdb { path } => { + dovecot::update_dynamic_passdb(cfg, path).unwrap() + } + Commands::GenerateAliases => postfix::generate_aliases(cfg), + Commands::GenerateDeniedRecipients => postfix::generate_denied_recipients(cfg), + Commands::GenerateDomains => postfix::generate_domains(cfg), + } +} diff --git a/pkgs/mailnix/src/postfix.rs b/pkgs/mailnix/src/postfix.rs new file mode 100644 index 0000000..d7b456b --- /dev/null +++ b/pkgs/mailnix/src/postfix.rs @@ -0,0 +1,32 @@ +use crate::config::Config; + +fn prefix_domain(value: String) -> String { + if !value.contains("@") { + return format!("@{value}"); + } + value +} + +pub fn generate_aliases(cfg: Config) { + for name in cfg.accounts.into_keys() { + println!("{} {}", name, name); + } + for (src, dests) in cfg.aliases.into_iter() { + let dests: Vec<_> = dests.into_iter().map(prefix_domain).collect(); + println!("{} {}", prefix_domain(src), dests.join(", ")); + } +} + +pub fn generate_denied_recipients(cfg: Config) { + let system_accounts = cfg + .accounts + .into_iter() + .filter(|(_, acc)| acc.is_system_user); + for (name, acc) in system_accounts { + println!("{} REJECT {}", name, acc.reject_message); + } +} + +pub fn generate_domains(cfg: Config) { + println!("{}", cfg.domains.into_iter().collect::>().join("\n")); +} From c1b19d6e3355e8ed6c11fc75f1ebb92a81d8f87d Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sun, 23 Feb 2025 16:31:02 +0100 Subject: [PATCH 48/61] mailsystem: Use newly added 'mailnix' package to generate postfix/dovecot files --- mailsystem/common.nix | 11 +++++++- mailsystem/dovecot.nix | 48 +++++---------------------------- mailsystem/nginx.nix | 2 +- mailsystem/postfix.nix | 54 +++++++++++++++++--------------------- mailsystem/roundcube.nix | 2 +- mailsystem/rspamd.nix | 2 +- mailsystem/selfsigned.nix | 2 +- pkgs/mailnix/src/config.rs | 14 +++++----- tests/common/lib.nix | 2 +- 9 files changed, 52 insertions(+), 85 deletions(-) diff --git a/mailsystem/common.nix b/mailsystem/common.nix index ab7174c..2ff0a5e 100644 --- a/mailsystem/common.nix +++ b/mailsystem/common.nix @@ -1,4 +1,8 @@ -{config, ...}: let +{ + config, + pkgs, + ... +}: let cfg = config.mailsystem; in rec { certificateDirectory = "/var/certs"; @@ -17,6 +21,11 @@ in rec { then ["acme-finished-${cfg.fqdn}.target"] else ["mailsystem-selfsigned-certificate.service"]; + mailnixCfgFile = pkgs.writeText "mailnix-public.json" (builtins.toJSON { + inherit (cfg) accounts domains; + aliases = cfg.virtualAliases; + }); + dovecotDynamicStateDir = "/var/lib/dovecot"; dovecotDynamicPasswdFile = "${dovecotDynamicStateDir}/passwd"; diff --git a/mailsystem/dovecot.nix b/mailsystem/dovecot.nix index ac9ea3e..3b05719 100644 --- a/mailsystem/dovecot.nix +++ b/mailsystem/dovecot.nix @@ -4,7 +4,7 @@ pkgs, ... }: -with (import ./common.nix {inherit config;}); let +with (import ./common.nix {inherit config pkgs;}); let cfg = config.mailsystem; postfixCfg = config.services.postfix; dovecot2Cfg = config.services.dovecot2; @@ -39,43 +39,11 @@ with (import ./common.nix {inherit config;}); let # Ensure passwd files are not world-readable at any time umask 077 - # Ensure we have a file for every user's (initial) password hash. - for f in ${builtins.toString (lib.mapAttrsToList (user: value: value.hashedPasswordFile) cfg.accounts)}; do - if [ ! -f "$f" ]; then - echo "Expected password hash file $f does not exist!" - exit 1 - fi - done - # Prepare static passwd-file for system users - cat < "${staticPasswdFile}" - ${lib.concatStringsSep "\n" (lib.mapAttrsToList genPasswdEntry systemUsers)} - EOF + ${pkgs.mailnix}/bin/mailnix ${mailnixCfgFile} generate-static-passdb > "${staticPasswdFile}" - # Prepare initial passwd-file for dynamic users - # (used for lookup during actual passwd-file generation) - cat < "${initialPasswdFile}" - ${lib.concatStringsSep "\n" (lib.mapAttrsToList genPasswdEntry normalUsers)} - EOF - - # Check for existence of dynamic passwd-file - touch "${dovecotDynamicPasswdFile}" - if (! test -f "${dovecotDynamicPasswdFile}"); then - echo "${dovecotDynamicPasswdFile} exists and is no regular file" - exit 1 - fi - # Ensure that only configured users are actually present and remove any others - truncate -s 0 "${dovecotDynamicPasswdFile}-filtered" - for u in ${builtins.toString (lib.mapAttrsToList (user: value: value.name) normalUsers)}; do - if grep -q "^$u:" "${dovecotDynamicPasswdFile}"; then - # User already has some password set -> Keep currently set password - grep "^$u:" "${dovecotDynamicPasswdFile}" >> "${dovecotDynamicPasswdFile}-filtered" - else - # User has no password set, yet -> Take password from initialPasswdFile - grep "^$u:" "${initialPasswdFile}" >> "${dovecotDynamicPasswdFile}-filtered" - fi - done - mv "${dovecotDynamicPasswdFile}-filtered" "${dovecotDynamicPasswdFile}" + # Prepare/Update passwd-file for dynamic users + ${pkgs.mailnix}/bin/mailnix ${mailnixCfgFile} update-dynamic-passdb ${dovecotDynamicPasswdFile} > "${dovecotDynamicPasswdFile}" ${lib.optionalString cfg.roundcube.enable '' # Ensure roundcube has access to dynamic passwd file @@ -83,12 +51,10 @@ with (import ./common.nix {inherit config;}); let ''} # Prepare userdb-file - cat < "${userdbFile}" - ${lib.concatStringsSep "\n" (lib.mapAttrsToList genUserdbEntry cfg.accounts)} - EOF + ${pkgs.mailnix}/bin/mailnix ${mailnixCfgFile} generate-userdb > "${userdbFile}" ''; - genMaildir = pkgs.writeScript "generate-maildir" '' + genMaildirScript = pkgs.writeScript "generate-maildir" '' #!${pkgs.stdenv.shell} # Create mail directory and set permissions accordingly. @@ -295,7 +261,7 @@ in { systemd.services.dovecot2 = { preStart = '' ${genAuthDbsScript} - ${genMaildir} + ${genMaildirScript} ''; wants = sslCertService; after = sslCertService; diff --git a/mailsystem/nginx.nix b/mailsystem/nginx.nix index c4f01a0..919bf7c 100644 --- a/mailsystem/nginx.nix +++ b/mailsystem/nginx.nix @@ -4,7 +4,7 @@ lib, ... }: -with (import ./common.nix {inherit config;}); let +with (import ./common.nix {inherit config pkgs;}); let cfg = config.mailsystem; in { config = diff --git a/mailsystem/postfix.nix b/mailsystem/postfix.nix index e5ebbfa..f6c532f 100644 --- a/mailsystem/postfix.nix +++ b/mailsystem/postfix.nix @@ -4,42 +4,29 @@ pkgs, ... }: -with (import ./common.nix {inherit config;}); let +with (import ./common.nix {inherit config pkgs;}); let cfg = config.mailsystem; mappedFile = name: "hash:/var/lib/postfix/conf/${name}"; - attrsToLookupTable = aliases: let - lookupTables = lib.mapAttrsToList (from: to: {"${from}" = to;}) aliases; - in - mergeLookupTables lookupTables; + runtimeDir = "/run/postfix"; + aliases_file = "${runtimeDir}/virtual_aliases"; + virtual_domains_file = "${runtimeDir}/virtual_domains"; + denied_recipients_file = "${runtimeDir}/denied_recipients"; - lookupTableToString = attrs: let - isDomain = value: !(lib.hasInfix "@" value); - valueToString = value: - if (isDomain value) - then "@${value}" - else value; - listToString = list: lib.concatStringsSep ", " (map valueToString list); - in - lib.concatStringsSep "\n" (lib.mapAttrsToList (name: list: "${valueToString name} ${listToString list}") attrs); + genPostmapsScript = pkgs.writeScript "generate-postfix-postmaps" '' + #!${pkgs.stdenv.shell} + set -euo pipefail - mergeLookupTables = tables: lib.zipAttrsWith (n: v: lib.flatten v) tables; + if (! test -d "${runtimeDir}"); then + mkdir "${runtimeDir}" + chmod 755 "${runtimeDir}" + fi - virtual_accounts = mergeLookupTables (lib.map (name: {"${name}" = name;}) (lib.attrNames cfg.accounts)); - virtual_aliases = attrsToLookupTable cfg.virtualAliases; - all_virtual_aliases = mergeLookupTables [virtual_accounts virtual_aliases]; - - # File containing all mappings of aliases/authenticated accounts and their sender mail addresses. - aliases_file = let - content = lookupTableToString all_virtual_aliases; - in - builtins.toFile "virtual_aliases" content; - - virtual_domains_file = builtins.toFile "virtual_domains" (lib.concatStringsSep "\n" cfg.domains); - - denied_recipients = map (account: "${account.name} REJECT ${account.rejectMessage}") (lib.filter (account: account.isSystemUser) (lib.attrValues cfg.accounts)); - denied_recipients_file = builtins.toFile "denied_recipients" (lib.concatStringsSep "\n" denied_recipients); + ${pkgs.mailnix}/bin/mailnix "${mailnixCfgFile}" "generate-aliases" > "${aliases_file}" + ${pkgs.mailnix}/bin/mailnix "${mailnixCfgFile}" "generate-domains" > "${virtual_domains_file}" + ${pkgs.mailnix}/bin/mailnix "${mailnixCfgFile}" "generate-denied-recipients" > "${denied_recipients_file}" + ''; submission_header_cleanup_rules = pkgs.writeText "submission_header_cleanup_rules" '' # Removes sensitive headers from mails handed in via the submission port. @@ -83,7 +70,6 @@ in { mapFiles."virtual_aliases" = aliases_file; mapFiles."denied_recipients" = denied_recipients_file; - virtual = lookupTableToString all_virtual_aliases; submissionsOptions = { smtpd_tls_security_level = "encrypt"; @@ -110,6 +96,9 @@ in { virtual_gid_maps = "static:${toString cfg.vmailUID}"; virtual_mailbox_base = cfg.mailDirectory; virtual_mailbox_domains = virtual_domains_file; + virtual_alias_maps = [ + (mappedFile "virtual_aliases") + ]; virtual_mailbox_maps = [ (mappedFile "virtual_aliases") ]; @@ -193,6 +182,11 @@ in { }; }; + systemd.services.postfix-setup = { + preStart = '' + ${genPostmapsScript} + ''; + }; systemd.services.postfix = { wants = sslCertService; after = diff --git a/mailsystem/roundcube.nix b/mailsystem/roundcube.nix index b05fb25..4c11c51 100644 --- a/mailsystem/roundcube.nix +++ b/mailsystem/roundcube.nix @@ -4,7 +4,7 @@ pkgs, ... }: -with (import ./common.nix {inherit config;}); let +with (import ./common.nix {inherit config pkgs;}); let cfg = config.mailsystem; roundcubeCfg = config.mailsystem.roundcube; in { diff --git a/mailsystem/rspamd.nix b/mailsystem/rspamd.nix index 847aba4..7955b9f 100644 --- a/mailsystem/rspamd.nix +++ b/mailsystem/rspamd.nix @@ -4,7 +4,7 @@ pkgs, ... }: -with (import ./common.nix {inherit config;}); let +with (import ./common.nix {inherit config pkgs;}); let cfg = config.mailsystem; nginxCfg = config.services.nginx; postfixCfg = config.services.postfix; diff --git a/mailsystem/selfsigned.nix b/mailsystem/selfsigned.nix index 4506fcf..1a27318 100644 --- a/mailsystem/selfsigned.nix +++ b/mailsystem/selfsigned.nix @@ -4,7 +4,7 @@ lib, ... }: -with (import ./common.nix {inherit config;}); let +with (import ./common.nix {inherit config pkgs;}); let cfg = config.mailsystem; in { config = lib.mkIf (cfg.enable && cfg.certificateScheme == "selfsigned") { diff --git a/pkgs/mailnix/src/config.rs b/pkgs/mailnix/src/config.rs index 0fc016d..9e3240f 100644 --- a/pkgs/mailnix/src/config.rs +++ b/pkgs/mailnix/src/config.rs @@ -134,18 +134,16 @@ impl Config { // check whether aliases have corresponding accounts/domains for (from, dests) in self.aliases.iter() { - let is_domain = from.contains("@"); - if is_domain && !self.accounts.contains_key(from) { - panic!("Aliased from-account \"{from}\" does not exist"); - } else if !is_domain && !self.domains.contains(from) { + let is_domain = !from.contains("@"); + if is_domain && !self.domains.contains(from) { panic!("Aliased from-domain \"{from}\" does not exist"); } for dest in dests.iter() { - let is_domain = dest.contains("@"); - if is_domain && !self.accounts.contains_key(dest) { - panic!("Aliased dest-account \"{dest}\" does not exist"); - } else if !is_domain && !self.domains.contains(dest) { + let is_domain = !dest.contains("@"); + if is_domain && !self.domains.contains(dest) { panic!("Aliased dest-domain \"{dest}\" does not exist"); + } else if !is_domain && !self.accounts.contains_key(dest) { + panic!("Aliased dest-account \"{dest}\" does not exist"); } } } diff --git a/tests/common/lib.nix b/tests/common/lib.nix index 1930be9..a2b1a15 100644 --- a/tests/common/lib.nix +++ b/tests/common/lib.nix @@ -2,7 +2,7 @@ lib = pkgs.lib; in rec { waitForRspamd = node: let - inherit (import ../../mailsystem/common.nix {inherit (node) config;}) rspamdProxySocket; + inherit (import ../../mailsystem/common.nix {inherit (node) config pkgs;}) rspamdProxySocket; in "set +e; timeout 1 ${node.nixpkgs.pkgs.netcat}/bin/nc -U ${rspamdProxySocket} < /dev/null; [ $? -eq 124 ]"; mkHashedPasswordFile = password: From 3a9b2c8b59990ebb0bee14fb7138721e9209a1b6 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sun, 23 Feb 2025 18:06:00 +0100 Subject: [PATCH 49/61] mailsystem: Add option extraSettingsFile to allow partial encryption of configuration --- mailsystem/common.nix | 14 ++++++++++---- mailsystem/default.nix | 12 ++++++++++++ mailsystem/dovecot.nix | 6 +++--- mailsystem/postfix.nix | 6 +++--- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/mailsystem/common.nix b/mailsystem/common.nix index 2ff0a5e..48dcdcb 100644 --- a/mailsystem/common.nix +++ b/mailsystem/common.nix @@ -21,10 +21,16 @@ in rec { then ["acme-finished-${cfg.fqdn}.target"] else ["mailsystem-selfsigned-certificate.service"]; - mailnixCfgFile = pkgs.writeText "mailnix-public.json" (builtins.toJSON { - inherit (cfg) accounts domains; - aliases = cfg.virtualAliases; - }); + mailnixCmd = let + mailnixCfgFile = pkgs.writeText "mailnix-public.json" (builtins.toJSON { + inherit (cfg) accounts domains; + aliases = cfg.virtualAliases; + }); + extraCfgFile = + if (cfg.extraSettingsFile != null) + then cfg.extraSettingsFile + else ""; + in "${pkgs.mailnix}/bin/mailnix ${extraCfgFile} ${mailnixCfgFile}"; dovecotDynamicStateDir = "/var/lib/dovecot"; dovecotDynamicPasswdFile = "${dovecotDynamicStateDir}/passwd"; diff --git a/mailsystem/default.nix b/mailsystem/default.nix index cae1a62..8b6b7bb 100644 --- a/mailsystem/default.nix +++ b/mailsystem/default.nix @@ -206,6 +206,18 @@ in { default = {}; }; + extraSettingsFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + description = '' + YAML file to merge into the mailsystem configuration at runtime. + This can be used to store secrets, and, more importantly, keep your email + addresses out of the hands of spammers. This `extraSettingsFile` currently + supports `domains`, `accounts` and `virtualAliases` which can be defined in + the same manner as they can be via nix. + ''; + default = null; + }; + certificateScheme = lib.mkOption { type = lib.types.enum ["acme" "selfsigned"]; default = "acme"; diff --git a/mailsystem/dovecot.nix b/mailsystem/dovecot.nix index 3b05719..7d65cde 100644 --- a/mailsystem/dovecot.nix +++ b/mailsystem/dovecot.nix @@ -40,10 +40,10 @@ with (import ./common.nix {inherit config pkgs;}); let umask 077 # Prepare static passwd-file for system users - ${pkgs.mailnix}/bin/mailnix ${mailnixCfgFile} generate-static-passdb > "${staticPasswdFile}" + ${mailnixCmd} generate-static-passdb > "${staticPasswdFile}" # Prepare/Update passwd-file for dynamic users - ${pkgs.mailnix}/bin/mailnix ${mailnixCfgFile} update-dynamic-passdb ${dovecotDynamicPasswdFile} > "${dovecotDynamicPasswdFile}" + ${mailnixCmd} update-dynamic-passdb ${dovecotDynamicPasswdFile} > "${dovecotDynamicPasswdFile}" ${lib.optionalString cfg.roundcube.enable '' # Ensure roundcube has access to dynamic passwd file @@ -51,7 +51,7 @@ with (import ./common.nix {inherit config pkgs;}); let ''} # Prepare userdb-file - ${pkgs.mailnix}/bin/mailnix ${mailnixCfgFile} generate-userdb > "${userdbFile}" + ${mailnixCmd} generate-userdb > "${userdbFile}" ''; genMaildirScript = pkgs.writeScript "generate-maildir" '' diff --git a/mailsystem/postfix.nix b/mailsystem/postfix.nix index f6c532f..3e31cb6 100644 --- a/mailsystem/postfix.nix +++ b/mailsystem/postfix.nix @@ -23,9 +23,9 @@ with (import ./common.nix {inherit config pkgs;}); let chmod 755 "${runtimeDir}" fi - ${pkgs.mailnix}/bin/mailnix "${mailnixCfgFile}" "generate-aliases" > "${aliases_file}" - ${pkgs.mailnix}/bin/mailnix "${mailnixCfgFile}" "generate-domains" > "${virtual_domains_file}" - ${pkgs.mailnix}/bin/mailnix "${mailnixCfgFile}" "generate-denied-recipients" > "${denied_recipients_file}" + ${mailnixCmd} "generate-aliases" > "${aliases_file}" + ${mailnixCmd} "generate-domains" > "${virtual_domains_file}" + ${mailnixCmd} "generate-denied-recipients" > "${denied_recipients_file}" ''; submission_header_cleanup_rules = pkgs.writeText "submission_header_cleanup_rules" '' From 79b2ec800e629945e72c5baaaed349ec61cf148a Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sun, 23 Feb 2025 20:47:32 +0100 Subject: [PATCH 50/61] pkgs: mailnix: Make errors thrown in config::merge more verbose --- pkgs/mailnix/src/config.rs | 54 +++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/pkgs/mailnix/src/config.rs b/pkgs/mailnix/src/config.rs index 9e3240f..ce019c3 100644 --- a/pkgs/mailnix/src/config.rs +++ b/pkgs/mailnix/src/config.rs @@ -153,42 +153,36 @@ impl Config { pub fn merge>(&mut self, path: P) -> Result<(), Box> { let mut other = Config::load(path).unwrap(); - if !self - .domains - .iter() - .all(|domain| other.domains.contains(domain)) - { - return Err( - "domains: Duplicate entry during merge detected, aborting..." - .to_string() - .into(), - ); + for domain in self.domains.iter() { + if other.domains.contains(domain) { + return Err( + "domains: Duplicate entry ({domain}) during merge detected, aborting..." + .to_string() + .into(), + ); + } } self.domains.append(&mut other.domains); - if !self - .accounts - .keys() - .all(|name| other.accounts.contains_key(name)) - { - return Err( - "accounts: Duplicate entry during merge detected, aborting..." - .to_string() - .into(), - ); + for name in self.accounts.keys() { + if other.accounts.contains_key(name) { + return Err( + "accounts: Duplicate entry ({name}) during merge detected, aborting..." + .to_string() + .into(), + ); + } } self.accounts.extend(other.accounts); - if !self - .aliases - .keys() - .all(|alias| other.aliases.contains_key(alias)) - { - return Err( - "aliases: Duplicate entry during merge detected, aborting..." - .to_string() - .into(), - ); + for alias in self.aliases.keys() { + if other.aliases.contains_key(alias) { + return Err( + "aliases: Duplicate entry ({alias}) during merge detected, aborting..." + .to_string() + .into(), + ); + } } self.aliases.extend(other.aliases); From 6f8bcdf9c043005da035ee83f5e487b2b0484f5d Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Tue, 25 Feb 2025 17:15:20 +0100 Subject: [PATCH 51/61] pkgs: mailnix: Refactor dovecot::update_dynamic_passdb in preparation for unit tests --- pkgs/mailnix/src/dovecot.rs | 40 ++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/pkgs/mailnix/src/dovecot.rs b/pkgs/mailnix/src/dovecot.rs index 65b3daf..e707d39 100644 --- a/pkgs/mailnix/src/dovecot.rs +++ b/pkgs/mailnix/src/dovecot.rs @@ -22,6 +22,27 @@ pub fn generate_static_passdb(cfg: Config) { } } +fn try_load_passdb>(path: P) -> Result, Box> { + let mut curr_dynamic_users = HashMap::new(); + + if path.as_ref().exists() { + let re = Regex::new(r"^(?P.*):(?P.*)::::::$").unwrap(); + + let curr_passdb = fs::read_to_string(path.as_ref()).unwrap(); + for line in curr_passdb.lines() { + let caps = re + .captures(line) + .unwrap_or_else(|| panic!("Regex does not match line: {line}")); + curr_dynamic_users.insert( + caps["name"].to_string(), + caps["hashed_password"].to_string(), + ); + } + eprintln!("current passdb entries: {curr_dynamic_users:#?}"); + } + Ok(curr_dynamic_users) +} + pub fn update_dynamic_passdb>(cfg: Config, path: P) -> Result<(), Box> { // create hashmap of all accounts with their initial passdb-lines let mut accounts: HashMap = cfg @@ -32,20 +53,11 @@ pub fn update_dynamic_passdb>(cfg: Config, path: P) -> Result<(), eprintln!("settings: {:#?}", accounts); // load current passdb and update account password hashes - if path.as_ref().exists() { - let re = Regex::new(r"^(?P.*):(?P.*)::::::$").unwrap(); - - let curr_passdb = fs::read_to_string(path.as_ref()).unwrap(); - eprintln!("current passdb: {curr_passdb}"); - for line in curr_passdb.lines() { - //let caps = re.captures(line).ok_or_else(panic!("Regex does not match").unwrap(); - let caps = re - .captures(line) - .unwrap_or_else(|| panic!("Regex does not match line: {line}")); - accounts.entry(caps["name"].to_string()).and_modify(|e| { - e.hashed_password = caps["hashed_password"].to_string(); - }); - } + let curr_dynamic_users = try_load_passdb(path)?; + for (name, hashed_password) in curr_dynamic_users.into_iter() { + accounts.entry(name).and_modify(|e| { + e.hashed_password = hashed_password; + }); } for (name, acc) in accounts.into_iter() { From 01e63f4bab300ff849840740ae7624ecb8de4124 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sun, 23 Mar 2025 13:27:09 +0100 Subject: [PATCH 52/61] mailsystem: dovecot: Use expanded variable names Starting with dovecot 2.4, short notations for variables are no longer accepted (see https://doc.dovecot.org/2.4.0/installation/upgrade/2.3-to-2.4.html#variable-expansion). --- mailsystem/dovecot.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mailsystem/dovecot.nix b/mailsystem/dovecot.nix index 7d65cde..5661111 100644 --- a/mailsystem/dovecot.nix +++ b/mailsystem/dovecot.nix @@ -233,7 +233,7 @@ in { userdb { driver = passwd-file args = ${userdbFile} - default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory}/%d/%n + default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory}/%{domain}/%{username} } service auth { From 41f8b16a421b60c79b935fec071da286ded58263 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sun, 23 Mar 2025 15:38:07 +0100 Subject: [PATCH 53/61] pkgs: mailnix: Refactor domain-check and address-parsing into own helper functions --- pkgs/mailnix/src/config.rs | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/pkgs/mailnix/src/config.rs b/pkgs/mailnix/src/config.rs index ce019c3..09d8c9a 100644 --- a/pkgs/mailnix/src/config.rs +++ b/pkgs/mailnix/src/config.rs @@ -76,6 +76,19 @@ fn sanitize_map_keys(map: &mut HashMap) -> Result<(), Box bool { + !address.contains("@") +} + +fn parse_address(address: &str) -> Result<(String, String), Box> { + let address_pattern = Regex::new(r"^(?P.*)@(?P.*)$").unwrap(); + let caps = address_pattern + .captures(address) + .ok_or("Mail address regex does not match for \"{address}\"") + .unwrap(); + Ok((caps["local_part"].to_string(), caps["domain"].to_string())) +} + impl Config { pub fn load>(path: P) -> Result> { let file = File::open(path)?; @@ -121,28 +134,22 @@ impl Config { pub fn check(&self) -> Result<(), Box> { // check whether all account domains exist - let re = Regex::new(r"^(?P.*)@(?P.*)$").unwrap(); for name in self.accounts.keys() { - let caps = re - .captures(name) - .ok_or("Mail address regex does not match") - .unwrap(); - if !self.domains.contains(&caps["domain"].to_string()) { + let (_, domain) = parse_address(name).unwrap(); + if !self.domains.contains(&domain) { panic!("Domain of account \"{name}\" does not exist"); } } // check whether aliases have corresponding accounts/domains for (from, dests) in self.aliases.iter() { - let is_domain = !from.contains("@"); - if is_domain && !self.domains.contains(from) { + if is_domain(from) && !self.domains.contains(from) { panic!("Aliased from-domain \"{from}\" does not exist"); } for dest in dests.iter() { - let is_domain = !dest.contains("@"); - if is_domain && !self.domains.contains(dest) { + if is_domain(dest) && !self.domains.contains(dest) { panic!("Aliased dest-domain \"{dest}\" does not exist"); - } else if !is_domain && !self.accounts.contains_key(dest) { + } else if !is_domain(dest) && !self.accounts.contains_key(dest) { panic!("Aliased dest-account \"{dest}\" does not exist"); } } From 6052072c3fd7ebb5d83ee68bf27a0e58b7da5374 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sun, 23 Mar 2025 15:46:55 +0100 Subject: [PATCH 54/61] pkgs: mailnix: Improve alias-validation to also regard catch-all aliases and domain-aliases --- pkgs/mailnix/src/config.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/pkgs/mailnix/src/config.rs b/pkgs/mailnix/src/config.rs index 09d8c9a..f7a686c 100644 --- a/pkgs/mailnix/src/config.rs +++ b/pkgs/mailnix/src/config.rs @@ -132,6 +132,34 @@ impl Config { Ok(()) } + fn is_valid_destination(&self, address: &String) -> bool { + if self.accounts.contains_key(address) { + return true; + } + + // There is no explicit account matching 'address'. However, 'address' may still be + // implicitly valid due to the existence of a catchall-alias or the domain being aliased. + let (local_part, domain) = parse_address(address).unwrap(); + if let Some(dests) = self.aliases.get(&domain) { + if dests.iter().filter(|dest| !is_domain(dest)).count() > 0 { + // At least one explicit catchall-address exists! + return true; + } + // At this point, we need to (recursively) iterate over domain-aliases to check whether + // there is an exact match (or a catchall-alias for the aliased domain). + for dest in dests.iter().filter(|addr| is_domain(addr)) { + let aliased_addr = format!("{local_part}@{dest}"); + // FIXME: This current implementation may not terminate if the configured aliases + // contain a loop! + if self.is_valid_destination(&aliased_addr) { + return true; + } + } + } + // alias destination is not valid + false + } + pub fn check(&self) -> Result<(), Box> { // check whether all account domains exist for name in self.accounts.keys() { @@ -149,7 +177,7 @@ impl Config { for dest in dests.iter() { if is_domain(dest) && !self.domains.contains(dest) { panic!("Aliased dest-domain \"{dest}\" does not exist"); - } else if !is_domain(dest) && !self.accounts.contains_key(dest) { + } else if !is_domain(dest) && !self.is_valid_destination(dest) { panic!("Aliased dest-account \"{dest}\" does not exist"); } } From 2db35aed077f1152e2152642501e213efed2298b Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 27 Mar 2025 19:28:14 +0100 Subject: [PATCH 55/61] pkgs: mailnix: Include password hash in system-users' passwd to enable sending mails --- pkgs/mailnix/src/dovecot.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/mailnix/src/dovecot.rs b/pkgs/mailnix/src/dovecot.rs index e707d39..341afa7 100644 --- a/pkgs/mailnix/src/dovecot.rs +++ b/pkgs/mailnix/src/dovecot.rs @@ -17,8 +17,8 @@ pub fn generate_static_passdb(cfg: Config) { .accounts .into_iter() .filter(|(_, acc)| acc.is_system_user); - for (name, _) in system_accounts { - println!("{}:::::::", name); + for (name, acc) in system_accounts { + println!("{}:{}::::::", name, acc.hashed_password); } } From f110705435a587058233ea2c5e3410c8bbd0a610 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Thu, 27 Mar 2025 19:29:02 +0100 Subject: [PATCH 56/61] tests: basic: Add tests whether system users can dispatch mails --- tests/basic.nix | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/basic.nix b/tests/basic.nix index 70bd13f..cdee2ad 100644 --- a/tests/basic.nix +++ b/tests/basic.nix @@ -82,6 +82,19 @@ in I'm pretending to be someotheraddress@example.com and the mailserver should reject this attempt. ''}") + with subtest("send succeeds for system user"): + client.succeed("${sendMail "system" "" accounts."normal".address '' + Subject: Testmail2 + + Hello User1, + this is some text! + ''}") + # give the mail server some time to process the mail + server.wait_until_fails('${pendingPostqueue}') + + with subtest("mail can be retrieved via imap"): + client.succeed("${recvMail "normal"} >&2") + with subtest("mail sent to system-account is rejected"): client.fail("${sendMail "normal" "someotheraddress@example.com" accounts."system".address '' Subject: Mail to system-account From 8c96ece5853855247494a597c3e5c1ab0c595fbc Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Wed, 28 May 2025 14:45:27 +0200 Subject: [PATCH 57/61] flake.nix: Update NixOS release to 25.05 --- flake.lock | 32 ++++++++++++++++---------------- flake.nix | 2 +- mailsystem/dovecot.nix | 13 ++++++++----- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/flake.lock b/flake.lock index a989bb3..598bc62 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1739936662, - "narHash": "sha256-x4syUjNUuRblR07nDPeLDP7DpphaBVbUaSoeZkFbGSk=", + "lastModified": 1748047550, + "narHash": "sha256-t0qLLqb4C1rdtiY8IFRH5KIapTY/n3Lqt57AmxEv9mk=", "owner": "ipetkov", "repo": "crane", - "rev": "19de14aaeb869287647d9461cbd389187d8ecdb7", + "rev": "b718a78696060df6280196a6f992d04c87a16aef", "type": "github" }, "original": { @@ -38,11 +38,11 @@ ] }, "locked": { - "lastModified": 1738453229, - "narHash": "sha256-7H9XgNiGLKN1G1CgRh0vUL4AheZSYzPm+zmZ7vxbJdo=", + "lastModified": 1743550720, + "narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "32ea77a06711b758da0ad9bd6a844c5740a87abd", + "rev": "c621e8422220273271f52058f618c94e405bb0f5", "type": "github" }, "original": { @@ -60,11 +60,11 @@ ] }, "locked": { - "lastModified": 1737465171, - "narHash": "sha256-R10v2hoJRLq8jcL4syVFag7nIGE7m13qO48wRIukWNg=", + "lastModified": 1747372754, + "narHash": "sha256-2Y53NGIX2vxfie1rOW0Qb86vjRZ7ngizoo+bnXU9D9k=", "owner": "cachix", "repo": "git-hooks.nix", - "rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17", + "rev": "80479b6ec16fefd9c1db3ea13aeb038c60530f46", "type": "github" }, "original": { @@ -96,16 +96,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1739923778, - "narHash": "sha256-BqUY8tz0AQ4to2Z4+uaKczh81zsGZSYxjgvtw+fvIfM=", + "lastModified": 1748162331, + "narHash": "sha256-rqc2RKYTxP3tbjA+PB3VMRQNnjesrT0pEofXQTrMsS8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "36864ed72f234b9540da4cf7a0c49e351d30d3f1", + "rev": "7c43f080a7f28b2774f3b3f43234ca11661bf334", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-24.11", + "ref": "nixos-25.05", "repo": "nixpkgs", "type": "github" } @@ -126,11 +126,11 @@ ] }, "locked": { - "lastModified": 1739829690, - "narHash": "sha256-mL1szCeIsjh6Khn3nH2cYtwO5YXG6gBiTw1A30iGeDU=", + "lastModified": 1748243702, + "narHash": "sha256-9YzfeN8CB6SzNPyPm2XjRRqSixDopTapaRsnTpXUEY8=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "3d0579f5cc93436052d94b73925b48973a104204", + "rev": "1f3f7b784643d488ba4bf315638b2b0a4c5fb007", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 21280e1..082f348 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,7 @@ description = "An opinionated Nixos Mailsystem"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; treefmt-nix.url = "github:numtide/treefmt-nix"; diff --git a/mailsystem/dovecot.nix b/mailsystem/dovecot.nix index 5661111..877c3e0 100644 --- a/mailsystem/dovecot.nix +++ b/mailsystem/dovecot.nix @@ -94,6 +94,14 @@ in { ) cfg.accounts; + environment.systemPackages = [ + # sieves + managesieve + pkgs.dovecot_pigeonhole + ]; + + # For compatibility with python imaplib + environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules"; + services.dovecot2 = { enable = true; enableImap = true; @@ -109,11 +117,6 @@ in { enableLmtp = true; - modules = [ - # sieves + managesieve - pkgs.dovecot_pigeonhole - ]; - # enable managesieve protocols = ["sieve"]; From 4c2eed3421b3217a75f262eb3bb5e88eb7683a7a Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Wed, 28 May 2025 14:46:58 +0200 Subject: [PATCH 58/61] pkgs: mailnix: Cargo.lock: Update dependencies --- pkgs/mailnix/Cargo.lock | 195 ++++++++++++++++++++++++------------- pkgs/mailnix/src/config.rs | 2 +- 2 files changed, 127 insertions(+), 70 deletions(-) diff --git a/pkgs/mailnix/Cargo.lock b/pkgs/mailnix/Cargo.lock index 9bd1eef..b916c85 100644 --- a/pkgs/mailnix/Cargo.lock +++ b/pkgs/mailnix/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aho-corasick" @@ -67,20 +67,20 @@ dependencies = [ [[package]] name = "anstyle-wincon" -version = "3.0.7" +version = "3.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +checksum = "6680de5231bd6ee4c6191b8a1325daa282b415391ec9d3a37bd34f2060dc73fa" dependencies = [ "anstyle", - "once_cell", + "once_cell_polyfill", "windows-sys", ] [[package]] name = "anyhow" -version = "1.0.96" +version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" [[package]] name = "autocfg" @@ -102,9 +102,9 @@ checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" [[package]] name = "cc" -version = "1.2.15" +version = "1.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af" +checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7" dependencies = [ "shlex", ] @@ -117,22 +117,22 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.39" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", "serde", - "windows-targets", + "windows-link", ] [[package]] name = "clap" -version = "4.5.30" +version = "4.5.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92b7b18d71fad5313a1e320fa9897994228ce274b60faa4d694fe0ea89cd9e6d" +checksum = "fd60e63e9be68e5fb56422e397cf9baddded06dae1d2e523401542383bc72a9f" dependencies = [ "clap_builder", "clap_derive", @@ -140,9 +140,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.30" +version = "4.5.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35db2071778a7344791a4fb4f95308b5673d219dee3ae348b86642574ecc90c" +checksum = "89cc6392a1f72bbeb820d71f32108f61fdaf18bc526e1d23954168a67759ef51" dependencies = [ "anstream", "anstyle", @@ -152,9 +152,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.28" +version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" dependencies = [ "heck", "proc-macro2", @@ -182,9 +182,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "darling" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ "darling_core", "darling_macro", @@ -192,9 +192,9 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", @@ -206,9 +206,9 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", @@ -217,9 +217,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.11" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" dependencies = [ "powerfmt", "serde", @@ -245,9 +245,9 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" [[package]] name = "heck" @@ -263,14 +263,15 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "iana-time-zone" -version = "0.1.61" +version = "0.1.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", "windows-core", ] @@ -303,12 +304,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.7.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "serde", ] @@ -320,9 +321,9 @@ checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itoa" -version = "1.0.14" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" @@ -336,9 +337,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.169" +version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "libyml" @@ -352,9 +353,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.26" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "mailnix" @@ -390,9 +391,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.20.3" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" [[package]] name = "powerfmt" @@ -402,18 +409,18 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "proc-macro2" -version = "1.0.93" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.38" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] @@ -449,30 +456,30 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "rustversion" -version = "1.0.19" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" [[package]] name = "ryu" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "serde" -version = "1.0.218" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.218" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -481,9 +488,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.139" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", "memchr", @@ -501,7 +508,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.7.1", + "indexmap 2.9.0", "serde", "serde_derive", "serde_json", @@ -527,7 +534,7 @@ version = "0.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" dependencies = [ - "indexmap 2.7.1", + "indexmap 2.9.0", "itoa", "libyml", "memchr", @@ -550,9 +557,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.98" +version = "2.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" dependencies = [ "proc-macro2", "quote", @@ -561,9 +568,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.37" +version = "0.3.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" dependencies = [ "deranged", "itoa", @@ -576,15 +583,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" [[package]] name = "time-macros" -version = "0.2.19" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" dependencies = [ "num-conv", "time-core", @@ -592,9 +599,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "utf8parse" @@ -668,11 +675,61 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.52.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-targets", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", ] [[package]] diff --git a/pkgs/mailnix/src/config.rs b/pkgs/mailnix/src/config.rs index f7a686c..b3dc274 100644 --- a/pkgs/mailnix/src/config.rs +++ b/pkgs/mailnix/src/config.rs @@ -1,6 +1,6 @@ use serde::Deserialize; use serde_with::formats::PreferMany; -use serde_with::{serde_as, MapPreventDuplicates, OneOrMany}; +use serde_with::{MapPreventDuplicates, OneOrMany, serde_as}; use regex::Regex; use std::collections::{HashMap, HashSet}; From 27d388e1c8a95195370931686583040a5c599de4 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sun, 10 Aug 2025 18:24:18 +0200 Subject: [PATCH 59/61] mailsystem: rspamd: Make rspamd-controller.socket accessible for dovecot2 user This is required for functional spam/ham learning. --- mailsystem/rspamd.nix | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/mailsystem/rspamd.nix b/mailsystem/rspamd.nix index 7955b9f..851693a 100644 --- a/mailsystem/rspamd.nix +++ b/mailsystem/rspamd.nix @@ -6,12 +6,13 @@ }: with (import ./common.nix {inherit config pkgs;}); let cfg = config.mailsystem; + dovecot2Cfg = config.services.dovecot2; nginxCfg = config.services.nginx; postfixCfg = config.services.postfix; redisCfg = config.services.redis.servers.rspamd; rspamdCfg = config.services.rspamd; - genSystemdSocketCfg = name: socketPath: additionalUser: { + genSystemdSocketCfg = name: socketPath: additionalUsers: { description = "rspamd ${name} worker socket"; listenStreams = [socketPath]; requiredBy = ["rspamd.service"]; @@ -20,8 +21,10 @@ with (import ./common.nix {inherit config pkgs;}); let SocketUser = rspamdCfg.user; SocketMode = 0600; ExecStartPost = - lib.mkIf (additionalUser != "") - ''${pkgs.acl.bin}/bin/setfacl -m "u:${additionalUser}:rw" "${socketPath}"''; + lib.mkIf (additionalUsers != []) + (pkgs.writeShellScript "set-systemd-socket-permissions" + (lib.concatMapStringsSep "\n" (user: ''${pkgs.acl.bin}/bin/setfacl -m "u:${user}:rw" "${socketPath}"'') + additionalUsers)); }; }; in { @@ -149,10 +152,8 @@ in { }; systemd.sockets = { - rspamd-proxy = genSystemdSocketCfg "proxy" rspamdProxySocket postfixCfg.user; - rspamd-controller = genSystemdSocketCfg "controller" rspamdControllerSocket ( - lib.optionalString cfg.rspamd.webUi.enable nginxCfg.user - ); + rspamd-proxy = genSystemdSocketCfg "proxy" rspamdProxySocket [postfixCfg.user]; + rspamd-controller = genSystemdSocketCfg "controller" rspamdControllerSocket ([dovecot2Cfg.mailUser] ++ lib.optional cfg.rspamd.webUi.enable nginxCfg.user); }; systemd.services.rspamd = { From f5bf117314552700f0c708dac51459dbab9954f6 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sun, 10 Aug 2025 18:34:36 +0200 Subject: [PATCH 60/61] tests: rspamd: Fix sieve tests Currently, the sieve tests only check whether the sieve scripts were invoked. However, they could still fail due to wrong permissions, etc. This commit enables additional logging output for dovecot2 and refines the sieve tests to check for error messages. --- tests/common/server.nix | 9 ++++++++- tests/rspamd.nix | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/common/server.nix b/tests/common/server.nix index ff60d5d..34476df 100644 --- a/tests/common/server.nix +++ b/tests/common/server.nix @@ -1,4 +1,4 @@ -{...}: { +{lib, ...}: { imports = [./../../mailsystem]; config = { virtualisation.memorySize = 1024; @@ -6,5 +6,12 @@ enable = true; certificateScheme = "selfsigned"; }; + + # Enable more verbose logging (required for, e.g., sieve testing) + services.dovecot2.extraConfig = lib.mkAfter '' + mail_debug = yes + auth_debug = yes + verbose_ssl = yes + ''; }; } diff --git a/tests/rspamd.nix b/tests/rspamd.nix index ef8e6ea..eb71ed6 100644 --- a/tests/rspamd.nix +++ b/tests/rspamd.nix @@ -152,8 +152,11 @@ in client.succeed("${test-mark-spam "normal2"} >&2") server.wait_until_succeeds("journalctl -u dovecot2 | grep -i learn-spam.sh >&2") + server.fail("journalctl -u dovecot2 | grep -i learn-spam.sh | grep -i error >&2") + client.succeed("${test-mark-ham "normal2"} >&2") server.wait_until_succeeds("journalctl -u dovecot2 | grep -i learn-ham.sh >&2") + server.fail("journalctl -u dovecot2 | grep -i learn-ham.sh | grep -i error >&2") with subtest("dkim signing"): client.succeed("${sendMail "normal2" "" accounts."normal".address '' From 95d920ae6717833df6907e00ac1c6c263a66e793 Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sun, 10 Aug 2025 18:36:31 +0200 Subject: [PATCH 61/61] tests: common: server: Prevent tests deadlocking on DNS requests --- tests/common/server.nix | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/common/server.nix b/tests/common/server.nix index 34476df..561d1bb 100644 --- a/tests/common/server.nix +++ b/tests/common/server.nix @@ -7,6 +7,15 @@ certificateScheme = "selfsigned"; }; + # Prevent tests deadlocking on DNS requests which will never succeed. + services.rspamd.locals."options.inc".text = '' + dns { + nameservers = ["127.0.0.1"]; + timeout = 0.0s; + retransmits = 0; + } + ''; + # Enable more verbose logging (required for, e.g., sieve testing) services.dovecot2.extraConfig = lib.mkAfter '' mail_debug = yes