mirror of
https://gitlab.com/simple-nixos-mailserver/nixos-mailserver.git
synced 2025-12-25 04:10:52 +01:00
Drop most of the existing certificate handling, because we're effectively duplicating functionality that NixOS offers for free with better design, testing and maintainance than what we could provide downstream. The remaining two options are to reference an existing `security.acme.certs` configuration through `mailserver.x509.useACMEHost` or to provide existing key material via `mailserver.x509.certificateFile` and `mailserver.x509.privateKeyFile`. Support for automatic creation of self-signed certificates has been removed, because it is undesirable in public mail setups. The updated setup guide now displays the recommended configuration that relies on the NixOS ACME module, but requires further customization to select a suitable challenge. Co-Authored-By: Emily <git@emilylange.de>
481 lines
16 KiB
Nix
481 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.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;
|
|
|
|
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}"
|
|
];
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|