diff --git a/modules/default.nix b/modules/default.nix index f5212b9..430d3df 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -30,7 +30,7 @@ ./initrd-ssh.nix ./locales.nix ./logitech.nix - ./mailserver.nix + ./mailserver ./media-mount.nix ./media-proxy.nix ./mullvad diff --git a/modules/mailserver.nix b/modules/mailserver.nix deleted file mode 100644 index fdda31b..0000000 --- a/modules/mailserver.nix +++ /dev/null @@ -1,599 +0,0 @@ -{ config, lib, pkgs, ... }: -let - cfg = config.sbruder.mailserver; - - certDir = config.security.acme.certs."${cfg.fqdn}".directory; -in -{ - options.sbruder.mailserver = with lib; with lib.types; { - enable = mkEnableOption "simple mail server"; - fqdn = mkOption { - type = str; - description = '' - FQDN of the mail server - - It needs to have a matching reverse DNS record. Also, an acme - certificate with this name has to be present. - ''; - example = "mail.example.com"; - }; - storage = mkOption { - type = path; - description = "Location of the storage for mails"; - default = "/var/vmail"; - }; - domains = mkOption { - type = listOf str; - description = "Domains to serve"; - example = [ "example.com" "example.org" ]; - }; - autoconfig = { - enable = mkEnableOption "autoconfiguration of compatible clients. Requires autoconfig. to exist for all specified domains"; - domain = mkOption { - type = str; - description = "Domain of the mail system."; - example = "example.com"; - }; - displayName = mkOption { - type = str; - description = "Name of the mail system."; - default = cfg.autoconfig.domain; - }; - displayShortName = mkOption { - type = str; - description = "Short name of the mail system."; - default = cfg.autoconfig.displayName; - }; - }; - users = mkOption { - type = listOf (submodule { - options = { - address = mkOption { - type = str; - description = "Primary e-mail address of the user"; - example = "jdoe@example.com"; - }; - passwordHash = mkOption { - type = str; - description = '' - Bcrypt hash of the user’s password. Please note that it will be - world-readable in the nix store. - - You can generate a password with `nix run nixpkgs.apacheHttpd -c - htpasswd -nBC 12 "" | cut -d: -f2` - ''; - example = "$2y$05$SHxhwVGx.XCd19HAcb1NKuidUxW1BwU7GeO0ZIcMTc5t2uZoYLVRK"; - }; - aliases = mkOption { - type = listOf str; - description = '' - A list of aliases for the user. - - If multiple users have the same alias defined, mail will be - delivered to both of them. - ''; - default = [ ]; - example = [ - "j.doe@example.com" - "jane.doe@example.com" - "postmaster@example.com" - ]; - }; - }; - }); - description = "Users of the mail server"; - }; - cleanHeaders = mkOption { - type = listOf str; - description = "A list of regular expressions that define what headers are filtered"; - default = [ - "/^\\s*Received:/" - "/^\\s*User-Agent:/" - "/^\\s*X-Mailer:/" - "/^\\s*X-Originating-IP:/" - ]; - }; - rejectSenders = mkOption { - type = listOf str; - description = "A list of senders to reject mails from"; - default = [ ]; - example = [ - "newsletter@example.com" - "spammer@example.com" - ]; - }; - spam = { - enable = (lib.mkEnableOption "spam filtering") // { default = true; }; - }; - dkim = { - enable = (lib.mkEnableOption "DKIM signing") // { default = true; }; - selector = lib.mkOption { - type = str; - description = "DKIM Selector to use"; - default = "mail"; - }; - }; - }; - - config = lib.mkIf cfg.enable { - # Users and groups - users.users.vmail = { - uid = 10000; - group = "vmail"; - home = cfg.storage; - createHome = true; - }; - - users.groups.vmail.gid = 10000; - - # Firewall - networking.firewall.allowedTCPPorts = [ - 143 # IMAP - 993 # IMAP (implicit TLS) - 25 # SMTP - 587 # SMTP submission - 465 # SMTP submission (implicit TLS) - ]; - - # Service dependencies - systemd.services.dovecot2 = { - wants = [ "acme-finished-${cfg.fqdn}.target" ]; - after = [ "acme-finished-${cfg.fqdn}.target" ]; - }; - systemd.services.postfix = { - wants = [ "acme-finished-${cfg.fqdn}.target" ]; - requires = [ "dovecot2.service" ]; - after = [ "acme-finished-${cfg.fqdn}.target" "dovecot2.service" ]; - }; - - # Reload on certificate renewal - security.acme.certs."${cfg.fqdn}".postRun = '' - if systemctl is-active dovecot2; then - systemctl --no-block reload dovecot2 - fi - if systemctl is-active postfix; then - systemctl --no-block reload postfix - fi - ''; - - # Postfix - security.dhparams.params.postfix = { }; - services.postfix = - let - listToString = lib.concatStringsSep ","; - - valiases = - let - # List of attribute sets with single key-value pair - plainAliases = (lib.flatten - (map - ({ address, aliases, ... }: - map - (alias: { "${alias}" = address; }) - (aliases ++ lib.singleton address)) - cfg.users)); - - # Attribute set with every alias mapped to a list of receivers - mergedAliases = (lib.attrsets.foldAttrs - (val: col: lib.singleton val ++ col) - [ ] - plainAliases); - - # Contents of the aliases file - aliasesString = (lib.concatStringsSep - "\n" - (lib.mapAttrsToList - (alias: addresses: "${alias} ${listToString addresses}") - mergedAliases)); - in - pkgs.writeText - "valiases" - aliasesString; - - access_sender = pkgs.writeText - "access_sender" - (lib.concatMapStringsSep - "\n" - (sender: "${sender} REJECT") - cfg.rejectSenders); - - submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" - (lib.concatMapStringsSep - "\n" - (regex: "${regex} IGNORE") - cfg.cleanHeaders); - in - { - enable = true; - - enableSubmission = true; # plain/STARTTLS (latter is forced in submissionOptions) - enableSubmissions = true; # submission with implicit TLS (TCP/465) - - hostname = cfg.fqdn; - networksStyle = "host"; - sslCert = "${certDir}/fullchain.pem"; - sslKey = "${certDir}/key.pem"; - - recipientDelimiter = "+"; - - mapFiles = { - inherit access_sender valiases; - }; - - config = { - # General - smtpd_banner = "${cfg.fqdn} ESMTP NO UCE"; - disable_vrfy_command = true; # disable check if mailbox exists - enable_long_queue_ids = true; # better for debugging - strict_rfc821_envelopes = true; # only accept properly formatted envelope - message_size_limit = "50331648"; # 48 MiB - - virtual_mailbox_domains = listToString cfg.domains; - virtual_mailbox_maps = "hash:/var/lib/postfix/conf/valiases"; - virtual_alias_maps = "hash:/var/lib/postfix/conf/valiases"; - virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp"; - - smtpd_recipient_restrictions = listToString [ - "reject_non_fqdn_recipient" - "reject_rbl_client ix.dnsbl.manitu.net" - "reject_unknown_recipient_domain" - "reject_unverified_recipient" - ]; - - smtpd_client_restrictions = listToString [ - "reject_rbl_client ix.dnsbl.manitu.net" - "reject_unknown_client_hostname" - ]; - - smtpd_sender_restrictions = listToString [ - "check_sender_access hash:/var/lib/postfix/conf/access_sender" - "reject_non_fqdn_sender" - "reject_unknown_sender_domain" - ]; - - # generated 2021-02-04, Mozilla Guideline v5.6, Postfix 3.5.6, OpenSSL 1.1.1i, intermediate configuration - # https://ssl-config.mozilla.org/#server=postfix&version=3.5.6&config=intermediate&openssl=1.1.1i&guideline=5.6 - smtpd_tls_security_level = "may"; - smtpd_tls_auth_only = "yes"; - smtpd_tls_mandatory_protocols = "!SSLv2, !SSLv3, !TLSv1, !TLSv1.1"; - smtpd_tls_protocols = "!SSLv2, !SSLv3, !TLSv1, !TLSv1.1"; - smtpd_tls_mandatory_ciphers = "medium"; - smtpd_tls_loglevel = "1"; - - tls_medium_cipherlist = listToString [ - "ECDHE-ECDSA-AES128-GCM-SHA256" - "ECDHE-RSA-AES128-GCM-SHA256" - "ECDHE-ECDSA-AES256-GCM-SHA384" - "ECDHE-RSA-AES256-GCM-SHA384" - "ECDHE-ECDSA-CHACHA20-POLY1305" - "ECDHE-RSA-CHACHA20-POLY1305" - "DHE-RSA-AES128-GCM-SHA256" - "DHE-RSA-AES256-GCM-SHA384" - ]; - tls_preempt_cipherlist = "no"; - - smtpd_tls_dh1024_param_file = config.security.dhparams.params.postfix.path; - - smtpd_milters = lib.mkIf cfg.dkim.enable (lib.singleton "unix:/run/opendkim/opendkim.sock"); - non_smtpd_milters = lib.mkIf cfg.dkim.enable (lib.singleton "unix:/run/opendkim/opendkim.sock"); - }; - - # plain/STARTTLS (forced with smtpd_tls_security_level) - submissionOptions = { - smtpd_tls_security_level = "encrypt"; - smtpd_sasl_auth_enable = "yes"; - smtpd_sasl_type = "dovecot"; - smtpd_sasl_path = "/run/dovecot2/auth"; - - smtpd_sender_login_maps = "hash:/etc/postfix/valiases"; - - smtpd_recipient_restrictions = listToString [ ]; - - smtpd_client_restrictions = listToString [ - "permit_sasl_authenticated" - "reject" - ]; - - smtpd_sender_restrictions = listToString [ - "reject_sender_login_mismatch" - ]; - - cleanup_service_name = "submission-header-cleanup"; - }; - # implicit TLS - submissionsOptions = config.services.postfix.submissionOptions; - - masterConfig = { - submission-header-cleanup = { - private = false; - maxproc = 0; - command = "cleanup"; - args = [ "-o" "header_checks=pcre:${submissionHeaderCleanupRules}" ]; - }; - }; - }; - - # Dovecot - services.dovecot2 = - let - postfixCfg = config.services.postfix; - - passdb = pkgs.writeText "dovecot-users" - (lib.concatMapStringsSep - "\n" - ({ address, passwordHash, ... }: "${address}:{BLF-CRYPT}${passwordHash}") - cfg.users); - in - { - enable = true; - - modules = with pkgs; [ dovecot_pigeonhole ]; - - enableLmtp = true; - enablePAM = false; - - mailUser = "vmail"; - mailGroup = "vmail"; - mailLocation = "maildir:${cfg.storage}/%d/%n"; - - sslServerCert = "${certDir}/fullchain.pem"; - sslServerKey = "${certDir}/key.pem"; - - mailboxes = { - Archive = { specialUse = "Archive"; auto = "subscribe"; }; - Sent = { specialUse = "Sent"; auto = "subscribe"; }; - Drafts = { specialUse = "Drafts"; auto = "subscribe"; }; - Trash = { specialUse = "Trash"; auto = "subscribe"; }; - Spam = { specialUse = "Junk"; auto = "subscribe"; }; - }; - - sieveScripts = { - before = pkgs.writeText "spam.sieve" '' - require "fileinto"; - - if header :is "X-Spam" "Yes" { - fileinto "Spam"; - } - ''; - }; - - extraConfig = '' - # generated 2021-02-04, Mozilla Guideline v5.6, Dovecot 2.3.13, OpenSSL 1.1.1i, intermediate configuration - # https://ssl-config.mozilla.org/#server=dovecot&version=2.3.13&config=intermediate&openssl=1.1.1i&guideline=5.6 - ssl = required - ssl_min_protocol = TLSv1.2 - ssl_cipher_list = ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 - ssl_prefer_server_ciphers = no - - protocol imap { - mail_plugins = $mail_plugins imap_sieve - } - - protocol lmtp { - mail_plugins = $mail_plugins sieve - } - - service imap-login { - inet_listener imap { - } - } - - service lmtp { - unix_listener dovecot-lmtp { - mode = 0600 - user = ${postfixCfg.user} - group = ${postfixCfg.group} - } - } - - passdb { - driver = passwd-file - args = username_format=%u ${passdb} - } - - userdb { - driver = static - args = uid=vmail gid=vmail home=${cfg.storage}/%d/%n - } - - service auth { - unix_listener auth { - mode = 0660 - user = ${postfixCfg.user} - group = ${postfixCfg.group} - } - } - - lda_mailbox_autosubscribe = yes - lda_mailbox_autocreate = yes - - plugin { - sieve_plugins = sieve_imapsieve sieve_extprograms - - ${lib.optionalString cfg.spam.enable '' - imapsieve_mailbox1_name = Spam - imapsieve_mailbox1_causes = COPY - imapsieve_mailbox1_before = file:/var/lib/dovecot/sieve/learn-spam.sieve - - imapsieve_mailbox2_name = * - imapsieve_mailbox2_from = Spam - imapsieve_mailbox2_causes = COPY - imapsieve_mailbox2_before = file:/var/lib/dovecot/sieve/learn-ham.sieve - sieve_pipe_bin_dir = ${pkgs.symlinkJoin { name = "sieve-pipe-bin-dir"; paths = with pkgs; [ rspamd ]; } }/bin - ''} - - sieve_global_extensions = +vnd.dovecot.pipe - } - ''; - }; - systemd.services.dovecot2.preStart = lib.mkIf cfg.spam.enable - (lib.mkAfter - (lib.concatStrings - (lib.mapAttrsToList - (name: content: '' - cp ${pkgs.writeText name content} /var/lib/dovecot/sieve/${name} - '') - { - "learn-spam.sieve" = '' - require ["vnd.dovecot.pipe", "copy", "imapsieve"]; - pipe :copy "rspamc" ["learn_spam"]; - ''; - "learn-ham.sieve" = '' - require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; - - if environment :matches "imap.mailbox" "*" { - set "mailbox" "''${1}"; - } - - if string "''${mailbox}" "Trash" { - stop; - } - - pipe :copy "rspamc" ["learn_ham"]; - ''; - }))); - - # DNS (recursor for DNSBLs etc.) - services.resolved.enable = false; - services.unbound = { - enable = true; - settings = { - server = { - interface = [ "127.0.0.53" ]; - }; - }; - resolveLocalQueries = false; # does not work with .53 - }; - networking.resolvconf.extraConfig = '' - name_servers='127.0.0.53' - ''; - - # rspamd - sops.secrets.rspamd-worker-controller = lib.mkIf cfg.spam.enable { - owner = config.users.users.rspamd.name; - sopsFile = ../machines + "/${config.networking.hostName}/secrets.yaml"; - }; - - services.rspamd = { - enable = cfg.spam.enable; - postfix.enable = true; - workers = { - normal = { - includes = [ "$CONFDIR/worker-normal.inc" ]; - bindSockets = lib.singleton { - socket = "/run/rspamd/rspamd.sock"; - mode = "0660"; - owner = "${config.services.rspamd.user}"; - group = "${config.services.rspamd.group}"; - }; - }; - controller = { - includes = [ "$CONFDIR/worker-controller.inc" ]; - bindSockets = [ "127.0.0.1:11334" ] ++ lib.optional config.sbruder.wireguard.home.enable "${config.sbruder.wireguard.home.address}:11334"; - }; - }; - locals = { - "dkim_signing.conf".text = '' - enabled = false; - ''; - "logging.inc".text = '' - # starts at info, drops to notice once started up - level = "silent"; - ''; - "milter_headers.conf".text = '' - extended_spam_headers = true; - ''; - "multimap.conf".text = '' - SENDER_DOMAIN_BLOCKED { - type = "from"; - filter = "email:domain:tld"; - map = "/var/lib/rspamd/blocked_sender_domains.map"; - symbol = "SENDER_DOMAIN_BLOCKED"; - description = "Sender’s effective second level domain is manually blocked"; - score = 8.0; - } - ''; - "redis.conf".text = '' - servers = "127.0.0.1:${toString config.services.redis.servers.rspamd.port}" - ''; - "worker-controller.inc".source = config.sops.secrets.rspamd-worker-controller.path; # includes password - }; - }; - - services.redis = lib.mkIf cfg.spam.enable { - vmOverCommit = true; - servers.rspamd = { - enable = true; - port = 6379; - }; - }; - - # DKIM - services.opendkim = lib.mkIf cfg.dkim.enable { - enable = true; - selector = cfg.dkim.selector; - domains = "csl:${lib.concatStringsSep "," cfg.domains}"; - configFile = pkgs.writeText "opendkim.conf" '' - UMask 0002 - ''; - }; - systemd.services.opendkim = lib.mkIf cfg.dkim.enable { - # changed to use larger key size - preStart = - let - inherit (config.services.opendkim) keyPath selector; - in - lib.mkForce '' - cd "${keyPath}" - if ! test -f ${selector}.private; then - ${pkgs.opendkim}/bin/opendkim-genkey \ - -s ${selector} \ - -d all-domains-generic-key \ - -b 4096 - echo "Generated OpenDKIM key! Please update your DNS settings:\n" - echo "-------------------------------------------------------------" - cat ${selector}.txt - echo "-------------------------------------------------------------" - fi - ''; - }; - - users.users.postfix.extraGroups = lib.mkIf cfg.dkim.enable (lib.singleton config.users.users.opendkim.group); - - # Autoconfig - services.nginx = lib.mkIf cfg.autoconfig.enable { - enable = true; - virtualHosts = lib.listToAttrs (map - (domain: lib.nameValuePair "autoconfig.${domain}" { - enableACME = true; - forceSSL = true; - - locations."=/mail/config-v1.1.xml".alias = pkgs.writeText "config-v1.1.xml" '' - - - - ${lib.escapeXML cfg.autoconfig.domain} - ${lib.escapeXML cfg.autoconfig.displayName} - ${lib.escapeXML cfg.autoconfig.displayShortName} - - ${lib.escapeXML cfg.fqdn} - 993 - SSL - password-cleartext - %EMAILADDRESS% - - - ${lib.escapeXML cfg.fqdn} - 465 - SSL - password-cleartext - %EMAILADDRESS% - - - - ''; - }) - cfg.domains); - }; - }; -} diff --git a/modules/mailserver/autoconfig.nix b/modules/mailserver/autoconfig.nix new file mode 100644 index 0000000..281a13f --- /dev/null +++ b/modules/mailserver/autoconfig.nix @@ -0,0 +1,63 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.sbruder.mailserver; + + configFile = pkgs.writeText "config-v1.1.xml" '' + + + + ${lib.escapeXML cfg.autoconfig.domain} + ${lib.escapeXML cfg.autoconfig.displayName} + ${lib.escapeXML cfg.autoconfig.displayShortName} + + ${lib.escapeXML cfg.fqdn} + 993 + SSL + password-cleartext + %EMAILADDRESS% + + + ${lib.escapeXML cfg.fqdn} + 465 + SSL + password-cleartext + %EMAILADDRESS% + + + + ''; +in +{ + options.sbruder.mailserver.autoconfig = { + enable = lib.mkEnableOption "autoconfiguration of compatible clients. Requires autoconfig. to exist for all specified domains"; + domain = lib.mkOption { + type = lib.types.str; + description = "Domain of the mail system."; + example = "example.com"; + }; + displayName = lib.mkOption { + type = lib.types.str; + description = "Name of the mail system."; + default = cfg.autoconfig.domain; + }; + displayShortName = lib.mkOption { + type = lib.types.str; + description = "Short name of the mail system."; + default = cfg.autoconfig.displayName; + }; + }; + + config = lib.mkIf cfg.enable { + services.nginx = lib.mkIf cfg.autoconfig.enable { + enable = true; + virtualHosts = lib.listToAttrs (map + (domain: lib.nameValuePair "autoconfig.${domain}" { + enableACME = true; + forceSSL = true; + + locations."=/mail/config-v1.1.xml".alias = configFile; + }) + cfg.domains); + }; + }; +} diff --git a/modules/mailserver/default.nix b/modules/mailserver/default.nix new file mode 100644 index 0000000..de75815 --- /dev/null +++ b/modules/mailserver/default.nix @@ -0,0 +1,102 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.sbruder.mailserver; +in +{ + options.sbruder.mailserver = with lib; with lib.types; { + enable = mkEnableOption "simple mail server"; + fqdn = mkOption { + type = str; + description = '' + FQDN of the mail server + + It needs to have a matching reverse DNS record. + By default, an acme certificate with this name has to be present. + See `certDir` for more details. + ''; + example = "mail.example.com"; + }; + storage = mkOption { + type = path; + description = "Location of the storage for mails"; + default = "/var/vmail"; + }; + domains = mkOption { + type = listOf str; + description = "Domains to serve"; + example = [ "example.com" "example.org" ]; + }; + certDir = mkOption { + type = path; + description = "Directory with `fullchain.pem` and `key.pem` for the FQDN. Defaults to the ACME directory of the FQDN."; + default = config.security.acme.certs."${cfg.fqdn}".directory; + }; + users = mkOption { + type = listOf (submodule { + options = { + address = mkOption { + type = str; + description = "Primary e-mail address of the user"; + example = "jdoe@example.com"; + }; + passwordHash = mkOption { + type = str; + description = '' + Bcrypt hash of the user’s password. Please note that it will be + world-readable in the nix store. + + You can generate a password with `nix run nixpkgs.apacheHttpd -c + htpasswd -nBC 12 "" | cut -d: -f2` + ''; + example = "$2y$05$SHxhwVGx.XCd19HAcb1NKuidUxW1BwU7GeO0ZIcMTc5t2uZoYLVRK"; + }; + aliases = mkOption { + type = listOf str; + description = '' + A list of aliases for the user. + + If multiple users have the same alias defined, mail will be + delivered to both of them. + ''; + default = [ ]; + example = [ + "j.doe@example.com" + "jane.doe@example.com" + "postmaster@example.com" + ]; + }; + }; + }); + description = "Users of the mail server"; + }; + cleanHeaders = mkOption { + type = listOf str; + description = "A list of regular expressions that define what headers are filtered"; + default = [ + "/^\\s*Received:/" + "/^\\s*User-Agent:/" + "/^\\s*X-Mailer:/" + "/^\\s*X-Originating-IP:/" + ]; + }; + rejectSenders = mkOption { + type = listOf str; + description = "A list of senders to reject mails from"; + default = [ ]; + example = [ + "newsletter@example.com" + "spammer@example.com" + ]; + }; + }; + + imports = [ + ./autoconfig.nix + ./dkim.nix + ./dns.nix + ./dovecot.nix + ./postfix.nix + ./rspamd.nix + ./users.nix + ]; +} diff --git a/modules/mailserver/dkim.nix b/modules/mailserver/dkim.nix new file mode 100644 index 0000000..36b4922 --- /dev/null +++ b/modules/mailserver/dkim.nix @@ -0,0 +1,52 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.sbruder.mailserver; +in +{ + options.sbruder.mailserver.dkim = { + enable = (lib.mkEnableOption "DKIM signing") // { default = true; }; + selector = lib.mkOption { + type = lib.types.str; + description = "DKIM Selector to use"; + default = "mail"; + }; + }; + + config = lib.mkIf (cfg.enable && cfg.dkim.enable) { + services.opendkim = { + enable = true; + selector = cfg.dkim.selector; + domains = "csl:${lib.concatStringsSep "," cfg.domains}"; + configFile = pkgs.writeText "opendkim.conf" '' + UMask 0002 + ''; + }; + systemd.services.opendkim = { + # changed to use larger key size + preStart = + let + inherit (config.services.opendkim) keyPath selector; + in + lib.mkForce '' + cd "${keyPath}" + if ! test -f ${selector}.private; then + ${pkgs.opendkim}/bin/opendkim-genkey \ + -s ${selector} \ + -d all-domains-generic-key \ + -b 4096 + echo "Generated OpenDKIM key! Please update your DNS settings:\n" + echo "-------------------------------------------------------------" + cat ${selector}.txt + echo "-------------------------------------------------------------" + fi + ''; + }; + + users.users.postfix.extraGroups = lib.mkIf cfg.dkim.enable (lib.singleton config.users.users.opendkim.group); + + services.postfix.config = { + smtpd_milters = lib.singleton "unix:/run/opendkim/opendkim.sock"; + non_smtpd_milters = lib.singleton "unix:/run/opendkim/opendkim.sock"; + }; + }; +} diff --git a/modules/mailserver/dns.nix b/modules/mailserver/dns.nix new file mode 100644 index 0000000..16f4396 --- /dev/null +++ b/modules/mailserver/dns.nix @@ -0,0 +1,18 @@ +{ config, lib, ... }: + +lib.mkIf config.sbruder.mailserver.enable { + # Enable recursor for DNSBLs etc. + services.resolved.enable = false; + services.unbound = { + enable = true; + settings = { + server = { + interface = [ "127.0.0.53" ]; + }; + }; + resolveLocalQueries = false; # does not work with .53 + }; + networking.resolvconf.extraConfig = '' + name_servers='127.0.0.53' + ''; +} diff --git a/modules/mailserver/dovecot.nix b/modules/mailserver/dovecot.nix new file mode 100644 index 0000000..0207069 --- /dev/null +++ b/modules/mailserver/dovecot.nix @@ -0,0 +1,157 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.sbruder.mailserver; + postfixCfg = config.services.postfix; + + passdb = pkgs.writeText "dovecot-users" + (lib.concatMapStringsSep + "\n" + ({ address, passwordHash, ... }: "${address}:{BLF-CRYPT}${passwordHash}") + cfg.users); +in +lib.mkIf cfg.enable { + services.dovecot2 = { + enable = true; + + modules = with pkgs; [ dovecot_pigeonhole ]; + + enableLmtp = true; + enablePAM = false; + + mailUser = "vmail"; + mailGroup = "vmail"; + mailLocation = "maildir:${cfg.storage}/%d/%n"; + + sslServerCert = "${cfg.certDir}/fullchain.pem"; + sslServerKey = "${cfg.certDir}/key.pem"; + + mailboxes = { + Archive = { specialUse = "Archive"; auto = "subscribe"; }; + Sent = { specialUse = "Sent"; auto = "subscribe"; }; + Drafts = { specialUse = "Drafts"; auto = "subscribe"; }; + Trash = { specialUse = "Trash"; auto = "subscribe"; }; + Spam = { specialUse = "Junk"; auto = "subscribe"; }; + }; + + sieveScripts = { + before = pkgs.writeText "spam.sieve" '' + require "fileinto"; + + if header :is "X-Spam" "Yes" { + fileinto "Spam"; + } + ''; + }; + + extraConfig = '' + # generated 2021-02-04, Mozilla Guideline v5.6, Dovecot 2.3.13, OpenSSL 1.1.1i, intermediate configuration + # https://ssl-config.mozilla.org/#server=dovecot&version=2.3.13&config=intermediate&openssl=1.1.1i&guideline=5.6 + ssl = required + ssl_min_protocol = TLSv1.2 + ssl_cipher_list = ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 + ssl_prefer_server_ciphers = no + + protocol imap { + mail_plugins = $mail_plugins imap_sieve + } + + protocol lmtp { + mail_plugins = $mail_plugins sieve + } + + service imap-login { + inet_listener imap { + } + } + + service lmtp { + unix_listener dovecot-lmtp { + mode = 0600 + user = ${postfixCfg.user} + group = ${postfixCfg.group} + } + } + + passdb { + driver = passwd-file + args = username_format=%u ${passdb} + } + + userdb { + driver = static + args = uid=vmail gid=vmail home=${cfg.storage}/%d/%n + } + + service auth { + unix_listener auth { + mode = 0660 + user = ${postfixCfg.user} + group = ${postfixCfg.group} + } + } + + lda_mailbox_autosubscribe = yes + lda_mailbox_autocreate = yes + + plugin { + sieve_plugins = sieve_imapsieve sieve_extprograms + + ${lib.optionalString cfg.spam.enable '' + imapsieve_mailbox1_name = Spam + imapsieve_mailbox1_causes = COPY + imapsieve_mailbox1_before = file:/var/lib/dovecot/sieve/learn-spam.sieve + + imapsieve_mailbox2_name = * + imapsieve_mailbox2_from = Spam + imapsieve_mailbox2_causes = COPY + imapsieve_mailbox2_before = file:/var/lib/dovecot/sieve/learn-ham.sieve + sieve_pipe_bin_dir = ${pkgs.symlinkJoin { name = "sieve-pipe-bin-dir"; paths = with pkgs; [ rspamd ]; } }/bin + ''} + + sieve_global_extensions = +vnd.dovecot.pipe + } + ''; + }; + systemd.services.dovecot2 = { + wants = [ "acme-finished-${cfg.fqdn}.target" ]; + after = [ "acme-finished-${cfg.fqdn}.target" ]; + + preStart = lib.mkIf cfg.spam.enable + (lib.mkAfter + (lib.concatStrings + (lib.mapAttrsToList + (name: content: '' + cp ${pkgs.writeText name content} /var/lib/dovecot/sieve/${name} + '') + { + "learn-spam.sieve" = '' + require ["vnd.dovecot.pipe", "copy", "imapsieve"]; + pipe :copy "rspamc" ["learn_spam"]; + ''; + "learn-ham.sieve" = '' + require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; + + if environment :matches "imap.mailbox" "*" { + set "mailbox" "''${1}"; + } + + if string "''${mailbox}" "Trash" { + stop; + } + + pipe :copy "rspamc" ["learn_ham"]; + ''; + }))); + }; + + networking.firewall.allowedTCPPorts = [ + 143 # IMAP + 993 # IMAP (implicit TLS) + ]; + + security.acme.certs."${cfg.fqdn}".postRun = '' + if systemctl is-active dovecot2; then + systemctl --no-block reload dovecot2 + fi + ''; +} diff --git a/modules/mailserver/postfix.nix b/modules/mailserver/postfix.nix new file mode 100644 index 0000000..2dbd116 --- /dev/null +++ b/modules/mailserver/postfix.nix @@ -0,0 +1,170 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.sbruder.mailserver; + + listToString = lib.concatStringsSep ","; + + # List of attribute sets with single key-value pair + plainAliases = (lib.flatten + (map + ({ address, aliases, ... }: + map + (alias: { "${alias}" = address; }) + (aliases ++ lib.singleton address)) + cfg.users)); + + # Attribute set with every alias mapped to a list of receivers + mergedAliases = (lib.attrsets.foldAttrs + (val: col: lib.singleton val ++ col) + [ ] + plainAliases); + + # Contents of the aliases file + aliasesString = (lib.concatStringsSep + "\n" + (lib.mapAttrsToList + (alias: addresses: "${alias} ${listToString addresses}") + mergedAliases)); + + valiases = pkgs.writeText "valiases" aliasesString; + + access_sender = pkgs.writeText + "access_sender" + (lib.concatMapStringsSep + "\n" + (sender: "${sender} REJECT") + cfg.rejectSenders); + + submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" + (lib.concatMapStringsSep + "\n" + (regex: "${regex} IGNORE") + cfg.cleanHeaders); +in +lib.mkIf cfg.enable { + security.dhparams.params.postfix = { }; + services.postfix = { + enable = true; + + enableSubmission = true; # plain/STARTTLS (latter is forced in submissionOptions) + enableSubmissions = true; # submission with implicit TLS (TCP/465) + + hostname = cfg.fqdn; + networksStyle = "host"; + sslCert = "${cfg.certDir}/fullchain.pem"; + sslKey = "${cfg.certDir}/key.pem"; + + recipientDelimiter = "+"; + + mapFiles = { + inherit access_sender valiases; + }; + + config = { + # General + smtpd_banner = "${cfg.fqdn} ESMTP NO UCE"; + disable_vrfy_command = true; # disable check if mailbox exists + enable_long_queue_ids = true; # better for debugging + strict_rfc821_envelopes = true; # only accept properly formatted envelope + message_size_limit = "50331648"; # 48 MiB + + virtual_mailbox_domains = listToString cfg.domains; + virtual_mailbox_maps = "hash:/var/lib/postfix/conf/valiases"; + virtual_alias_maps = "hash:/var/lib/postfix/conf/valiases"; + virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp"; + + smtpd_recipient_restrictions = listToString [ + "reject_non_fqdn_recipient" + "reject_rbl_client ix.dnsbl.manitu.net" + "reject_unknown_recipient_domain" + "reject_unverified_recipient" + ]; + + smtpd_client_restrictions = listToString [ + "reject_rbl_client ix.dnsbl.manitu.net" + "reject_unknown_client_hostname" + ]; + + smtpd_sender_restrictions = listToString [ + "check_sender_access hash:/var/lib/postfix/conf/access_sender" + "reject_non_fqdn_sender" + "reject_unknown_sender_domain" + ]; + + # generated 2021-02-04, Mozilla Guideline v5.6, Postfix 3.5.6, OpenSSL 1.1.1i, intermediate configuration + # https://ssl-config.mozilla.org/#server=postfix&version=3.5.6&config=intermediate&openssl=1.1.1i&guideline=5.6 + smtpd_tls_security_level = "may"; + smtpd_tls_auth_only = "yes"; + smtpd_tls_mandatory_protocols = "!SSLv2, !SSLv3, !TLSv1, !TLSv1.1"; + smtpd_tls_protocols = "!SSLv2, !SSLv3, !TLSv1, !TLSv1.1"; + smtpd_tls_mandatory_ciphers = "medium"; + smtpd_tls_loglevel = "1"; + + tls_medium_cipherlist = listToString [ + "ECDHE-ECDSA-AES128-GCM-SHA256" + "ECDHE-RSA-AES128-GCM-SHA256" + "ECDHE-ECDSA-AES256-GCM-SHA384" + "ECDHE-RSA-AES256-GCM-SHA384" + "ECDHE-ECDSA-CHACHA20-POLY1305" + "ECDHE-RSA-CHACHA20-POLY1305" + "DHE-RSA-AES128-GCM-SHA256" + "DHE-RSA-AES256-GCM-SHA384" + ]; + tls_preempt_cipherlist = "no"; + + smtpd_tls_dh1024_param_file = config.security.dhparams.params.postfix.path; + }; + + # plain/STARTTLS (forced with smtpd_tls_security_level) + submissionOptions = { + smtpd_tls_security_level = "encrypt"; + smtpd_sasl_auth_enable = "yes"; + smtpd_sasl_type = "dovecot"; + smtpd_sasl_path = "/run/dovecot2/auth"; + + smtpd_sender_login_maps = "hash:/etc/postfix/valiases"; + + smtpd_recipient_restrictions = listToString [ ]; + + smtpd_client_restrictions = listToString [ + "permit_sasl_authenticated" + "reject" + ]; + + smtpd_sender_restrictions = listToString [ + "reject_sender_login_mismatch" + ]; + + cleanup_service_name = "submission-header-cleanup"; + }; + # implicit TLS + submissionsOptions = config.services.postfix.submissionOptions; + + masterConfig = { + submission-header-cleanup = { + private = false; + maxproc = 0; + command = "cleanup"; + args = [ "-o" "header_checks=pcre:${submissionHeaderCleanupRules}" ]; + }; + }; + }; + + networking.firewall.allowedTCPPorts = [ + 25 # SMTP + 587 # SMTP submission + 465 # SMTP submission (implicit TLS) + ]; + + systemd.services.postfix = { + wants = [ "acme-finished-${cfg.fqdn}.target" ]; + requires = [ "dovecot2.service" ]; + after = [ "acme-finished-${cfg.fqdn}.target" "dovecot2.service" ]; + }; + + security.acme.certs."${cfg.fqdn}".postRun = '' + if systemctl is-active postfix; then + systemctl --no-block reload postfix + fi + ''; +} diff --git a/modules/mailserver/rspamd.nix b/modules/mailserver/rspamd.nix new file mode 100644 index 0000000..82866ee --- /dev/null +++ b/modules/mailserver/rspamd.nix @@ -0,0 +1,70 @@ +{ config, lib, ... }: +let + cfg = config.sbruder.mailserver; +in +{ + options.sbruder.mailserver.spam = { + enable = (lib.mkEnableOption "spam filtering") // { default = true; }; + }; + + config = lib.mkIf (cfg.enable && cfg.spam.enable) { + sops.secrets.rspamd-worker-controller = { + owner = config.users.users.rspamd.name; + sopsFile = ../../machines + "/${config.networking.hostName}/secrets.yaml"; + }; + + services.rspamd = { + enable = true; + postfix.enable = true; + workers = { + normal = { + includes = [ "$CONFDIR/worker-normal.inc" ]; + bindSockets = lib.singleton { + socket = "/run/rspamd/rspamd.sock"; + mode = "0660"; + owner = "${config.services.rspamd.user}"; + group = "${config.services.rspamd.group}"; + }; + }; + controller = { + includes = [ "$CONFDIR/worker-controller.inc" ]; + bindSockets = [ "127.0.0.1:11334" ] ++ lib.optional config.sbruder.wireguard.home.enable "${config.sbruder.wireguard.home.address}:11334"; + }; + }; + locals = { + "dkim_signing.conf".text = '' + enabled = false; + ''; + "logging.inc".text = '' + # starts at info, drops to notice once started up + level = "silent"; + ''; + "milter_headers.conf".text = '' + extended_spam_headers = true; + ''; + "multimap.conf".text = '' + SENDER_DOMAIN_BLOCKED { + type = "from"; + filter = "email:domain:tld"; + map = "/var/lib/rspamd/blocked_sender_domains.map"; + symbol = "SENDER_DOMAIN_BLOCKED"; + description = "Sender’s effective second level domain is manually blocked"; + score = 8.0; + } + ''; + "redis.conf".text = '' + servers = "127.0.0.1:${toString config.services.redis.servers.rspamd.port}" + ''; + "worker-controller.inc".source = config.sops.secrets.rspamd-worker-controller.path; # includes password + }; + }; + + services.redis = { + vmOverCommit = true; + servers.rspamd = { + enable = true; + port = 6379; + }; + }; + }; +} diff --git a/modules/mailserver/users.nix b/modules/mailserver/users.nix new file mode 100644 index 0000000..a38bef6 --- /dev/null +++ b/modules/mailserver/users.nix @@ -0,0 +1,12 @@ +{ config, lib, ... }: + +lib.mkIf config.sbruder.mailserver.enable { + users.users.vmail = { + uid = 10000; + group = "vmail"; + home = config.sbruder.mailserver.storage; + createHome = true; + }; + + users.groups.vmail.gid = 10000; +}