0da8e2b197
With the options in the upstream dovecot module gone the quota support and its option now live in our downstream module. The only behavior change this introduces is not setting a global per user default instead of the previous 100G per user. Diabling quota support and setting per user quotas now raises an assertion: ```` Failed assertions: - Without quota support enabled, per-user quotas cannot be applied to the following accounts: - lowquota@example.com Either remove per user quota settings or re-enable `mailserver.quota.enable`. ````
488 lines
16 KiB
Nix
488 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.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";
|
|
|
|
# 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}"
|
|
];
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|