From bc01d4d2d03c1d7e52c81739f81060a527f3e9ac Mon Sep 17 00:00:00 2001 From: Thomas Preisner Date: Sat, 30 Nov 2024 19:35:15 +0100 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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";