From 49897ba6fe0580ff1a0d2afee1345c71e331271a Mon Sep 17 00:00:00 2001 From: niten Date: Sun, 17 Sep 2023 09:57:55 -0700 Subject: [PATCH] Initial checkin --- clamav.nix | 58 ++++++ dkim.nix | 89 ++++++++++ dovecot.nix | 440 ++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 17 ++ mail-server.nix | 291 ++++++++++++++++++++++++++++++ postfix.nix | 381 +++++++++++++++++++++++++++++++++++++++ rspamd.nix | 94 ++++++++++ sieves/ham.sieve | 19 ++ sieves/spam.sieve | 7 + 9 files changed, 1396 insertions(+) create mode 100644 clamav.nix create mode 100644 dkim.nix create mode 100644 dovecot.nix create mode 100644 flake.nix create mode 100644 mail-server.nix create mode 100644 postfix.nix create mode 100644 rspamd.nix create mode 100644 sieves/ham.sieve create mode 100644 sieves/spam.sieve diff --git a/clamav.nix b/clamav.nix new file mode 100644 index 0000000..a91ea75 --- /dev/null +++ b/clamav.nix @@ -0,0 +1,58 @@ +{ config, lib, pkgs, ... }: + +with lib; +let cfg = config.fudo.mail.clamav; + +in { + options.fudo.mail.clamav = with types; { + enable = mkEnableOption "Enable virus scanning with ClamAV."; + + state-directory = mkOption { + type = str; + description = "Path at which to store ClamAV database."; + default = "/var/lib/clamav"; + }; + + port = mkOption { + type = port; + description = "Port on which to listen for incoming requests."; + default = 15407; + }; + }; + + config = mkIf cfg.enable { + users = { + users.clamav = { + isSystemUser = true; + uid = config.ids.uids.clamav; + home = mkForce cfg.state-directory; + description = "ClamAV daemon user"; + group = "clamav"; + }; + groups.clamav = { + members = [ "clamav" ]; + gid = config.ids.gids.clamav; + }; + }; + + systemd.tmpfiles.rules = + [ "d ${cfg.state-directory} 0750 clamav clamav - -" ]; + + services.clamav = { + enable = true; + settings = { + PhishingScanURLs = "no"; + DatabaseDirectory = mkForce cfg.state-directory; + User = "clavmav"; + TCPSocket = cfg.port; + }; + }; + updater = { + enable = true; + settings = { + DatabaseDirectory = mkForce cfg.state-directory; + DatabaseOwner = "clamav"; + }; + }; + }; +} diff --git a/dkim.nix b/dkim.nix new file mode 100644 index 0000000..5a56d22 --- /dev/null +++ b/dkim.nix @@ -0,0 +1,89 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.fudo.mail.dkim; + + ensureDomainDkimCert = keyDir: domain: + let + dkimKey = "${keyDir}/${domain}.mail.key"; + dkimTxt = "${keyDir}/${domain}.mail.txt"; + in '' + if [ ! -f "${dkimKey}" ] || [ ! -f ${dkimTxt} ]; then + opendkim-genkey \ + -s mail \ + -d ${domain} \ + --bits="${toString cfg.key-bits}" \ + --directory=$TMPDIR + mv $TMPDIR/mail.private ${dkimKey} + mv $TMPDIR/mail.txt ${dkimTxt} + fi + ''; + + ensureAllDkimCerts = keyDir: + domains concatStringsSep "\n" (map (ensureDomainDkimCert keyDir) domains); + + makeKeyTable = keyDir: domains: + pkgs.writeText "opendkim-key-table" (concatStringsSep "\n" + (map (dom: "${dom}:mail:${keyDir}/${dom}.mail.key"))); + + makeSigningTable = domains: + pkgs.writeText "opendkim-signing-table" + (concatStringsSep "\n" (map (dom: "${dom} ${dom}") domains)); + +in { + options.fudo.mail.dkim = with types; { + enable = mkEnableOption "Enable DKIM signature verification."; + + debug = mkEnableOption "Enable debug logs."; + + domains = mkOption { + type = listOf str; + description = + "List of domains to be considered local, and signed instead of verified."; + }; + + port = mkOption { + type = port; + description = "Port at which to listen for incoming signing requests."; + default = 5324; + }; + + state-directory = mkOption { + type = str; + description = "Directory at which to store DKIM state (i.e. keys)."; + }; + }; + + config = mkIf cfg.enable { + services.opendkim = { + enable = true; + selector = cfg.selector; + domains = let domainString = concatStringsSep "," cfg.domains; + in "csl:${domainsString}"; + configFile = let + debugString = '' + Syslog yes + SyslogSuccess yes + LogWhy yes + ''; + in pkgs.writeText "opendkim.conf" '' + Canonicalization relaxed/simple + Socket inet:${toString cfg.port} + KeyTable file: ${keyTable} + SigningTable file:${signingTable} + ${optionalString cfg.debug debugString} + ''; + }; + + systemd = { + tmpfiles.rules = let + user = config.services.opendkim.user; + group = config.services.opendkim.group; + in [ "d ${cfg.state-directory} 0700 ${user} ${group} - -" ]; + services.opendkim = { + serviceConfig.ReadWritePaths = [ cfg.state-directory ]; + }; + }; + }; +} diff --git a/dovecot.nix b/dovecot.nix new file mode 100644 index 0000000..87c8b78 --- /dev/null +++ b/dovecot.nix @@ -0,0 +1,440 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + sievePath = let + isRegularFile = _: type: type == "regular"; + sieves = filter isRegularFile (builtins.readDir ./sieves); + headOrNull = lst: if lst == [ ] then null else head lst; + stripExt = ext: filename: headOrNull (match "(.+)[.]${ext}$" filename); + compileFile = filename: + let + filePath = ./sieves + "/${filename}"; + fileBaseName = stripExt "sieve" filename; + in "sievec ${filePath} $out/${fileBaseName}.svbin"; + in pkgs.stdenv.mkDerivation { + name = "dovecot-sieves"; + buildInputs = with pkgs; [ dovecot_pidgeonhole ]; + phases = [ "installPhase" ]; + buildPhase = '' + mkdir -p $out + ${concatStringsSep "\n" (map compileFile sieves)} + ''; + }; + +in { + options.fudo.mail.dovecot = with types; { + enable = mkEnableOption "Enable Dovecot2 IMAP server."; + + debug = mkEnableOption "Enable debug logs."; + + state-directory = mkOption { + type = str; + description = "Directory at which to store server state."; + }; + + ports = { + lmtp = mkOption { + type = port; + description = "Port on which to listen for LMTP connections."; + default = 24; + }; + auth = mkOption { + type = port; + description = "Port on which to listen for auth requests."; + default = 5447; + }; + userdb = mkOption { + type = port; + description = "Port on which to listen for userdb requests."; + default = 5448; + }; + metrics = mkOption { + type = port; + description = "Port on which to serve metrics data."; + default = 5034; + }; + + }; + + mail-user = mkOption { + type = str; + description = "User as which to run store & access mail."; + default = "fudo-mail"; + }; + + mail-group = mkOption { + type = str; + description = "Group as which to store & access mail."; + default = "fudo-mail"; + }; + + ssl = { + certificate = mkOption { + type = str; + description = "Location of the Dovecot SSL certificate."; + }; + + private-key = mkOption { + type = str; + description = "Location of the Dovecot SSL private key."; + }; + }; + + metrics = { + user = mkOption { + type = str; + description = "User as which to fetch metrics."; + default = "dovecot-metrics"; + }; + + group = mkOption { + type = str; + description = "Group as which to fetch metrics."; + default = "dovecot-metrics"; + }; + }; + + mailboxes = let + mailboxOpts = { name, ... }: { + options = { + name = mkOption { + type = str; + description = "Mailbox Name."; + default = name; + }; + auto = mkOption { + type = enum [ "no" "create" "subscribe" ]; + description = "Whether to auto-create/subscribe."; + default = "no"; + }; + specialUse = mkOption { + type = nullOr (enum [ + "All" + "Archive" + "Drafts" + "Flagged" + "Junk" + "Sent" + "Trash" + ]); + description = "Mailbox special use."; + default = null; + }; + autoexpunge = mkOption { + type = nullOr str; + description = + "How long to wait before clearing mail from this mailbox. Null is never."; + default = null; + }; + }; + }; + in mkOption { + type = attrsOf (submodule mailboxOpts); + description = "Mailboxes to be created for dovecot."; + default = { + Trash = { + auto = "create"; + specialUse = "Trash"; + autoexpunge = "30d"; + }; + Junk = { + auto = "create"; + specialUse = "Junk"; + autoexpunge = "60d"; + }; + Drafts = { + auto = "create"; + specialUse = "Drafts"; + autoexpunge = "60d"; + }; + Sent = { + auto = "create"; + specialUse = "Sent"; + }; + Archive = { + atuo = "no"; + specialUse = "Archive"; + }; + Flagged = { + auto = "subscribe"; + specialUse = "Flagged"; + }; + }; + }; + + rspamd = { + host = mkOption { + type = str; + description = "Host to which spam/ham will be forwarded."; + }; + port = mkOption { + type = str; + description = "Port to which spam/ham will be forwarded."; + }; + }; + + max-user-connections = mkOption { + type = int; + description = "Maximum allowed simultaneous connections by one user."; + default = 5; + }; + + ldap = let + ldapOpts = { + options = { + host = mkOption { + type = str; + description = "LDAP hostname."; + }; + + port = mkOption { + type = str; + description = "Port on which LDAP is listening."; + }; + + base = mkOption { + type = str; + description = "Base of the LDAP server database."; + example = "dc=mydomain,dc=org"; + }; + + bind-dn = mkOption { + type = str; + description = '' + DN used for fetching user information. + + Needs access to homeDirectory, uidNumber, gidNumber, and uid, but not + password attributes. + ''; + }; + + bind-password-file = mkOption { + type = str; + description = "Path to file containing bind password for bind-dn."; + }; + }; + }; + in mkOption { + type = nullOr ldapOpts; + default = null; + }; + }; + + config = mkIf cfg.enable { + services = { + prometheus.exporters.dovecot = { + enable = true; + scopes = [ "user" "global" ]; + user = cfg.metrics.user; + listenAddresses = "127.0.0.1"; + port = cfg.metrics.port; + socketPath = "/var/run/dovecot2/old-stats"; + }; + }; + + users = { + users = { + "${cfg.mail-user}" = { + isSystemUser = true; + group = cfg.mail-group; + }; + "${cfg.metrics.user}" = { + isSystemUser = true; + group = cfg.metrics.group; + }; + }; + groups = { + "${cfg.mail-group}".members = [ cfg.mail-user ]; + "${cfg.metrics.group}".members = [ cfg.metrics.user ]; + }; + }; + + systemd = { + tmpfiles.rules = [ + "d ${cfg.state-directory} 0750 ${cfg.mail-user} ${cfg.mail-group} - -" + "d ${cfg.state-directory}/mail 0750 ${cfg.mail-user} ${cfg.mail-group} - -" + "d ${cfg.state-directory}/sieves 0750 ${cfg.mail-user} ${cfg.mail-group} - -" + ]; + }; + + dovecot2 = { + enable = true; + enableImap = true; + enableLmtp = true; + enablePAM = cfg.ldap == null; + + mailUser = cfg.mail-user; + mailGroup = cfg.mail-group; + mailLocation = "maildir:${cfg.state-directory}/mail//%u/"; + createMailUser = false; + + sslServerCert = cfg.ssl.certificate; + sslServerKey = cfg.ssl.private-key; + + mailboxes = cfg.mailboxes; + + modules = with pkgs; [ dovecot_pidgeonhole ]; + protocols = [ "sieve" ]; + + mailPlugins.globally.enable = [ "old_stats" ]; + + sieveScripts = { + after = builtins.toFile "spam.sieve" '' + require "fileinto"; + + if header :is "X-Spam" "Yes" { + fileinto "Junk"; + stop; + } + ''; + }; + + extraConfig = let + # Add learn_ham & learn_spam to dovecot2 path for use by sieves + pipeBin = let + teachRspamd = msg: + pkgs.writeShellApplication { + name = "rspamd_${msg}"; + runtimeInputs = with pkgs; [ rspamd ]; + text = + "exec rspamc -h ${cfg.rspamd.host}:${cfg.rspam.port} ${msg}"; + }; + learnHam = teachRspamd "learn_ham"; + learnSpam = teachRspamd "learn_spam"; + in pkgs.buildEnv { + name = "rspam_pipe_bin"; + paths = [ learnHam learnSpam ]; + }; + in '' + ## Extra Config + + mail_plugins = $mail_plugins + + ${lib.optionalString cfg.debug '' + mail_debug = yes + auth_debug = yes + verbose_ssl = yes + ''} + + protocol imap { + mail_max_userip_connections = ${toString cfg.max-user-connections} + mail_plugins = $mail_plugins imap_sieve + } + + protocol lmtp { + mail_plugins = $mail_plugins sieve + } + + mail_access_groups = ${cfg.mail-group} + + # When looking up usernames, just use the name, not the full address + auth_username_format = %n + + service lmtp { + # Enable logging in debug mode + ${optionalString cfg.debug "executable = lmtp -L"} + + inet_listener dovecot-lmtp { + address = 0.0.0.0 + port = ${toString cfg.port.lmtp} + } + + # Drop privs, since all mail is owned by one user + user = ${cfg.mail-user} + # group = ${cfg.mail-group} + # user = root + } + + auth_mechanisms = login plain + + ${optionalString (cfg.dovecot.ldap != null) '' + passdb { + driver = ldap + args = ${cfg.dovecot.ldap.generated-ldap-config} + } + ''} + userdb { + driver = static + args = uid=${ + toString cfg.mail-user-id + } home=${cfg.state-directory}/mail/%u + } + + # Used by postfix to authorize users + service auth { + inet_listener auth { + address = 0.0.0.0 + port = ${toString cfg.ports.auth} + } + inet_listener auth-userdb { + address = 0.0.0.0 + port = ${toString cfg.ports.userdb} + } + } + + service auth-worker { + user = ${config.services.dovecot2.user} + idle_kill = 3 + } + + service imap { + vsz_limit = 1024M + } + + namespace inbox { + separator = "/" + inbox = yes + } + + plugin { + sieve_plugins = sieve_imapsieve sieve_extprograms + sieve = file:${cfg.state-directory}/sieves/%u/scripts;active=${cfg.state-directory}/sieves/%u/active.sieve + sieve_default = file:${cfg.sieve-directory}/%u/default.sieve + sieve_default_name = default + # From elsewhere to Spam folder + imapsieve_mailbox1_name = Junk + imapsieve_mailbox1_causes = COPY + imapsieve_mailbox1_before = file:${sievePath}/report-spam.sieve + # From Spam folder to elsewhere + imapsieve_mailbox2_name = * + imapsieve_mailbox2_from = Junk + imapsieve_mailbox2_causes = COPY + imapsieve_mailbox2_before = file:${sievePath}/report-ham.sieve + + sieve_pipe_bin_dir = ${pipeBin}/pipe/bin + sieve_global_extensions = +vnd.dovecot.pipe +vnd.dovecot.environment + } + + recipient_delimiter = + + + lmtp_save_to_detail_mailbox = yes + + lda_mailbox_autosubscribe = yes + lda_mailbox_autocreate = yes + + service old-stats { + unix_listener old-stats { + user = ${cfg.metrics.user} + group = ${cfg.metrics.group} + } + fifo_listener old-stats-mail { + mode = 0660 + user = ${config.services.dovecot2.user} + group = ${config.services.dovecot2.group} + } + fifo_listener old-stats-user { + mode = 0660 + user = ${config.services.dovecot2.user} + group = ${config.services.dovecot2.group} + } + } + + plugin { + old_stats_refresh = 30 secs + old_stats_track_cmds = yes + } + ''; + }; + }; +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..406fa03 --- /dev/null +++ b/flake.nix @@ -0,0 +1,17 @@ +{ + description = "Mail server running in containers."; + + inputs = { + nixpkgs.url = "nixpkgs/nixos-23.05"; + arion.url = "github:hercules-ci/arion"; + }; + + outputs = { self, nixpkgs, arion, ... }: { + nixosModules = rec { + default = mailServerContainer; + mailServerContainer = { ... }: { + imports = [ arion.nixosModules.arion ./mail-server.nix ]; + }; + }; + }; +} diff --git a/mail-server.nix b/mail-server.nix new file mode 100644 index 0000000..2f351c3 --- /dev/null +++ b/mail-server.nix @@ -0,0 +1,291 @@ +{ config, lib, pkgs, ... }: + +with lib; { + options.fudo.mail = with types; { + enable = mkEnableOption "Enable mail server."; + + state-directory = mkOption { + type = str; + description = "Directory at which to store server state."; + }; + + primary-domain = mkOption { + type = str; + description = "Primary domain name served by this server."; + }; + + extra-domains = mkOption { + type = listOf str; + description = "List of additional domains served by this server."; + default = [ ]; + }; + + smtp = { + hostname = mkOption { + type = str; + description = + "Hostname too use for the SMTP server. Must resolve to this host."; + default = "smtp.${config.fudo.mail.primary-domain}"; + }; + + ssl = { + certificate = mkOption { + type = str; + description = "SSL certificate for the SMTP host."; + }; + private-key = mkOption { + type = str; + description = "SSL private key for the SMTP host."; + }; + }; + }; + + imap = { + hostname = mkOption { + type = str; + description = + "Hostname too use for the IMAP server. Must resolve to this host."; + default = "imap.${config.fudo.mail.primary-domain}"; + }; + + ssl = { + certificate = mkOption { + type = str; + description = "SSL certificate for the IMAP host."; + }; + private-key = mkOption { + type = str; + description = "SSL private key for the IMAP host."; + }; + }; + }; + }; + + config = { + services.nginx = { + virtualHosts = { + "${cfg.smtp.hostname}".locations."/metrics" = { + proxyPass = "http://localhost:${metricsPort}/metrics"; + }; + "${cfg.imap.hostname}".locations."/metrics" = { + proxyPass = "http://localhost:${metricsPort}/metrics"; + }; + }; + }; + + virtualisation.arion.projects.mail-server.settings = let + image = { pkgs, ... }: { + project.name = "fudo-mailserver"; + networks = { + external_network.internal = false; + internal_network.internal = true; + }; + serices = let + antivirusPort = 15407; + antispamPort = 11335; + lmtpPort = 24; + authPort = 5447; + userdbPort = 5448; + metricsPort = 5034; + in { + smtp = { + networks = [ + "internal_network" + # Needs access to internet to forward emails + "external_network" + ]; + ports = [ "25:25" "587:587" "465:465" "2525:2525" ]; + nixos = { + useSystemd = true; + configuration = [ + (import ./postfix.nix) + { + boot.tmpOnTmpfs = true; + system.nssModules = lib.mkForce [ ]; + fudo.mail.postfix = { + enable = true; + debug = cfg.debug; + domain = cfg.primary-domain; + local-domains = cfg.extra-domains; + hostname = cfg.smtp.hostname; + trusted-networks = cfg.trusted-networks; + blacklist = { + senders = cfg.blacklist.senders; + recipients = cfg.blacklist.recipients; + dns = cfg.blacklist.dns; + }; + aliases = { + user-aliases = cfg.user-aliases; + alias-users = cfg.alias-users; + }; + ssl = { + certificate = cfg.smtp.ssl.certificate; + private-key = cfg.smtp.ssl.private-key; + }; + sasl-domain = cfg.sasl-domain; + message-size-limit = cfg.message-size-limit; + ports = { metrics = metricsPort; }; + }; + } + ]; + }; + }; + imap = { + networks = [ "internal_network" ]; + ports = [ "143:143" "993:993" ]; + nixos = { + useSystemd = true; + configuration = [ + (import ./dovecot.nix) + { + boot.tmpOnTmpfs = true; + system.nssModules = lib.mkForce [ ]; + fudo.mail.dovecot = { + enable = true; + debug = cfg.debug; + state-directory = "${cfg.state-directory}/dovecot"; + ports = { + lmtp = lmtpPort; + auth = authPort; + userdb = userdbPort; + metrics = metricsPort; + }; + mail-user = cfg.mail-user; + mail-group = cfg.mail-group; + ssl = { + certificate = cfg.imap.ssl.certificate; + private-key = cfg.imap.ssl.private-key; + }; + rspamd = { + host = "antispam"; + port = antispamPort; + }; + ldap = mkIf cfg.ldap-proxy { + host = "ldap-proxy"; + port = 3389; + base = cfg.ldap.base; + bind-dn = cfg.ldap.bind-dn; + bind-password-file = cfg.ldap.bind-password-file; + }; + }; + } + ]; + }; + }; + ldap-proxy.service = mkIf (cfg.ldap-proxy != null) { + image = cfg.images.ldap-proxy; + restart = "always"; + networks = [ + "internal_network" + # Needs access to external network for user lookups + "external_network" + ]; + envFile = hostSecrets.mailLdapProxyEnv.target-file; + }; + antispam = { + networks = [ + "internal_network" + # Needs external access for blacklist checks + "external_network" + ]; + nixos = { + useSystemd = true; + configuration = [ + (import ./rspamd.nix) + { + boot.tmpOnTmpfs = true; + system.nssModules = lib.mkForce [ ]; + fudo.mail.rspamd = { + enable = true; + ports = { + milter = antispamPort; + controller = antispamControllerPort; + metrics = metricsPort; + }; + antivirus = { + host = "antivirus"; + port = antivirusPort; + }; + }; + } + ]; + }; + }; + antivirus = { + networks = [ + "internal_network" + # Needs external access for database updates + "external_network" + ]; + nixos = { + useSystemd = true; + configuration = [ + (import ./clamav.nix) + { + boot.tmpOnTmpfs = true; + system.nssModules = lib.mkForce [ ]; + fudo.mail.clamav = { + enable = true; + state-directory = "${cfg.state-directory}/rspamd"; + port = antispamPort; + }; + } + ]; + }; + }; + dkim = { + networks = [ "internal_network" ]; + nixos = { + useSystemd = true; + configuration = [ + (import ./dkim.nix) + { + boot.tmpOnTmpfs = true; + system.nssModules = lib.mkForce [ ]; + fudo.mail.dkim = { + enable = true; + debug = cfg.debug; + domains = [ cfg.primary-domain ] ++ cfg.extra-domains; + }; + port = dkimPort; + state-directory = "${cfg.state-directory}/dkim"; + } + ]; + }; + }; + metrics-proxy = { + networks = [ "internal_network" ]; + ports = [ "${cfg.metricsPort}:80" ]; + nixos = { + useSystemd = true; + configuration = { + boot.tmpOnTmpfs = true; + system.nssModules = lib.mkForce [ ]; + services.nginx = { + enable = true; + recommendedProxySettings = true; + recommendedGzipSettings = true; + recommendedOptimisation = true; + virtualHosts.localhost = { + default = true; + locations = { + "/postfix" = { + proxyPass = "http://smtp:${metricsPort}/"; + }; + "/dovecot" = { + proxyPass = "http://imap:${metricsPort}/"; + }; + "rspamd" = { + proxyPass = "http://antispam:${metricsPort}/"; + }; + }; + }; + }; + }; + }; + }; + }; + }; + in { imports = [ image ]; }; + }; +} diff --git a/postfix.nix b/postfix.nix new file mode 100644 index 0000000..24d3227 --- /dev/null +++ b/postfix.nix @@ -0,0 +1,381 @@ +{ config, lib, pkgs, ... }: + +with lib; +let allDomains = [ cfg.domain ] ++ cfg.local-domains; + +in { + options.fudo.mail.postfix = with types; { + enable = mkEnableOption "Enable Postfix SMTP server."; + + domain = mkOption { + type = str; + description = "Primary domain served by this mail server."; + }; + + local-domains = mkOption { + type = listOf str; + description = + "List of domains to be considered local to this server. Don't include the primary domain."; + default = [ ]; + }; + + hostname = mkOption { + type = str; + description = "Fully-qualified hostname of this mail server."; + }; + + trusted-networks = mkOption { + type = listOf str; + description = "List of trusted network ranges."; + }; + + blacklist = { + senders = mkOption { + type = listOf str; + description = + "List of email addresses for which we will never send email."; + default = [ ]; + }; + + recipients = mkOption { + type = listOf str; + description = + "List of email addresses for which we will not accept email."; + default = [ ]; + }; + + dns = mkOption { + type = listOf str; + description = "List of DNS spam blacklists to use."; + default = [ ]; + }; + }; + + aliases = { + user-aliases = mkOption { + type = attrsOf (listOf str); + description = + "Map of username to list of emails belonging to that user."; + default = { }; + example = { some_user = [ "foo@bar.com" "baz@bar.com" ]; }; + }; + + alias-users = mkOption { + type = attrsOf (listOf str); + description = + "Map of aliases to list of accounts which should receive incoming email."; + default = { }; + example = { hostmaster = [ "admin0" "admin1" ]; }; + }; + }; + + ssl = { + certificate = mkOption { + type = str; + description = "Location of host SSL certificate."; + }; + + private-key = mkOption { + type = str; + description = "Location of host SSL private key."; + }; + }; + + sasl-domain = mkOption { + type = str; + description = "SASL domain to use for authentication."; + }; + + policy-spf.extraConfig = mkOption { + type = str; + default = ""; + example = "skip_addresses = 127.0.0.0/8,::ffff:127.0.0.0/104,::1"; + description = "Extra configuration options for policyd-spf."; + }; + + user = mkOption { + type = str; + description = "User as which to run Postfix server."; + default = "postfix"; + }; + + group = mkOption { + type = str; + description = "Group as which to run Postfix server."; + default = "postfix"; + }; + + message-size-limit = mkOption { + type = int; + description = "Max size of email messages, in MB."; + default = 200; + }; + + ports = { + metrics = mkOption { + type = port; + description = "Port on which to listen for metrics requests."; + default = 1725; + }; + }; + }; + + config = mkIf cfg.enable { + users = { + users."${cfg.user}" = { + isSystemUser = true; + group = cfg.group; + }; + + groups."${cfg.group}".members = [ cfg.user ]; + }; + + services = { + prometheus.exporters.postfix = { + enable = true; + systemd.enable = true; + showqPath = "/var/lib/postfix/queue/public/showq"; + group = config.services.postfix.group; + listenAddress = "127.0.0.1"; + port = cfg.metrics-port; + }; + + postfix = { + enable = true; + + user = cfg.user; + group = cfg.group; + + domain = cfg.domain; + origin = cfg.domain; + hostname = cfg.hostname; + destination = [ "localhost" "localhost.localdomain" ]; + + enableHeaderChecks = true; + enableSmtp = true; + enableSubmission = true; + useSrs = true; + + dnsBlacklists = cfg.dns-blacklists; + + concatMapAttrsToList = f: as: concatLists (mapAttrsToList f as); + + mapFiles = let + writeEntries = filename: entries: + pkgs.writeText filename (concatStringsSep "\n" entries); + mkRejectList = entries: map (entry: "${entry} REJECT") entries; + escapeDot = replaceString [ "." ] [ "\\." ]; + in { + reject_senders = writeEntries "sender_blacklist" + (mkRejectList cfg.blacklist.senders); + reject_recipients = writeEntries "recipient_blacklist" + (mkRejectList cfg.blacklist.recipients); + virtual_mailbox_map = writeEntries "virtual_mailbox_map" + (map (domain: "@${domain} OK") allDomains); + sender_login_map = writeEntries "sender_login_maps" + (map (domain: "/^(.*)@${escapeDot domain}$/ \${1}") allDomains); + }; + + networks = cfg.trusted-networks; + + virtual = let + mkEmail = domain: user: "${user}@${domain}"; + mkUserAliases = concatMapAttrsToList (user: aliases: + map (alias: "${alias} ${mkEmail cfg.domain user}")); + mkAliasUsers = domains: + let + userList = users: + concatStringsSep "," (map (mkEmail cfg.domain) users); + in concatMapAttrsToList (alias: users: + concatMap + (domain: "${mkEmail domain alias} ${mkAliasUsers users}")); + in concatStringsSep "\n" ((mkUserAliases cfg.aliases.user-aliases) + ++ (mkAliasUsers allDomains cfg.aliases.alias-users)); + + sslCert = cfg.ssl.certificate; + sslKey = cfg.ssl.private-key; + + config = let + pcreFile = name: "pcre:/var/lib/postfix/conf/${name}"; + mappedFile = name: "hash:/var/lib/postfix/conf/${name}"; + + in { + virtual_mailbox_domains = allDomains; + virtual_mailbox_maps = mappedFile "virtual_mailbox_map"; + + ## I don't think these are needed... + # virtual_uid_maps = let uid = config.users.users."${cfg.user}".uid; + # in "static:${toString uid}"; + # virtual_gid_maps = let gid = config.users.groups."${cfg.group}".gid; + # in "static: ${toString gid}"; + + virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp"; + + message_size_limit = toString (cfg.message-size-limit * 1024 * 1024); + + stmpd_banner = "${cfg.hostname} ESMTP NO UCE"; + + tls_eecdh_strong_curve = "prime256v1"; + tls_eecdh_ultra_curve = "secp384r1"; + + policy-spf_time_limit = "3600s"; + + smtp_host_lookup = "dns, native"; + + smtpd_sasl_type = "dovecot"; + smtpd_sasl_path = "/run/dovecot2/auth"; + smtpd_sasl_auth_enable = "yes"; + smtpd_sasl_local_domain = cfg.sasl-domain; + + smtpd_sasl_security_options = "noanonymous"; + smtpd_sasl_tls_security_options = "noanonymous"; + + smtpd_sender_login_maps = (pcreFile "sender_login_map"); + + disable_vrfy_command = "yes"; + + recipient_delimiter = "+"; + + 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}"; + + smtpd_milters = [ + "unix:/run/rspamd/rspamd-milter.sock" + "unix:/var/run/opendkim/opendkim.sock" + ]; + + non_smtpd_milters = [ + "unix:/run/rspamd/rspamd-milter.sock" + "unix:/var/run/opendkim/opendkim.sock" + ]; + + smtpd_relay_restrictions = [ + "permit_mynetworks" + "permit_sasl_authenticated" + "reject_unauth_destination" + "reject_unauth_pipelining" + "reject_unauth_destination" + "reject_unknown_sender_domain" + ]; + + smtpd_sender_restrictions = [ + "check_sender_access ${mapped-file "reject_senders"}" + "permit_mynetworks" + "permit_sasl_authenticated" + "reject_unknown_sender_domain" + ]; + + smtpd_recipient_restrictions = [ + "check_sender_access ${mapped-file "reject_recipients"}" + "permit_mynetworks" + "permit_sasl_authenticated" + "check_policy_service unix:private/policy-spf" + "reject_unknown_recipient_domain" + "reject_unauth_pipelining" + "reject_unauth_destination" + "reject_invalid_hostname" + "reject_non_fqdn_hostname" + "reject_non_fqdn_sender" + "reject_non_fqdn_recipient" + ]; + + smtpd_helo_restrictions = + [ "permit_mynetworks" "reject_invalid_hostname" "permit" ]; + + # Handled by submission + smtpd_tls_security_level = "may"; + + smtpd_tls_eecdh_grade = "ultra"; + + # Disable obselete protocols + smtpd_tls_protocols = + [ "TLSv1.2" "TLSv1.1" "!TLSv1" "!SSLv2" "!SSLv3" ]; + smtp_tls_protocols = + [ "TLSv1.2" "TLSv1.1" "!TLSv1" "!SSLv2" "!SSLv3" ]; + smtpd_tls_mandatory_protocols = + [ "TLSv1.2" "TLSv1.1" "!TLSv1" "!SSLv2" "!SSLv3" ]; + smtp_tls_mandatory_protocols = + [ "TLSv1.2" "TLSv1.1" "!TLSv1" "!SSLv2" "!SSLv3" ]; + + smtp_tls_ciphers = "high"; + smtpd_tls_ciphers = "high"; + smtp_tls_mandatory_ciphers = "high"; + smtpd_tls_mandatory_ciphers = "high"; + + smtpd_tls_mandatory_exclude_ciphers = + [ "MD5" "DES" "ADH" "RC4" "PSD" "SRP" "3DES" "eNULL" "aNULL" ]; + smtpd_tls_exclude_ciphers = + [ "MD5" "DES" "ADH" "RC4" "PSD" "SRP" "3DES" "eNULL" "aNULL" ]; + smtp_tls_mandatory_exclude_ciphers = + [ "MD5" "DES" "ADH" "RC4" "PSD" "SRP" "3DES" "eNULL" "aNULL" ]; + smtp_tls_exclude_ciphers = + [ "MD5" "DES" "ADH" "RC4" "PSD" "SRP" "3DES" "eNULL" "aNULL" ]; + + tls_preempt_cipherlist = "yes"; + + smtpd_tls_auth_only = "yes"; + + smtpd_tls_loglevel = "1"; + + tls_random_source = "dev:/dev/urandom"; + }; + + submissionOptions = { + 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 = cfg.domain; + smtpd_client_restrictions = "permit_sasl_authenticated,reject"; + smtpd_sender_restrictions = + "reject_sender_login_mismatch,reject_unknown_sender_domain"; + smtpd_recipient_restrictions = + "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject"; + cleanup_service_name = "submission-header-cleanup"; + }; + + masterConfig = { + "policy-spf" = let + policySpfFile = pkgs.writeText "policyd-spf.conf" + (cfg.postfix.policy-spf.extraConfig + + (lib.optionalString cfg.debug "debugLevel = 4")); + in { + type = "unix"; + privileged = true; + chroot = false; + command = "spawn"; + args = [ + "user=nobody" + "argv=${pkgs.pypolicyd-spf}/bin/policyd-spf" + "${policydSpf}" + ]; + }; + "submission-header-cleanup" = let + submissionHeaderCleanupRules = + 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 + ''; + in { + type = "unix"; + private = false; + chroot = false; + maxproc = 0; + command = "cleanup"; + args = + [ "-o" "header_checks=pcre:${submissionHeaderCleanupRules}" ]; + }; + }; + }; + }; + }; +} diff --git a/rspamd.nix b/rspamd.nix new file mode 100644 index 0000000..590b17c --- /dev/null +++ b/rspamd.nix @@ -0,0 +1,94 @@ +{ config, lib, pkgs, ... }: + +# TODO: use blacklists + +with lib; +let + cfg = config.fudo.mail.rspamd; + mailCfg = config.fudo.mail; + +in { + options.fudo.mail.rspamd = with types; { + enable = mkEnableOption "Enable rspamd spam test server."; + + ports = { + metrics = mkOption { + type = port; + default = 7573; + }; + controller = mkOption { + type = port; + default = 11334; + }; + milter = mkOption { + type = port; + default = 11335; + }; + }; + + antivirus = { + host = mkOption { + type = str; + description = "Host of the ClamAV server."; + }; + + port = mkOption { + type = port; + description = "Port at which to reach ClamAV"; + }; + }; + }; + + config = mkIf cfg.enable { + services.prometheus.exporters.rspamd = { + enable = true; + listenAddress = "127.0.0.1"; + port = cfg.metrics-port; + }; + + services.rspamd = { + enable = true; + + locals = { + "milter_headers.conf".text = "extended_spam_headers = yes;"; + + "antivirus.conf".text = '' + clamav { + action = "reject"; + symbol = "CLAM_VIRUS"; + type = "clamav"; + log_clean = true; + servers = "${cfg.antivirus.host}:${cfg.antivirus.port}"; + scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all + } + ''; + }; + + overrides."milter_headers.conf".text = "extended_spam_headers = true;"; + + workers = { + rspamd_proxy = { + type = "rspamd_proxy"; + bindSockets = [ "localhost:${toString cfg.port}" ]; + count = 4; + extraConfig = '' + milter = yes; + timeout = 120s; + + upstream "local" { + default = yes; + self_scan = yes; + } + ''; + }; + + controller = { + type = "controller"; + count = 4; + bindSockets = [ "localhost:${toString cfg.controller-port}" ]; + includes = [ ]; + }; + }; + }; + }; +} diff --git a/sieves/ham.sieve b/sieves/ham.sieve new file mode 100644 index 0000000..9cb43b4 --- /dev/null +++ b/sieves/ham.sieve @@ -0,0 +1,19 @@ +require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; + +if environment :matches "imap.mailbox" "*" { + set "mailbox" "${1}"; +} + +if string "${mailbox}" "Trash" { + stop; +} + +if string "${mailbox}" "Junk" { + stop +} + +if environment :matches "imap.user" "*" { + set "username" "${1}"; +} + +pipe :copy "rspamd_learn_ham" [ "${username}" ]; diff --git a/sieves/spam.sieve b/sieves/spam.sieve new file mode 100644 index 0000000..db818ce --- /dev/null +++ b/sieves/spam.sieve @@ -0,0 +1,7 @@ +require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"]; + +if environment :matches "imap.user" "*" { + set "username" "${1}"; +} + +pipe :copy "rspamd_learn_spam" [ "${username}" ];