7909eabac2
This drops ARIA, Camellia and AES-CBC support from TLSv1.2 cipher suites. When we explicitly restrict the cipherlist in Postfix, then we need to define TLSv1.3 cipher suites in our OpenSSL config file.
524 lines
17 KiB
Nix
524 lines
17 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.accounts
|
|
)
|
|
);
|
|
regex_valiases_postfix = mergeLookupTables (
|
|
lib.flatten (
|
|
lib.mapAttrsToList (
|
|
name: value:
|
|
let
|
|
to = name;
|
|
in
|
|
map (from: { "${from}" = to; }) value.aliasesRegexp
|
|
) cfg.accounts
|
|
)
|
|
);
|
|
|
|
# catchAllPostfix :: Map String [String]
|
|
catchAllPostfix = mergeLookupTables (
|
|
lib.flatten (
|
|
lib.mapAttrsToList (
|
|
name: value:
|
|
let
|
|
to = name;
|
|
in
|
|
map (from: { "@${from}" = to; }) value.catchAll
|
|
) cfg.accounts
|
|
)
|
|
);
|
|
|
|
# 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.aliases;
|
|
|
|
# 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.accounts)
|
|
);
|
|
denied_recipients_file = builtins.toFile "denied_recipients" (
|
|
lib.concatStringsSep "\n" denied_recipients_postfix
|
|
);
|
|
|
|
reject_senders_postfix = map (
|
|
sender:
|
|
"${sender} REJECT${
|
|
lib.optionalString (cfg.rejectSenderMessage != "") " ${cfg.rejectSenderMessage}"
|
|
}"
|
|
) 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.caFile}
|
|
tls_require_cert = yes
|
|
|
|
search_base = ${cfg.ldap.base}
|
|
scope = ${cfg.ldap.scope}
|
|
|
|
bind = yes
|
|
bind_dn = ${cfg.ldap.bind.dn}
|
|
'';
|
|
|
|
# Enforce a mapping between SMTP user and envelope sender address
|
|
ldapSenderLoginMap = pkgs.writeText "ldap-sender-login-map.cf" ''
|
|
${commonLdapConfig}
|
|
query_filter = ${cfg.ldap.postfix.filter}
|
|
result_attribute = ${cfg.ldap.attributes.username}
|
|
'';
|
|
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;
|
|
};
|
|
|
|
# Check whether a recipient address exists, before accepting mail for it
|
|
ldapVirtualMailboxMap = pkgs.writeText "ldap-virtual-mailbox-map.cf" ''
|
|
${commonLdapConfig}
|
|
query_filter = ${cfg.ldap.postfix.filter}
|
|
result_attribute = ${cfg.ldap.attributes.username}
|
|
'';
|
|
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_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"}"
|
|
]
|
|
++ lib.optionals cfg.quota.enable [
|
|
# 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";
|
|
|
|
# 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";
|
|
|
|
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 = {
|
|
# Allow all TLSv1.3 cipher suites
|
|
Ciphersuites = concatStringsSep ":" [
|
|
"TLS_AES_256_GCM_SHA384"
|
|
"TLS_AES_128_GCM_SHA256"
|
|
"TLS_CHACHA20_POLY1305_SHA256"
|
|
];
|
|
|
|
# Full list: openssl list -tls-groups
|
|
# 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
|
|
Groups = mkGroupString [
|
|
[ "*X25519MLKEM768" ]
|
|
[ "*X25519" ]
|
|
[
|
|
"P-256"
|
|
"P-384"
|
|
]
|
|
];
|
|
SignatureAlgorithms = concatStringsSep ":" [
|
|
# Full list: openssl list -tls-signature-algorithms
|
|
# Reduced to algorithms with key material supported in CA/B
|
|
# baseline requirements and excluding deprecated algorithms
|
|
# like SHA1.
|
|
|
|
# EcDSA certificates
|
|
# https://cabforum.org/working-groups/server/baseline-requirements/requirements/#71312-ecdsa
|
|
"ecdsa_secp256r1_sha256"
|
|
"ecdsa_secp384r1_sha384"
|
|
"ecdsa_secp521r1_sha512"
|
|
|
|
# RSA certificates
|
|
# https://cabforum.org/working-groups/server/baseline-requirements/requirements/#71311-rsa
|
|
"rsa_pss_rsae_sha256"
|
|
"rsa_pss_rsae_sha384"
|
|
"rsa_pss_rsae_sha512"
|
|
"rsa_pss_pss_sha256"
|
|
"rsa_pss_pss_sha384"
|
|
"rsa_pss_pss_sha512"
|
|
"rsa_pkcs1_sha256"
|
|
"rsa_pkcs1_sha384"
|
|
"rsa_pkcs1_sha512"
|
|
];
|
|
};
|
|
};
|
|
};
|
|
tls_config_name = "postfix";
|
|
|
|
# Algorithm selection happens through `tls_config_file` instead.
|
|
tls_eecdh_auto_curves = [ ];
|
|
tls_ffdhe_auto_groups = [ ];
|
|
|
|
# Require AEAD & ECDHE for TLSv1.2.
|
|
tls_high_cipherlist = concatStringsSep ":" [
|
|
"ECDHE-ECDSA-AES256-GCM-SHA384"
|
|
"ECDHE-RSA-AES256-GCM-SHA384"
|
|
"ECDHE-ECDSA-AES128-GCM-SHA256"
|
|
"ECDHE-RSA-AES128-GCM-SHA256"
|
|
"ECDHE-ECDSA-CHACHA20-POLY1305"
|
|
"ECDHE-RSA-CHACHA20-POLY1305"
|
|
];
|
|
|
|
# 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}"
|
|
];
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|