The configuration dates back quite a bit and then STARTTLS was considered the best option. However, with RFC 8314 from 2018, which now recommends implicit TLS for IMAP and SMTP submission, this changed. This allows using implicit TLS for those services. STARTTLS might become deprecated and/or removed in the future.
546 lines
17 KiB
Nix
546 lines
17 KiB
Nix
{ config, lib, pkgs, ... }:
|
||
let
|
||
cfg = config.sbruder.mailserver;
|
||
|
||
certDir = config.security.acme.certs."${cfg.fqdn}".directory;
|
||
in
|
||
{
|
||
options.sbruder.mailserver = with lib; with lib.types; {
|
||
enable = mkEnableOption "simple mail server";
|
||
fqdn = mkOption {
|
||
type = str;
|
||
description = ''
|
||
FQDN of the mail server
|
||
|
||
It needs to have a matching reverse DNS record. Also, an acme
|
||
certificate with this name has to be present.
|
||
'';
|
||
example = "mail.example.com";
|
||
};
|
||
storage = mkOption {
|
||
type = path;
|
||
description = "Location of the storage for mails";
|
||
default = "/var/vmail";
|
||
};
|
||
domains = mkOption {
|
||
type = listOf str;
|
||
description = "Domains to serve";
|
||
example = [ "example.com" "example.org" ];
|
||
};
|
||
users = mkOption {
|
||
type = listOf (submodule {
|
||
options = {
|
||
address = mkOption {
|
||
type = str;
|
||
description = "Primary e-mail address of the user";
|
||
example = "jdoe@example.com";
|
||
};
|
||
passwordHash = mkOption {
|
||
type = str;
|
||
description = ''
|
||
Bcrypt hash of the user’s password. Please note that it will be
|
||
world-readable in the nix store.
|
||
|
||
You can generate a password with `nix run nixpkgs.apacheHttpd -c
|
||
htpasswd -nBC 12 "" | cut -d: -f2`
|
||
'';
|
||
example = "$2y$05$SHxhwVGx.XCd19HAcb1NKuidUxW1BwU7GeO0ZIcMTc5t2uZoYLVRK";
|
||
};
|
||
aliases = mkOption {
|
||
type = listOf str;
|
||
description = ''
|
||
A list of aliases for the user.
|
||
|
||
If multiple users have the same alias defined, mail will be
|
||
delivered to both of them.
|
||
'';
|
||
default = [ ];
|
||
example = [
|
||
"j.doe@example.com"
|
||
"jane.doe@example.com"
|
||
"postmaster@example.com"
|
||
];
|
||
};
|
||
};
|
||
});
|
||
description = "Users of the mail server";
|
||
};
|
||
cleanHeaders = mkOption {
|
||
type = listOf str;
|
||
description = "A list of regular expressions that define what headers are filtered";
|
||
default = [
|
||
"/^\\s*Received:/"
|
||
"/^\\s*User-Agent:/"
|
||
"/^\\s*X-Mailer:/"
|
||
"/^\\s*X-Originating-IP:/"
|
||
];
|
||
};
|
||
rejectSenders = mkOption {
|
||
type = listOf str;
|
||
description = "A list of senders to reject mails from";
|
||
default = [ ];
|
||
example = [
|
||
"newsletter@example.com"
|
||
"spammer@example.com"
|
||
];
|
||
};
|
||
spam = {
|
||
enable = (lib.mkEnableOption "spam filtering") // { default = true; };
|
||
};
|
||
dkim = {
|
||
enable = (lib.mkEnableOption "DKIM signing") // { default = true; };
|
||
selector = lib.mkOption {
|
||
type = str;
|
||
description = "DKIM Selector to use";
|
||
default = "mail";
|
||
};
|
||
};
|
||
};
|
||
|
||
config = lib.mkIf cfg.enable {
|
||
# Users and groups
|
||
users.users.vmail = {
|
||
uid = 10000;
|
||
group = "vmail";
|
||
home = cfg.storage;
|
||
createHome = true;
|
||
};
|
||
|
||
users.groups.vmail.gid = 10000;
|
||
|
||
# Firewall
|
||
networking.firewall.allowedTCPPorts = [
|
||
143 # IMAP
|
||
993 # IMAP (implicit TLS)
|
||
25 # SMTP
|
||
587 # SMTP submission
|
||
465 # SMTP submission (implicit TLS)
|
||
];
|
||
|
||
# Service dependencies
|
||
systemd.services.dovecot2 = {
|
||
wants = [ "acme-finished-${cfg.fqdn}.target" ];
|
||
after = [ "acme-finished-${cfg.fqdn}.target" ];
|
||
};
|
||
systemd.services.postfix = {
|
||
wants = [ "acme-finished-${cfg.fqdn}.target" ];
|
||
requires = [ "dovecot2.service" ];
|
||
after = [ "acme-finished-${cfg.fqdn}.target" "dovecot2.service" ];
|
||
};
|
||
|
||
# Reload on certificate renewal
|
||
security.acme.certs."${cfg.fqdn}".postRun = ''
|
||
if systemctl is-active dovecot2; then
|
||
systemctl --no-block reload dovecot2
|
||
fi
|
||
if systemctl is-active postfix; then
|
||
systemctl --no-block reload postfix
|
||
fi
|
||
'';
|
||
|
||
# Postfix
|
||
security.dhparams.params.postfix = { };
|
||
services.postfix =
|
||
let
|
||
listToString = lib.concatStringsSep ",";
|
||
|
||
valiases =
|
||
let
|
||
# List of attribute sets with single key-value pair
|
||
plainAliases = (lib.flatten
|
||
(map
|
||
({ address, aliases, ... }:
|
||
map
|
||
(alias: { "${alias}" = address; })
|
||
(aliases ++ lib.singleton address))
|
||
cfg.users));
|
||
|
||
# Attribute set with every alias mapped to a list of receivers
|
||
mergedAliases = (lib.attrsets.foldAttrs
|
||
(val: col: lib.singleton val ++ col)
|
||
[ ]
|
||
plainAliases);
|
||
|
||
# Contents of the aliases file
|
||
aliasesString = (lib.concatStringsSep
|
||
"\n"
|
||
(lib.mapAttrsToList
|
||
(alias: addresses: "${alias} ${listToString addresses}")
|
||
mergedAliases));
|
||
in
|
||
pkgs.writeText
|
||
"valiases"
|
||
aliasesString;
|
||
|
||
access_sender = pkgs.writeText
|
||
"access_sender"
|
||
(lib.concatMapStringsSep
|
||
"\n"
|
||
(sender: "${sender} REJECT")
|
||
cfg.rejectSenders);
|
||
|
||
submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules"
|
||
(lib.concatMapStringsSep
|
||
"\n"
|
||
(regex: "${regex} IGNORE")
|
||
cfg.cleanHeaders);
|
||
in
|
||
{
|
||
enable = true;
|
||
|
||
enableSubmission = true; # plain/STARTTLS (latter is forced in submissionOptions)
|
||
enableSubmissions = true; # submission with implicit TLS (TCP/465)
|
||
|
||
hostname = cfg.fqdn;
|
||
networksStyle = "host";
|
||
sslCert = "${certDir}/fullchain.pem";
|
||
sslKey = "${certDir}/key.pem";
|
||
|
||
recipientDelimiter = "+";
|
||
|
||
mapFiles = {
|
||
inherit access_sender valiases;
|
||
};
|
||
|
||
config = {
|
||
# General
|
||
smtpd_banner = "${cfg.fqdn} ESMTP NO UCE";
|
||
disable_vrfy_command = true; # disable check if mailbox exists
|
||
enable_long_queue_ids = true; # better for debugging
|
||
strict_rfc821_envelopes = true; # only accept properly formatted envelope
|
||
message_size_limit = "50331648"; # 48 MiB
|
||
|
||
virtual_mailbox_domains = listToString cfg.domains;
|
||
virtual_mailbox_maps = "hash:/var/lib/postfix/conf/valiases";
|
||
virtual_alias_maps = "hash:/var/lib/postfix/conf/valiases";
|
||
virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp";
|
||
|
||
smtpd_recipient_restrictions = listToString [
|
||
"reject_non_fqdn_recipient"
|
||
"reject_rbl_client ix.dnsbl.manitu.net"
|
||
"reject_unknown_recipient_domain"
|
||
"reject_unverified_recipient"
|
||
];
|
||
|
||
smtpd_client_restrictions = listToString [
|
||
"reject_rbl_client ix.dnsbl.manitu.net"
|
||
"reject_unknown_client_hostname"
|
||
];
|
||
|
||
smtpd_sender_restrictions = listToString [
|
||
"check_sender_access hash:/var/lib/postfix/conf/access_sender"
|
||
"reject_non_fqdn_sender"
|
||
"reject_unknown_sender_domain"
|
||
];
|
||
|
||
# generated 2021-02-04, Mozilla Guideline v5.6, Postfix 3.5.6, OpenSSL 1.1.1i, intermediate configuration
|
||
# https://ssl-config.mozilla.org/#server=postfix&version=3.5.6&config=intermediate&openssl=1.1.1i&guideline=5.6
|
||
smtpd_tls_security_level = "may";
|
||
smtpd_tls_auth_only = "yes";
|
||
smtpd_tls_mandatory_protocols = "!SSLv2, !SSLv3, !TLSv1, !TLSv1.1";
|
||
smtpd_tls_protocols = "!SSLv2, !SSLv3, !TLSv1, !TLSv1.1";
|
||
smtpd_tls_mandatory_ciphers = "medium";
|
||
smtpd_tls_loglevel = "1";
|
||
|
||
tls_medium_cipherlist = listToString [
|
||
"ECDHE-ECDSA-AES128-GCM-SHA256"
|
||
"ECDHE-RSA-AES128-GCM-SHA256"
|
||
"ECDHE-ECDSA-AES256-GCM-SHA384"
|
||
"ECDHE-RSA-AES256-GCM-SHA384"
|
||
"ECDHE-ECDSA-CHACHA20-POLY1305"
|
||
"ECDHE-RSA-CHACHA20-POLY1305"
|
||
"DHE-RSA-AES128-GCM-SHA256"
|
||
"DHE-RSA-AES256-GCM-SHA384"
|
||
];
|
||
tls_preempt_cipherlist = "no";
|
||
|
||
smtpd_tls_dh1024_param_file = config.security.dhparams.params.postfix.path;
|
||
|
||
smtpd_milters = lib.mkIf cfg.dkim.enable (lib.singleton "unix:/run/opendkim/opendkim.sock");
|
||
non_smtpd_milters = lib.mkIf cfg.dkim.enable (lib.singleton "unix:/run/opendkim/opendkim.sock");
|
||
};
|
||
|
||
# plain/STARTTLS (forced with smtpd_tls_security_level)
|
||
submissionOptions = {
|
||
smtpd_tls_security_level = "encrypt";
|
||
smtpd_sasl_auth_enable = "yes";
|
||
smtpd_sasl_type = "dovecot";
|
||
smtpd_sasl_path = "/run/dovecot2/auth";
|
||
|
||
smtpd_sender_login_maps = "hash:/etc/postfix/valiases";
|
||
|
||
smtpd_recipient_restrictions = listToString [ ];
|
||
|
||
smtpd_client_restrictions = listToString [
|
||
"permit_sasl_authenticated"
|
||
"reject"
|
||
];
|
||
|
||
smtpd_sender_restrictions = listToString [
|
||
"reject_sender_login_mismatch"
|
||
];
|
||
|
||
cleanup_service_name = "submission-header-cleanup";
|
||
};
|
||
# implicit TLS
|
||
submissionsOptions = config.services.postfix.submissionOptions;
|
||
|
||
masterConfig = {
|
||
submission-header-cleanup = {
|
||
private = false;
|
||
maxproc = 0;
|
||
command = "cleanup";
|
||
args = [ "-o" "header_checks=pcre:${submissionHeaderCleanupRules}" ];
|
||
};
|
||
};
|
||
};
|
||
|
||
# Dovecot
|
||
services.dovecot2 =
|
||
let
|
||
postfixCfg = config.services.postfix;
|
||
|
||
passdb = pkgs.writeText "dovecot-users"
|
||
(lib.concatMapStringsSep
|
||
"\n"
|
||
({ address, passwordHash, ... }: "${address}:{BLF-CRYPT}${passwordHash}")
|
||
cfg.users);
|
||
in
|
||
{
|
||
enable = true;
|
||
|
||
modules = with pkgs; [ dovecot_pigeonhole ];
|
||
|
||
enableLmtp = true;
|
||
enablePAM = false;
|
||
|
||
mailUser = "vmail";
|
||
mailGroup = "vmail";
|
||
mailLocation = "maildir:${cfg.storage}/%d/%n";
|
||
|
||
sslServerCert = "${certDir}/fullchain.pem";
|
||
sslServerKey = "${certDir}/key.pem";
|
||
|
||
mailboxes = {
|
||
Archive = { specialUse = "Archive"; auto = "subscribe"; };
|
||
Sent = { specialUse = "Sent"; auto = "subscribe"; };
|
||
Drafts = { specialUse = "Drafts"; auto = "subscribe"; };
|
||
Trash = { specialUse = "Trash"; auto = "subscribe"; };
|
||
Spam = { specialUse = "Junk"; auto = "subscribe"; };
|
||
};
|
||
|
||
sieveScripts = {
|
||
before = pkgs.writeText "spam.sieve" ''
|
||
require "fileinto";
|
||
|
||
if header :is "X-Spam" "Yes" {
|
||
fileinto "Spam";
|
||
}
|
||
'';
|
||
};
|
||
|
||
extraConfig = ''
|
||
# generated 2021-02-04, Mozilla Guideline v5.6, Dovecot 2.3.13, OpenSSL 1.1.1i, intermediate configuration
|
||
# https://ssl-config.mozilla.org/#server=dovecot&version=2.3.13&config=intermediate&openssl=1.1.1i&guideline=5.6
|
||
ssl = required
|
||
ssl_min_protocol = TLSv1.2
|
||
ssl_cipher_list = ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
|
||
ssl_prefer_server_ciphers = no
|
||
|
||
protocol imap {
|
||
mail_plugins = $mail_plugins imap_sieve
|
||
}
|
||
|
||
protocol lmtp {
|
||
mail_plugins = $mail_plugins sieve
|
||
}
|
||
|
||
service imap-login {
|
||
inet_listener imap {
|
||
}
|
||
}
|
||
|
||
service lmtp {
|
||
unix_listener dovecot-lmtp {
|
||
mode = 0600
|
||
user = ${postfixCfg.user}
|
||
group = ${postfixCfg.group}
|
||
}
|
||
}
|
||
|
||
passdb {
|
||
driver = passwd-file
|
||
args = username_format=%u ${passdb}
|
||
}
|
||
|
||
userdb {
|
||
driver = static
|
||
args = uid=vmail gid=vmail home=${cfg.storage}/%d/%n
|
||
}
|
||
|
||
service auth {
|
||
unix_listener auth {
|
||
mode = 0660
|
||
user = ${postfixCfg.user}
|
||
group = ${postfixCfg.group}
|
||
}
|
||
}
|
||
|
||
lda_mailbox_autosubscribe = yes
|
||
lda_mailbox_autocreate = yes
|
||
|
||
plugin {
|
||
sieve_plugins = sieve_imapsieve sieve_extprograms
|
||
|
||
${lib.optionalString cfg.spam.enable ''
|
||
imapsieve_mailbox1_name = Spam
|
||
imapsieve_mailbox1_causes = COPY
|
||
imapsieve_mailbox1_before = file:/var/lib/dovecot/sieve/learn-spam.sieve
|
||
|
||
imapsieve_mailbox2_name = *
|
||
imapsieve_mailbox2_from = Spam
|
||
imapsieve_mailbox2_causes = COPY
|
||
imapsieve_mailbox2_before = file:/var/lib/dovecot/sieve/learn-ham.sieve
|
||
sieve_pipe_bin_dir = ${pkgs.symlinkJoin { name = "sieve-pipe-bin-dir"; paths = with pkgs; [ rspamd ]; } }/bin
|
||
''}
|
||
|
||
sieve_global_extensions = +vnd.dovecot.pipe
|
||
}
|
||
'';
|
||
};
|
||
systemd.services.dovecot2.preStart = lib.mkIf cfg.spam.enable
|
||
(lib.mkAfter
|
||
(lib.concatStrings
|
||
(lib.mapAttrsToList
|
||
(name: content: ''
|
||
cp ${pkgs.writeText name content} /var/lib/dovecot/sieve/${name}
|
||
'')
|
||
{
|
||
"learn-spam.sieve" = ''
|
||
require ["vnd.dovecot.pipe", "copy", "imapsieve"];
|
||
pipe :copy "rspamc" ["learn_spam"];
|
||
'';
|
||
"learn-ham.sieve" = ''
|
||
require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"];
|
||
|
||
if environment :matches "imap.mailbox" "*" {
|
||
set "mailbox" "''${1}";
|
||
}
|
||
|
||
if string "''${mailbox}" "Trash" {
|
||
stop;
|
||
}
|
||
|
||
pipe :copy "rspamc" ["learn_ham"];
|
||
'';
|
||
})));
|
||
|
||
# DNS (recursor for DNSBLs etc.)
|
||
services.resolved.enable = false;
|
||
services.unbound = {
|
||
enable = true;
|
||
settings = {
|
||
server = {
|
||
interface = [ "127.0.0.53" ];
|
||
};
|
||
};
|
||
resolveLocalQueries = false; # does not work with .53
|
||
};
|
||
networking.resolvconf.extraConfig = ''
|
||
name_servers='127.0.0.53'
|
||
'';
|
||
|
||
# rspamd
|
||
sops.secrets.rspamd-worker-controller = lib.mkIf cfg.spam.enable {
|
||
owner = config.users.users.rspamd.name;
|
||
sopsFile = ../machines + "/${config.networking.hostName}/secrets.yaml";
|
||
};
|
||
|
||
services.rspamd = {
|
||
enable = cfg.spam.enable;
|
||
postfix.enable = true;
|
||
workers = {
|
||
normal = {
|
||
includes = [ "$CONFDIR/worker-normal.inc" ];
|
||
bindSockets = lib.singleton {
|
||
socket = "/run/rspamd/rspamd.sock";
|
||
mode = "0660";
|
||
owner = "${config.services.rspamd.user}";
|
||
group = "${config.services.rspamd.group}";
|
||
};
|
||
};
|
||
controller = {
|
||
includes = [ "$CONFDIR/worker-controller.inc" ];
|
||
bindSockets = [ "127.0.0.1:11334" ] ++ lib.optional config.sbruder.wireguard.home.enable "${config.sbruder.wireguard.home.address}:11334";
|
||
};
|
||
};
|
||
locals = {
|
||
"dkim_signing.conf".text = ''
|
||
enabled = false;
|
||
'';
|
||
"logging.inc".text = ''
|
||
# starts at info, drops to notice once started up
|
||
level = "silent";
|
||
'';
|
||
"milter_headers.conf".text = ''
|
||
extended_spam_headers = true;
|
||
'';
|
||
"multimap.conf".text = ''
|
||
SENDER_DOMAIN_BLOCKED {
|
||
type = "from";
|
||
filter = "email:domain:tld";
|
||
map = "/var/lib/rspamd/blocked_sender_domains.map";
|
||
symbol = "SENDER_DOMAIN_BLOCKED";
|
||
description = "Sender’s effective second level domain is manually blocked";
|
||
score = 8.0;
|
||
}
|
||
'';
|
||
"redis.conf".text = ''
|
||
servers = "127.0.0.1:${toString config.services.redis.servers.rspamd.port}"
|
||
'';
|
||
"worker-controller.inc".source = config.sops.secrets.rspamd-worker-controller.path; # includes password
|
||
};
|
||
};
|
||
|
||
services.redis = lib.mkIf cfg.spam.enable {
|
||
vmOverCommit = true;
|
||
servers.rspamd = {
|
||
enable = true;
|
||
port = 6379;
|
||
};
|
||
};
|
||
|
||
# DKIM
|
||
services.opendkim = lib.mkIf cfg.dkim.enable {
|
||
enable = true;
|
||
selector = cfg.dkim.selector;
|
||
domains = "csl:${lib.concatStringsSep "," cfg.domains}";
|
||
configFile = pkgs.writeText "opendkim.conf" ''
|
||
UMask 0002
|
||
'';
|
||
};
|
||
systemd.services.opendkim = lib.mkIf cfg.dkim.enable {
|
||
# changed to use larger key size
|
||
preStart =
|
||
let
|
||
inherit (config.services.opendkim) keyPath selector;
|
||
in
|
||
lib.mkForce ''
|
||
cd "${keyPath}"
|
||
if ! test -f ${selector}.private; then
|
||
${pkgs.opendkim}/bin/opendkim-genkey \
|
||
-s ${selector} \
|
||
-d all-domains-generic-key \
|
||
-b 4096
|
||
echo "Generated OpenDKIM key! Please update your DNS settings:\n"
|
||
echo "-------------------------------------------------------------"
|
||
cat ${selector}.txt
|
||
echo "-------------------------------------------------------------"
|
||
fi
|
||
'';
|
||
};
|
||
|
||
users.users.postfix.extraGroups = lib.mkIf cfg.dkim.enable (lib.singleton config.users.users.opendkim.group);
|
||
};
|
||
}
|