# nixos-mailserver: a simple mail server # Copyright (C) 2016-2018 Robin Raymond # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see { config, pkgs, lib, ... }: let cfg = config.mailserver; postfixCfg = config.services.postfix; rspamdCfg = config.services.rspamd; rspamdUser = config.services.rspamd.user; rspamdGroup = config.services.rspamd.group; createDkimKeypair = { domain, selector, type, bits, ... }: let privkey = "${cfg.dkim.keyDirectory}/${domain}.${selector}.key"; pubkey = "${cfg.dkim.keyDirectory}/${domain}.${selector}.txt"; in pkgs.writeShellScript "dkim-keygen-${domain}-${selector}" '' if [ ! -f "${privkey}" ] then ${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen ${ lib.cli.toCommandLineShellGNU { } { inherit domain selector type bits privkey ; } } > "${pubkey}" chmod 0644 "${pubkey}" echo "Generated key for domain ${domain} and selector ${selector}" fi ''; mailDomains = lib.unique ( # primary mailserver domains config.mailserver.domains # all dkim domains, even extra domains specified ++ lib.attrNames cfg.dkim.domains # and the srs domain, if one is configured ++ lib.optionals (cfg.srs.domain != null) [ cfg.srs.domain ] ); dkimKeys = lib.concatMap ( domain: let configuredSelectors = config.mailserver.dkim.domains.${domain}.selectors or { }; finalSelectors = if configuredSelectors == { } then # synthesize default dkim key, if none configured { "${config.mailserver.dkim.defaults.selector}" = { keyType = null; keyLength = null; keyFile = null; }; } else configuredSelectors; in lib.mapAttrsToList (selector: settings: rec { inherit domain selector; keyFile = settings.keyFile; keyPath = if keyFile != null then keyFile else "${cfg.dkim.keyDirectory}/${domain}.${selector}.key"; bits = if settings.keyLength != null then settings.keyLength else config.mailserver.dkim.defaults.keyLength; type = if settings.keyType != null then settings.keyType else config.mailserver.dkim.defaults.keyType; }) finalSelectors ) mailDomains; dkimKeysToGenerate = lib.filter (key: key.keyFile == null) dkimKeys; dkimKeysByDomain = lib.groupBy (item: item.domain) dkimKeys; in { config = lib.mkIf cfg.enable { environment.systemPackages = lib.mkBefore [ (pkgs.runCommand "rspamc-wrapped" { nativeBuildInputs = with pkgs; [ makeWrapper ]; } '' makeWrapper ${pkgs.rspamd}/bin/rspamc $out/bin/rspamc \ --add-flags "-h /run/rspamd/worker-controller.sock" '' ) ]; services.rspamd = { enable = true; debug = cfg.debug.rspamd; locals = { "milter_headers.conf" = { text = '' use = [ "authentication-results" ]; extended_spam_headers = true; ''; }; "redis.conf" = { text = '' servers = "${ if cfg.redis.port == null then cfg.redis.address else "${cfg.redis.address}:${toString cfg.redis.port}" }"; '' + (lib.optionalString (cfg.redis.password != null) '' password = "${cfg.redis.password}"; ''); }; "classifier-bayes.conf" = { text = '' cache { backend = "redis"; } ''; }; "antivirus.conf" = lib.mkIf cfg.virusScanning { text = '' clamav { action = "reject"; symbol = "CLAM_VIRUS"; type = "clamav"; log_clean = true; servers = "/run/clamav/clamd.ctl"; scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all } ''; }; "dkim_signing.conf" = { text = '' enabled = ${lib.boolToString cfg.dkim.enable}; # Only sign explicitly configured domains try_fallback = false; # Allow for usernames w/o domain part allow_username_mismatch = true; # Don't normalize DKIM key selection for subdomains use_esld = false; domain { ${lib.concatStringsSep "\n\n" ( map (domain: '' ${domain} { selectors [ ${lib.concatStringsSep ",\n" ( map (selector: '' { path: "${selector.keyPath}"; selector: "${selector.selector}"; }'') dkimKeysByDomain.${domain} )} ] } '') (lib.attrNames dkimKeysByDomain) )} } ''; }; "dmarc.conf" = { text = '' ${lib.optionalString cfg.dmarcReporting.enable '' reporting { enabled = true; email = "noreply-dmarc@${cfg.systemDomain}"; domain = "${cfg.systemDomain}"; org_name = "${cfg.systemName}"; from_name = "${cfg.systemName}"; msgid_from = "${cfg.systemDomain}"; ${lib.optionalString (cfg.dmarcReporting.excludeDomains != [ ]) '' exclude_domains = ${builtins.toJSON cfg.dmarcReporting.excludeDomains}; ''} }''} ''; }; }; overrides = { "options.inc" = { text = '' local_addrs = [::1/128, 127.0.0.0/8] ''; }; }; workers.rspamd_proxy = { type = "rspamd_proxy"; bindSockets = [ { socket = "/run/rspamd/rspamd-milter.sock"; mode = "0664"; } ]; 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 } ''; }; workers.controller = { type = "controller"; count = 1; bindSockets = [ { socket = "/run/rspamd/worker-controller.sock"; mode = "0666"; } ]; includes = [ ]; extraConfig = '' static_dir = "''${WWWDIR}"; # Serve the web UI static assets ''; }; }; services.redis.servers.rspamd.enable = lib.mkDefault cfg.redis.configureLocally; systemd.tmpfiles.settings."10-rspamd.conf" = { "${cfg.dkim.keyDirectory}" = { d = { # Create /var/dkim owned by rspamd user/group user = rspamdUser; group = rspamdGroup; }; Z = { # Recursively adjust permissions in /var/dkim user = rspamdUser; group = rspamdGroup; }; }; }; systemd.services.rspamd = { requires = [ "redis-rspamd.service" ] ++ (lib.optional cfg.virusScanning "clamav-daemon.service"); after = [ "redis-rspamd.service" ] ++ (lib.optional cfg.virusScanning "clamav-daemon.service"); serviceConfig = lib.mkMerge [ { SupplementaryGroups = [ config.services.redis.servers.rspamd.group ]; } (lib.optionalAttrs cfg.dkim.enable { ExecStartPre = map createDkimKeypair dkimKeysToGenerate; ReadWritePaths = [ cfg.dkim.keyDirectory ]; }) ]; }; systemd.services.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable { # Explicitly select yesterday's date to work around broken # default behaviour when called without a date. # https://github.com/rspamd/rspamd/issues/4062 script = toString [ (lib.getExe' pkgs.rspamd "rspamadm") "dmarc_report" "$(date -d 'yesterday' '+%Y%m%d')" ]; serviceConfig = { User = "${config.services.rspamd.user}"; Group = "${config.services.rspamd.group}"; AmbientCapabilities = [ ]; CapabilityBoundingSet = ""; DevicePolicy = "closed"; IPAddressAllow = "localhost"; LockPersonality = true; NoNewPrivileges = true; PrivateDevices = true; PrivateMounts = true; PrivateTmp = true; PrivateUsers = true; ProtectClock = true; ProtectControlGroups = true; ProtectHome = true; ProtectHostname = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProtectProc = "invisible"; ProcSubset = "pid"; ProtectSystem = "strict"; RemoveIPC = true; RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; SupplementaryGroups = lib.optionals cfg.redis.configureLocally [ config.services.redis.servers.rspamd.group ]; SystemCallArchitectures = "native"; SystemCallFilter = [ "@system-service" "~@privileged" ]; UMask = "0077"; }; }; systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable { description = "Daily delivery of aggregated DMARC reports"; wantedBy = [ "timers.target" ]; timerConfig = { OnCalendar = "daily"; Persistent = true; RandomizedDelaySec = 86400; FixedRandomDelay = true; }; }; users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ]; }; }