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.
482 lines
16 KiB
Nix
482 lines
16 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,
|
|
options,
|
|
pkgs,
|
|
lib,
|
|
...
|
|
}:
|
|
|
|
with (import ./common.nix {
|
|
inherit
|
|
config
|
|
options
|
|
lib
|
|
pkgs
|
|
;
|
|
});
|
|
|
|
let
|
|
inherit (lib.strings) concatStringsSep;
|
|
cfg = config.mailserver;
|
|
|
|
iniFormat = pkgs.formats.iniWithGlobalSection { };
|
|
|
|
# Merge several lookup tables. A lookup table is a attribute set where
|
|
# - the key is an address (user@example.com) or a domain (@example.com)
|
|
# - the value is a list of addresses
|
|
mergeLookupTables = tables: lib.zipAttrsWith (_: v: lib.flatten v) tables;
|
|
|
|
# valiases_postfix :: Map String [String]
|
|
valiases_postfix = mergeLookupTables (
|
|
lib.flatten (
|
|
lib.mapAttrsToList (
|
|
name: value:
|
|
let
|
|
to = name;
|
|
in
|
|
map (from: { "${from}" = to; }) (value.aliases ++ lib.singleton name)
|
|
) cfg.loginAccounts
|
|
)
|
|
);
|
|
regex_valiases_postfix = mergeLookupTables (
|
|
lib.flatten (
|
|
lib.mapAttrsToList (
|
|
name: value:
|
|
let
|
|
to = name;
|
|
in
|
|
map (from: { "${from}" = to; }) value.aliasesRegexp
|
|
) cfg.loginAccounts
|
|
)
|
|
);
|
|
|
|
# catchAllPostfix :: Map String [String]
|
|
catchAllPostfix = mergeLookupTables (
|
|
lib.flatten (
|
|
lib.mapAttrsToList (
|
|
name: value:
|
|
let
|
|
to = name;
|
|
in
|
|
map (from: { "@${from}" = to; }) value.catchAll
|
|
) cfg.loginAccounts
|
|
)
|
|
);
|
|
|
|
# all_valiases_postfix :: Map String [String]
|
|
all_valiases_postfix = mergeLookupTables [
|
|
valiases_postfix
|
|
extra_valiases_postfix
|
|
];
|
|
|
|
# attrsToLookupTable :: Map String (Either String [ String ]) -> Map String [String]
|
|
attrsToLookupTable =
|
|
aliases:
|
|
let
|
|
lookupTables = lib.mapAttrsToList (from: to: { "${from}" = to; }) aliases;
|
|
in
|
|
mergeLookupTables lookupTables;
|
|
|
|
# extra_valiases_postfix :: Map String [String]
|
|
extra_valiases_postfix = attrsToLookupTable cfg.extraVirtualAliases;
|
|
|
|
# forwards :: Map String [String]
|
|
forwards = attrsToLookupTable cfg.forwards;
|
|
|
|
# lookupTableToString :: Map String [String] -> String
|
|
lookupTableToString =
|
|
attrs:
|
|
let
|
|
valueToString = value: lib.concatStringsSep ", " value;
|
|
in
|
|
lib.concatStringsSep "\n" (
|
|
lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs
|
|
);
|
|
|
|
# valiases_file :: Path
|
|
valiases_file =
|
|
let
|
|
content = lookupTableToString (mergeLookupTables [
|
|
all_valiases_postfix
|
|
catchAllPostfix
|
|
]);
|
|
in
|
|
builtins.toFile "valias" content;
|
|
|
|
regex_valiases_file =
|
|
let
|
|
content = lookupTableToString regex_valiases_postfix;
|
|
in
|
|
builtins.toFile "regex_valias" content;
|
|
|
|
# denied_recipients_postfix :: [ String ]
|
|
denied_recipients_postfix = map (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") (
|
|
lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts)
|
|
);
|
|
denied_recipients_file = builtins.toFile "denied_recipients" (
|
|
lib.concatStringsSep "\n" denied_recipients_postfix
|
|
);
|
|
|
|
reject_senders_postfix = map (sender: "${sender} REJECT") cfg.rejectSender;
|
|
reject_senders_file = builtins.toFile "reject_senders" (
|
|
lib.concatStringsSep "\n" reject_senders_postfix
|
|
);
|
|
|
|
reject_recipients_postfix = map (recipient: "${recipient} REJECT") cfg.rejectRecipients;
|
|
# rejectRecipients :: [ Path ]
|
|
reject_recipients_file = builtins.toFile "reject_recipients" (
|
|
lib.concatStringsSep "\n" reject_recipients_postfix
|
|
);
|
|
|
|
# vhosts_file :: Path
|
|
vhosts_file = builtins.toFile "vhosts" (concatStringsSep "\n" cfg.domains);
|
|
|
|
# vaccounts_file :: Path
|
|
# see
|
|
# https://blog.grimneko.de/2011/12/24/a-bunch-of-tips-for-improving-your-postfix-setup/
|
|
# for details on how this file looks. By using the same file as valiases,
|
|
# every alias is owned (uniquely) by its user.
|
|
# The user's own address is already in all_valiases_postfix.
|
|
vaccounts_file = builtins.toFile "vaccounts" (lookupTableToString all_valiases_postfix);
|
|
regex_vaccounts_file = builtins.toFile "regex_vaccounts" (
|
|
lookupTableToString regex_valiases_postfix
|
|
);
|
|
|
|
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
|
|
''
|
|
+ lib.optionalString cfg.rewriteMessageId ''
|
|
|
|
# Replaces the user submitted hostname with the server's FQDN to hide the
|
|
# user's host or network.
|
|
|
|
/^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}>
|
|
''
|
|
);
|
|
|
|
smtpdMilters = [ "unix:/run/rspamd/rspamd-milter.sock" ];
|
|
|
|
mappedFile = name: "hash:/var/lib/postfix/conf/${name}";
|
|
mappedRegexFile = name: "pcre:/var/lib/postfix/conf/${name}";
|
|
|
|
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 = "$myhostname";
|
|
smtpd_client_restrictions = "permit_sasl_authenticated,reject";
|
|
smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}${
|
|
lib.optionalString (regex_valiases_postfix != { }) ",pcre:/etc/postfix/regex_vaccounts"
|
|
}";
|
|
smtpd_sender_restrictions = "reject_sender_login_mismatch";
|
|
smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject";
|
|
cleanup_service_name = "submission-header-cleanup";
|
|
};
|
|
|
|
commonLdapConfig = ''
|
|
server_host = ${lib.concatStringsSep " " cfg.ldap.uris}
|
|
start_tls = ${if cfg.ldap.startTls then "yes" else "no"}
|
|
version = 3
|
|
tls_ca_cert_file = ${cfg.ldap.tlsCAFile}
|
|
tls_require_cert = yes
|
|
|
|
search_base = ${cfg.ldap.searchBase}
|
|
scope = ${cfg.ldap.searchScope}
|
|
|
|
bind = yes
|
|
bind_dn = ${cfg.ldap.bind.dn}
|
|
'';
|
|
|
|
ldapSenderLoginMap = pkgs.writeText "ldap-sender-login-map.cf" ''
|
|
${commonLdapConfig}
|
|
query_filter = ${cfg.ldap.postfix.filter}
|
|
result_attribute = ${cfg.ldap.postfix.mailAttribute}
|
|
'';
|
|
ldapSenderLoginMapFile = "/run/postfix/ldap-sender-login-map.cf";
|
|
appendPwdInSenderLoginMap = appendLdapBindPwd {
|
|
name = "ldap-sender-login-map";
|
|
file = ldapSenderLoginMap;
|
|
prefix = "bind_pw = ";
|
|
passwordFile = cfg.ldap.bind.passwordFile;
|
|
destination = ldapSenderLoginMapFile;
|
|
};
|
|
|
|
ldapVirtualMailboxMap = pkgs.writeText "ldap-virtual-mailbox-map.cf" ''
|
|
${commonLdapConfig}
|
|
query_filter = ${cfg.ldap.postfix.filter}
|
|
result_attribute = ${cfg.ldap.postfix.uidAttribute}
|
|
'';
|
|
ldapVirtualMailboxMapFile = "/run/postfix/ldap-virtual-mailbox-map.cf";
|
|
appendPwdInVirtualMailboxMap = appendLdapBindPwd {
|
|
name = "ldap-virtual-mailbox-map";
|
|
file = ldapVirtualMailboxMap;
|
|
prefix = "bind_pw = ";
|
|
passwordFile = cfg.ldap.bind.passwordFile;
|
|
destination = ldapVirtualMailboxMapFile;
|
|
};
|
|
in
|
|
{
|
|
config = lib.mkIf cfg.enable {
|
|
# SMTP TLS error reporting (RFC 8460)
|
|
services.tlsrpt = {
|
|
inherit (cfg.tlsrpt) enable;
|
|
configurePostfix = true;
|
|
reportd.settings = {
|
|
organization_name = cfg.systemName;
|
|
contact_info = "${cfg.systemContact}";
|
|
sender_address = "noreply-tlsrpt@${cfg.systemDomain}";
|
|
};
|
|
};
|
|
|
|
# SMTP client policy mapping for DANE (RFC 6698) and MTA-STS (RFC 8461)
|
|
services.postfix-tlspol = {
|
|
enable = true;
|
|
configurePostfix = true;
|
|
};
|
|
|
|
# Sender Rewriting Scheme (https://www.libsrs2.net/srs/srs.pdf)
|
|
services.postsrsd = {
|
|
inherit (cfg.srs) enable;
|
|
configurePostfix = true;
|
|
settings = {
|
|
domains = lib.unique (
|
|
[
|
|
cfg.fqdn
|
|
cfg.sendingFqdn
|
|
cfg.systemDomain
|
|
]
|
|
++ cfg.domains
|
|
);
|
|
separator = "=";
|
|
srs-domain = cfg.srs.domain;
|
|
};
|
|
};
|
|
|
|
security.acme.certs = lib.mkIf withACME {
|
|
${cfg.x509.useACMEHost} = {
|
|
reloadServices = [ "postfix.service" ];
|
|
};
|
|
};
|
|
|
|
systemd.services.postfix.reloadTriggers = lib.mkIf (!withACME) [
|
|
x509CertificateFile
|
|
x509PrivateKeyFile
|
|
];
|
|
|
|
systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable {
|
|
preStart = ''
|
|
${appendPwdInVirtualMailboxMap}
|
|
${appendPwdInSenderLoginMap}
|
|
'';
|
|
restartTriggers = [
|
|
appendPwdInVirtualMailboxMap
|
|
appendPwdInSenderLoginMap
|
|
];
|
|
};
|
|
|
|
services.postfix = {
|
|
enable = true;
|
|
mapFiles."valias" = valiases_file;
|
|
mapFiles."regex_valias" = regex_valiases_file;
|
|
mapFiles."vaccounts" = vaccounts_file;
|
|
mapFiles."regex_vaccounts" = regex_vaccounts_file;
|
|
mapFiles."denied_recipients" = denied_recipients_file;
|
|
mapFiles."reject_senders" = reject_senders_file;
|
|
mapFiles."reject_recipients" = reject_recipients_file;
|
|
enableSubmission = cfg.enableSubmission;
|
|
enableSubmissions = cfg.enableSubmissionSsl;
|
|
virtual = lookupTableToString (mergeLookupTables [
|
|
all_valiases_postfix
|
|
catchAllPostfix
|
|
forwards
|
|
]);
|
|
|
|
settings.main = {
|
|
myhostname = cfg.sendingFqdn;
|
|
mydestination = ""; # disable local mail delivery
|
|
recipient_delimiter = cfg.recipientDelimiter;
|
|
smtpd_banner = "${cfg.fqdn} ESMTP NO UCE";
|
|
disable_vrfy_command = true;
|
|
message_size_limit = cfg.messageSizeLimit;
|
|
|
|
# virtual mail system
|
|
virtual_uid_maps = "static:5000";
|
|
virtual_gid_maps = "static:5000";
|
|
virtual_mailbox_base = cfg.mailDirectory;
|
|
virtual_mailbox_domains = vhosts_file;
|
|
virtual_mailbox_maps = [
|
|
(mappedFile "valias")
|
|
]
|
|
++ lib.optionals cfg.ldap.enable [
|
|
"ldap:${ldapVirtualMailboxMapFile}"
|
|
]
|
|
++ lib.optionals (regex_valiases_postfix != { }) [
|
|
(mappedRegexFile "regex_valias")
|
|
];
|
|
virtual_alias_maps = lib.mkAfter (
|
|
lib.optionals (regex_valiases_postfix != { }) [
|
|
(mappedRegexFile "regex_valias")
|
|
]
|
|
);
|
|
virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp";
|
|
|
|
# Avoid leakage of X-Original-To, X-Delivered-To headers between recipients
|
|
lmtp_destination_recipient_limit = "1";
|
|
|
|
# sasl with dovecot
|
|
smtpd_sasl_type = "dovecot";
|
|
smtpd_sasl_path = "/run/dovecot2/auth";
|
|
smtpd_sasl_auth_enable = true;
|
|
smtpd_relay_restrictions = [
|
|
"permit_mynetworks"
|
|
"permit_sasl_authenticated"
|
|
"reject_unauth_destination"
|
|
];
|
|
|
|
# reject selected senders
|
|
smtpd_sender_restrictions = [
|
|
"check_sender_access ${mappedFile "reject_senders"}"
|
|
];
|
|
|
|
smtpd_recipient_restrictions = [
|
|
# reject selected recipients
|
|
"check_recipient_access ${mappedFile "denied_recipients"}"
|
|
"check_recipient_access ${mappedFile "reject_recipients"}"
|
|
# quota checking
|
|
"check_policy_service unix:/run/dovecot2/quota-status"
|
|
];
|
|
|
|
# The X509 private key followed by the corresponding certificate
|
|
smtpd_tls_chain_files = [
|
|
"${x509PrivateKeyFile}"
|
|
"${x509CertificateFile}"
|
|
];
|
|
|
|
# TLS for incoming mail is optional
|
|
smtpd_tls_security_level = "may";
|
|
|
|
# But required for authentication attempts
|
|
smtpd_tls_auth_only = true;
|
|
|
|
# TLS versions supported for the SMTP server
|
|
smtpd_tls_protocols = ">=TLSv1.2";
|
|
smtpd_tls_mandatory_protocols = ">=TLSv1.2";
|
|
|
|
# Require ciphersuites that OpenSSL classifies as "High"
|
|
smtpd_tls_ciphers = "high";
|
|
smtpd_tls_mandatory_ciphers = "high";
|
|
|
|
# Exclude cipher suites with undesirable properties
|
|
smtpd_tls_exclude_ciphers = "SHA1, eNULL, aNULL";
|
|
smtpd_tls_mandatory_exclude_ciphers = "SHA1, eNULL, aNULL";
|
|
|
|
# Enable DNSSEC/DANE support for outgoing SMTP connections
|
|
# https://www.postfix.org/postconf.5.html#smtp_tls_security_level
|
|
smtp_dns_support_level = "dnssec";
|
|
smtp_tls_security_level = "dane";
|
|
|
|
# TLS versions supported for the SMTP client
|
|
smtp_tls_protocols = ">=TLSv1.2";
|
|
smtp_tls_mandatory_protocols = ">=TLSv1.2";
|
|
|
|
# Require ciphersuites that OpenSSL classifies as "High"
|
|
smtp_tls_ciphers = "high";
|
|
smtp_tls_mandatory_ciphers = "high";
|
|
|
|
# Exclude ciphersuites with undesirable properties
|
|
smtp_tls_exclude_ciphers = "SHA1, eNULL, aNULL";
|
|
smtp_tls_mandatory_exclude_ciphers = "SHA1, eNULL, aNULL";
|
|
|
|
# Restrict and prioritize the following curves in the given order
|
|
# Excludes curves that have no widespread support, so we don't bloat the handshake needlessly.
|
|
# https://www.postfix.org/postconf.5.html#tls_eecdh_auto_curves
|
|
tls_config_file =
|
|
let
|
|
mkGroupString = groups: concatStringsSep " / " (map (concatStringsSep ":") groups);
|
|
in
|
|
iniFormat.generate "postfix-openssl.cnf" {
|
|
globalSection.postfix = "postfix_settings";
|
|
sections = {
|
|
postfix_settings.ssl_conf = "postfix_ssl_settings";
|
|
postfix_ssl_settings.system_default = "baseline_postfix_settings";
|
|
baseline_postfix_settings.Groups = mkGroupString [
|
|
[ "*X25519MLKEM768" ]
|
|
[ "*X25519" ]
|
|
[
|
|
"P-256"
|
|
"P-384"
|
|
]
|
|
];
|
|
};
|
|
};
|
|
tls_config_name = "postfix";
|
|
|
|
# Algorithm selection happens through `tls_config_file` instead.
|
|
tls_eecdh_auto_curves = [ ];
|
|
tls_ffdhe_auto_groups = [ ];
|
|
|
|
# As long as all cipher suites are considered safe, let the client use its preferred cipher
|
|
tls_preempt_cipherlist = false;
|
|
|
|
# Log only a summary message on TLS handshake completion
|
|
smtp_tls_loglevel = "1";
|
|
smtpd_tls_loglevel = "1";
|
|
|
|
smtpd_milters = smtpdMilters;
|
|
non_smtpd_milters = lib.mkIf cfg.dkim.enable [ "unix:/run/rspamd/rspamd-milter.sock" ];
|
|
milter_protocol = "6";
|
|
milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_authen}";
|
|
};
|
|
|
|
submissionOptions = submissionOptions;
|
|
submissionsOptions = submissionOptions;
|
|
|
|
settings.master = {
|
|
"lmtp" = {
|
|
# Add headers when delivering, see http://www.postfix.org/smtp.8.html
|
|
# D => Delivered-To, O => X-Original-To, R => Return-Path
|
|
args = [ "flags=O" ];
|
|
};
|
|
"submission-header-cleanup" = {
|
|
type = "unix";
|
|
private = false;
|
|
chroot = false;
|
|
maxproc = 0;
|
|
command = "cleanup";
|
|
args = [
|
|
"-o"
|
|
"header_checks=pcre:${submissionHeaderCleanupRules}"
|
|
];
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|