ecbe707330
Added support means we allow it, but for now we don't prefer it, since it has not seen much use yet. For Postfix that means it lands below the two groups that already send a key share and save us a roundtrip. https://www.ietf.org/archive/id/draft-kwiatkowski-tls-ecdhe-mlkem-02.html
540 lines
17 KiB
Nix
540 lines
17 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
|
|
pkgs
|
|
lib
|
|
;
|
|
});
|
|
|
|
let
|
|
inherit (lib)
|
|
attrNames
|
|
concatMapStringsSep
|
|
filterAttrs
|
|
mapAttrs'
|
|
mkForce
|
|
mkIf
|
|
mkMerge
|
|
nameValuePair
|
|
;
|
|
|
|
cfg = config.mailserver;
|
|
|
|
passwdDir = "/run/dovecot2";
|
|
passwdFile = "${passwdDir}/passwd";
|
|
userdbFile = "${passwdDir}/userdb";
|
|
|
|
genPasswdScript =
|
|
pkgs.writeScript "generate-password-file"
|
|
# bash
|
|
''
|
|
#!${pkgs.stdenv.shell}
|
|
|
|
set -euo pipefail
|
|
|
|
if (! test -d "${passwdDir}"); then
|
|
mkdir "${passwdDir}"
|
|
chmod 755 "${passwdDir}"
|
|
fi
|
|
|
|
# Prevent world-readable password files, even temporarily.
|
|
umask 077
|
|
|
|
prepend_scheme() {
|
|
case "$1" in
|
|
{*}*) printf '%s' "$1" ;;
|
|
*) printf '{CRYPT}%s' "$1" ;;
|
|
esac
|
|
}
|
|
|
|
for f in ${
|
|
builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.accounts)
|
|
}; do
|
|
if [ ! -f "$f" ]; then
|
|
echo "Expected password hash file $f does not exist!"
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
cat <<EOF > ${passwdFile}
|
|
${lib.concatStringsSep "\n" (
|
|
lib.mapAttrsToList (
|
|
name: _:
|
|
if lib.elem name accountsWithPlaintextPasswordFiles then
|
|
"${name}:${"$(sed -n '1{p;p;q}' ${passwordFiles."${name}"} | ${lib.getExe' config.services.dovecot2.package "doveadm"} pw)"}::::::"
|
|
else
|
|
"${name}:${"$(prepend_scheme \"$(head -n 1 ${passwordFiles."${name}"})\")"}::::::"
|
|
) cfg.accounts
|
|
)}
|
|
EOF
|
|
chown dovecot2:dovecot2 ${passwdFile}
|
|
|
|
cat <<EOF > ${userdbFile}
|
|
${lib.concatStringsSep "\n" (
|
|
lib.mapAttrsToList (
|
|
name: value:
|
|
# https://doc.dovecot.org/2.4.3/core/config/auth/databases/passwd_file.html
|
|
# https://doc.dovecot.org/2.4.3/core/plugins/quota.html#per-user-quota
|
|
# https://dovecot.org/mailman3/archives/list/dovecot@dovecot.org/thread/67DBLLW4L5QBTEYRKGA26POFZ52ZR7ZO/#67DBLLW4L5QBTEYRKGA26POFZ52ZR7ZO
|
|
"${name}:::::::"
|
|
+ lib.optionalString (value.quota != null) "userdb_quota/user/storage_size=${value.quota}"
|
|
) cfg.accounts
|
|
)}
|
|
EOF
|
|
chown dovecot2:dovecot2 ${userdbFile}
|
|
'';
|
|
|
|
junkMailboxes = builtins.attrNames (
|
|
lib.filterAttrs (_: v: v ? "special_use" && v.special_use == "\\Junk") cfg.mailboxes
|
|
);
|
|
junkMailboxNumber = builtins.length junkMailboxes;
|
|
# The assertion guarantees there is exactly one Junk mailbox.
|
|
junkMailboxName = if junkMailboxNumber == 1 then builtins.elemAt junkMailboxes 0 else "";
|
|
|
|
mkLdapSearchScope =
|
|
scope:
|
|
(
|
|
if scope == "sub" then
|
|
"subtree"
|
|
else if scope == "one" then
|
|
"onelevel"
|
|
else
|
|
scope
|
|
);
|
|
in
|
|
{
|
|
config = lib.mkIf cfg.enable {
|
|
assertions = [
|
|
{
|
|
assertion = junkMailboxNumber == 1;
|
|
message = "nixos-mailserver requires exactly one dovecot mailbox with the 'special_use' flag set to '\\Junk' (${builtins.toString junkMailboxNumber} have been found)";
|
|
}
|
|
(
|
|
let
|
|
usersWithQuota = attrNames (
|
|
filterAttrs (_: account: account.quota != null) config.mailserver.accounts
|
|
);
|
|
in
|
|
{
|
|
assertion = !cfg.quota.enable -> usersWithQuota == [ ];
|
|
message = ''
|
|
Without quota support enabled, per-user quotas cannot be applied to the following accounts:
|
|
|
|
${concatMapStringsSep "\n" (account: "- ${account}") usersWithQuota}
|
|
|
|
Either remove per user quota settings or re-enable `mailserver.quota.enable`.
|
|
'';
|
|
}
|
|
)
|
|
];
|
|
|
|
warnings =
|
|
lib.optional
|
|
(
|
|
(builtins.length cfg.fullTextSearch.languages > 1)
|
|
&& (builtins.elem "stopwords" cfg.fullTextSearch.filters)
|
|
)
|
|
''
|
|
Using stopwords in `mailserver.fullTextSearch.filters` with multiple
|
|
languages in `mailserver.fullTextSearch.languages` configured WILL
|
|
cause some searches to fail.
|
|
|
|
The recommended solution is to NOT use the stopword filter when
|
|
multiple languages are present in the configuration.
|
|
'';
|
|
|
|
security.acme.certs = lib.mkIf withACME {
|
|
${cfg.x509.useACMEHost} = {
|
|
reloadServices = [ "dovecot.service" ];
|
|
};
|
|
};
|
|
|
|
# Dovecot modules
|
|
environment.systemPackages = [
|
|
pkgs.dovecot_pigeonhole
|
|
];
|
|
|
|
# For compatibility with python imaplib
|
|
environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
|
|
|
|
services.dovecot2 = {
|
|
enable = true;
|
|
package = pkgs.dovecot; # pin over stateVersion logic in nixox 26.05
|
|
enablePAM = mkForce false;
|
|
|
|
sieve.pipeBins = map lib.getExe [
|
|
(pkgs.writeShellScriptBin "rspamd-learn-ham.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_ham")
|
|
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_spam")
|
|
];
|
|
|
|
# https://doc.dovecot.org/2.4.3/core/settings/syntax.html
|
|
# https://doc.dovecot.org/2.4.3/core/settings/types.html#boolean-list
|
|
settings = mkMerge [
|
|
({
|
|
# https://doc.dovecot.org/main/core/summaries/settings.html#dovecot_config_version
|
|
dovecot_config_version = "2.4.3";
|
|
# https://doc.dovecot.org/main/core/summaries/settings.html#dovecot_storage_version
|
|
dovecot_storage_version = "2.3.21.1";
|
|
|
|
# server identity
|
|
hostname = cfg.fqdn;
|
|
|
|
# vmail user
|
|
mail_uid = cfg.storage.owner;
|
|
mail_gid = cfg.storage.group;
|
|
mail_access_groups = cfg.storage.group;
|
|
|
|
# authentication
|
|
auth_mechanisms = [
|
|
"plain"
|
|
"login"
|
|
];
|
|
|
|
# backend services
|
|
"service anvil" = {
|
|
"unix_listener anvil" = {
|
|
mode = "0660";
|
|
group = cfg.storage.group;
|
|
};
|
|
};
|
|
"service auth" = {
|
|
"unix_listener auth" = {
|
|
user = config.services.postfix.user;
|
|
group = config.services.postfix.group;
|
|
mode = "0660";
|
|
};
|
|
};
|
|
"service lmtp" = {
|
|
"unix_listener dovecot-lmtp" = {
|
|
user = config.services.postfix.user;
|
|
group = config.services.postfix.group;
|
|
mode = "0600";
|
|
};
|
|
user = cfg.storage.owner;
|
|
vsz_limit = "${toString cfg.lmtpMemoryLimit} MB";
|
|
};
|
|
|
|
# frontend services
|
|
"service imap-login" = mkIf (cfg.enableImap || cfg.enableImapSsl) {
|
|
"inet_listener imap" = {
|
|
# https://dovecot.org/pipermail/dovecot/2010-March/047479.html
|
|
port = if cfg.enableImap then 143 else 0;
|
|
};
|
|
"inet_listener imaps" = mkIf cfg.enableImapSsl {
|
|
port = 993;
|
|
ssl = true;
|
|
};
|
|
};
|
|
"service pop3-login" = mkIf (cfg.enablePop3 || cfg.enablePop3Ssl) {
|
|
"inet_listener pop3" = {
|
|
# https://dovecot.org/pipermail/dovecot/2010-March/047479.html
|
|
port = if cfg.enablePop3 then 110 else 0;
|
|
};
|
|
"inet_listener pop3s" = mkIf cfg.enablePop3Ssl {
|
|
port = 995;
|
|
ssl = true;
|
|
};
|
|
};
|
|
"service imap" = {
|
|
vsz_limit = "${toString cfg.imapMemoryLimit} MB";
|
|
};
|
|
|
|
# protocols
|
|
protocols = {
|
|
lmtp = true;
|
|
imap = cfg.enableImap || cfg.enableImapSsl;
|
|
pop3 = cfg.enablePop3 || cfg.enablePop3Ssl;
|
|
sieve = cfg.enableManageSieve;
|
|
};
|
|
|
|
"protocol lmtp" = {
|
|
mail_plugins = {
|
|
sieve = true;
|
|
};
|
|
};
|
|
"protocol imap" = {
|
|
mail_max_userip_connections = cfg.maxConnectionsPerUser;
|
|
mail_plugins = {
|
|
imap_sieve = true;
|
|
};
|
|
};
|
|
"protocol pop3" = {
|
|
mail_max_userip_connections = cfg.maxConnectionsPerUser;
|
|
};
|
|
|
|
# tls settings
|
|
ssl_server_cert_file = x509CertificateFile;
|
|
ssl_server_key_file = x509PrivateKeyFile;
|
|
# https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7
|
|
ssl = "required";
|
|
ssl_min_protocol = "TLSv1.2";
|
|
ssl_server_prefer_ciphers = "client";
|
|
ssl_cipher_list = lib.concatStringsSep ":" [
|
|
# TLS1.3
|
|
"TLS_AES_128_GCM_SHA256"
|
|
"TLS_CHACHA20_POLY1305_SHA256"
|
|
"TLS_AES_256_GCM_SHA384"
|
|
# TLS1.2
|
|
# EC key material
|
|
"ECDHE-ECDSA-AES128-GCM-SHA256"
|
|
"ECDHE-ECDSA-CHACHA20-POLY1305"
|
|
"ECDHE-ECDSA-AES256-GCM-SHA384"
|
|
# RSA key material
|
|
"ECDHE-RSA-AES128-GCM-SHA256"
|
|
"ECDHE-RSA-CHACHA20-POLY1305"
|
|
"ECDHE-RSA-AES256-GCM-SHA384"
|
|
];
|
|
ssl_curve_list = lib.concatStringsSep ":" [
|
|
"X25519MLKEM768"
|
|
"X25519"
|
|
"SecP256r1MLKEM768"
|
|
"prime256v1"
|
|
"secp384r1"
|
|
];
|
|
|
|
# default user mailboxes
|
|
"namespace inbox" = {
|
|
inbox = true;
|
|
separator = cfg.hierarchySeparator;
|
|
}
|
|
// mapAttrs' (name: value: nameValuePair ''mailbox "${name}"'' value) cfg.mailboxes;
|
|
lda_mailbox_autosubscribe = true;
|
|
lda_mailbox_autocreate = true;
|
|
|
|
# subaddressing
|
|
recipient_delimiter = cfg.recipientDelimiter;
|
|
lmtp_save_to_detail_mailbox = cfg.lmtpSaveToDetailMailbox;
|
|
|
|
# sieve filtering
|
|
"sieve_script spamfilter" = {
|
|
# junk filter
|
|
path = pkgs.writeText "after.sieve" ''
|
|
require "fileinto";
|
|
|
|
if header :is "X-Spam" "Yes" {
|
|
fileinto "${junkMailboxName}";
|
|
stop;
|
|
}
|
|
'';
|
|
type = "after";
|
|
};
|
|
"sieve_script default" = {
|
|
# declarative
|
|
type = "default";
|
|
path = "${cfg.sieveDirectory}/%{user}/default.sieve";
|
|
};
|
|
|
|
"sieve_script personal" = {
|
|
# managesieve
|
|
type = "personal";
|
|
active_path = "${cfg.sieveDirectory}/%{user}/active.sieve";
|
|
path = "${cfg.sieveDirectory}/%{user}/scripts";
|
|
};
|
|
|
|
sieve_extensions = {
|
|
fileinto = true;
|
|
};
|
|
sieve_global_extensions = {
|
|
"vnd.dovecot.pipe" = true;
|
|
};
|
|
sieve_plugins = {
|
|
sieve_imapsieve = true;
|
|
sieve_extprograms = true;
|
|
};
|
|
|
|
# imapsieve (spam/ham learning)
|
|
"mailbox ${junkMailboxName}" = {
|
|
"sieve_script spam" = {
|
|
cause = [
|
|
"APPEND"
|
|
"COPY"
|
|
];
|
|
path = ./dovecot/imap_sieve/report-spam.sieve;
|
|
type = "before";
|
|
};
|
|
};
|
|
"imapsieve_from ${junkMailboxName}" = {
|
|
"sieve_script ham" = {
|
|
cause = "copy";
|
|
path = ./dovecot/imap_sieve/report-ham.sieve;
|
|
type = "before";
|
|
};
|
|
};
|
|
|
|
mailbox_list_layout = cfg.storage.directoryLayout;
|
|
mailbox_list_utf8 = cfg.useUTF8FolderNames;
|
|
mail_driver = "maildir";
|
|
mail_path = "~/mail";
|
|
|
|
# declarative users
|
|
"userdb declarative" = {
|
|
driver = "passwd-file";
|
|
passwd_file_path = userdbFile;
|
|
fields = {
|
|
home = "${cfg.storage.path}/%{user | domain}/%{user | username}";
|
|
inherit (cfg.storage) uid gid;
|
|
mail_index_path = "${
|
|
if cfg.indexDir != null then cfg.indexDir else cfg.storage.path
|
|
}/%{user | domain }/%{user | username}";
|
|
};
|
|
};
|
|
"passdb declarative" = {
|
|
driver = "passwd-file";
|
|
passwd_file_path = passwdFile;
|
|
};
|
|
|
|
})
|
|
(mkIf cfg.ldap.enable {
|
|
# ldap users
|
|
ssl_client_ca_file = cfg.ldap.caFile;
|
|
ssl_client_require_valid_cert = true;
|
|
|
|
ldap_version = 3;
|
|
ldap_uris = cfg.ldap.uris;
|
|
ldap_starttls = cfg.ldap.startTls;
|
|
ldap_auth_dn = cfg.ldap.bind.dn;
|
|
ldap_auth_dn_password = "</run/credentials/dovecot.service/ldap-bind-pw";
|
|
ldap_base = cfg.ldap.base;
|
|
ldap_scope = mkLdapSearchScope cfg.ldap.scope;
|
|
|
|
"userdb ldap" = {
|
|
driver = "ldap";
|
|
filter = cfg.ldap.dovecot.userFilter;
|
|
fields = {
|
|
home = "${cfg.storage.path}/ldap/%{ldap:${cfg.ldap.attributes.uuid}}";
|
|
inherit (cfg.storage) uid gid;
|
|
mail_index_path = "${
|
|
if cfg.indexDir != null then cfg.indexDir else cfg.storage.path
|
|
}/ldap/%{ldap:${cfg.ldap.attributes.uuid}}";
|
|
};
|
|
ldap_connection_group = "ldap-userdb-conn";
|
|
};
|
|
"passdb ldap" = {
|
|
driver = "ldap";
|
|
filter = cfg.ldap.dovecot.passFilter;
|
|
fields = {
|
|
password = "%{ldap:userPassword}";
|
|
};
|
|
ldap_connection_group = "ldap-passdb-conn";
|
|
};
|
|
})
|
|
(mkIf cfg.quota.enable {
|
|
mail_plugins.quota = true;
|
|
|
|
"protocol imap".mail_plugins.imap_quota = true;
|
|
|
|
"service quota-status" = {
|
|
executable = toString [
|
|
"${config.services.dovecot2.package}/libexec/dovecot/quota-status"
|
|
"-p"
|
|
"postfix"
|
|
];
|
|
"unix_listener quota-status" = {
|
|
user = "postfix";
|
|
};
|
|
client_limit = 1;
|
|
vsz_limit = "${toString cfg.quotaStatusMemoryLimit} MB";
|
|
};
|
|
|
|
quota_status_success = "DUNNO";
|
|
quota_status_nouser = "DUNNO";
|
|
quota_status_overquota = "552 5.2.2 Mailbox is full";
|
|
# quota_storage_grace = "10M";
|
|
|
|
"quota user" = {
|
|
driver = "count";
|
|
storage_size = mkIf (cfg.quota.defaults.perUser != null) cfg.quota.defaults.perUser;
|
|
};
|
|
})
|
|
(mkIf cfg.fullTextSearch.enable (
|
|
{
|
|
mail_plugins = {
|
|
fts = true;
|
|
fts_flatcurve = true;
|
|
};
|
|
|
|
"service indexer-worker" = mkIf (cfg.fullTextSearch.memoryLimit != null) {
|
|
vsz_limit = "${toString cfg.fullTextSearch.memoryLimit} MB";
|
|
};
|
|
|
|
fts_autoindex = cfg.fullTextSearch.autoIndex;
|
|
fts_driver = "flatcurve";
|
|
fts_search_add_missing = "yes";
|
|
fts_search_read_fallback = cfg.fullTextSearch.fallback;
|
|
fts_header_excludes = lib.genAttrs cfg.fullTextSearch.headerExcludes (_: true);
|
|
|
|
"fts flatcurve" = {
|
|
flatcurve_substring_search = cfg.fullTextSearch.substringSearch;
|
|
};
|
|
|
|
# languages
|
|
language_filters = lib.genAttrs cfg.fullTextSearch.filters (_: true);
|
|
language_tokenizer_address_token_maxlen = 100; # default 250 too large for Xapian
|
|
}
|
|
# build languages from list, the first one becomes the default language
|
|
// lib.listToAttrs (
|
|
lib.imap0 (i: lang: {
|
|
name = "language ${lang}";
|
|
value = (if i == 0 then { default = true; } else { }) // {
|
|
language_tokenizers = [
|
|
"generic"
|
|
"email-address"
|
|
];
|
|
};
|
|
}) cfg.fullTextSearch.languages
|
|
)
|
|
))
|
|
(mkIf cfg.debug.dovecot {
|
|
mail_debug = true;
|
|
# https://doc.dovecot.org/2.4.3/core/config/events/filter.html#common-unified-filter-language
|
|
log_debug = "category=ssl OR category=auth";
|
|
})
|
|
];
|
|
};
|
|
|
|
systemd.services.dovecot = {
|
|
preStart = ''
|
|
${genPasswdScript}
|
|
'';
|
|
reloadTriggers = lib.mkIf (!withACME) [
|
|
x509CertificateFile
|
|
x509PrivateKeyFile
|
|
];
|
|
serviceConfig = lib.optionalAttrs cfg.ldap.enable {
|
|
LoadCredential = [
|
|
"ldap-bind-pw:${cfg.ldap.bind.passwordFile}"
|
|
];
|
|
};
|
|
};
|
|
|
|
systemd.services.postfix.restartTriggers = [
|
|
genPasswdScript
|
|
];
|
|
};
|
|
}
|