540 lines
17 KiB
Nix
540 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
|
||
25 # SMTP
|
||
587 # SMTP submission
|
||
];
|
||
|
||
# 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;
|
||
|
||
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");
|
||
};
|
||
|
||
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";
|
||
};
|
||
|
||
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);
|
||
};
|
||
}
|