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