Files
simple-nixos-mailserver/mail-server/postfix.nix
Martin Weinelt 3555a546ab Add support for SMTP TLS reports
When enabled the tlsrpt services will send out aggregated reports about
TLS connections the local Postfix made to interested parties, who set up
a `_smtp._tls` TXT record with a rua attribute.

Introduces mailserver.systemContact to specify an administrative contact
advertised in these automated reports.
2025-11-08 22:39:29 +01:00

437 lines
14 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;
# 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;
};
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
]);
config = {
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 = [
"${keyPath}"
"${certificatePath}"
];
# 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
# https://ssl-config.mozilla.org/#server=postfix&version=3.10&config=intermediate&openssl=3.4.1&guideline=5.7
tls_eecdh_auto_curves = [
"X25519"
"prime256v1"
"secp384r1"
];
# Disable FFDHE on TLSv1.3 because it is slower than elliptic curves
# https://www.postfix.org/postconf.5.html#tls_ffdhe_auto_groups
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.dkimSigning [ "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;
masterConfig = {
"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}"
];
};
};
};
};
}