nixos-config/modules/mailserver.nix

415 lines
13 KiB
Nix
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{ 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 users 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; };
};
};
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"; # 48MiB
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;
};
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;
enableLmtp = true;
enablePAM = false;
mailUser = "vmail";
mailGroup = "vmail";
mailLocation = "maildir:${cfg.storage}/%d/%n";
sslServerCert = "${certDir}/fullchain.pem";
sslServerKey = "${certDir}/key.pem";
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
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
'';
};
# 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;
'';
"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;
};
};
};
}