6ff4a50f02
After bumping the generation of new DKIM keys to RSA 2048 in NixOS 25.11 key rotation for existing users could not be done safely. To resolve this situation we now support multiple generations of selectors per domain to enable proper DKIM key transitions as described in RFC6376 3.1. The added documentation introduces and motivates DKIM and guides the user through a DKIM key rotation. Additionally, DKIM key material can now also be treated as a managed secrets when autogenerated state on the mail server host is undesirable. This change is fully backwards compatible in behavior and will continue to use the previously generated DKIM key without any additional configuration up until the point when DKIM selectors are configured explicitly.
355 lines
10 KiB
Nix
355 lines
10 KiB
Nix
# 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 <http://www.gnu.org/licenses/>
|
|
|
|
{
|
|
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 = ''
|
|
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 ];
|
|
};
|
|
}
|