Compare commits

...
Sign in to create a new pull request.

17 commits
main ... quota

Author SHA1 Message Date
1e75d07d56 foobar 2024-12-28 01:42:00 +01:00
c90e09d125 Add configuration option to alias entire domains and respective tests 2024-12-28 00:40:32 +01:00
48bd6b8981 tests: Add various tests for alias functionality 2024-12-28 00:35:18 +01:00
fb834ec7ee tests: Add basic tests for sending/receiving mails and verification of headers 2024-12-28 00:34:56 +01:00
b630755ea8 tests: common: Add lib.nix containing various helpers for testing mailsystem behaviour 2024-12-28 00:23:33 +01:00
a033432bb8 flake.nix: Add and configure treefmt-nix for nix fmt 2024-12-28 00:23:33 +01:00
b81e8f00bb flake.nix: Rename pre-commit-hooks-nix into git-hooks-nix
Cachix has renamed their project.
2024-12-28 00:23:33 +01:00
5c280dcedb tests: minimal: Configure and verify vmail user/group/uid/gid 2024-12-28 00:23:33 +01:00
9687dbaae1 Add minimal (internal) tests 2024-12-28 00:23:33 +01:00
a592881b8b mailsystem: Add option to use selfsigned certificates in preparation for testing 2024-12-28 00:23:33 +01:00
6d6b856bee flake.nix: Actually expose mailsystem as flake module 2024-12-28 00:23:33 +01:00
3d0e0dd95c mailsystem: Add configuration for roundcube as webmail interface 2024-12-28 00:23:33 +01:00
5583676384 mailsystem: rspamd: Add configuration options to make rspamd's web ui accessible 2024-12-28 00:23:33 +01:00
0ce3ecae52 mailsystem: dovecot: Autolearn ham/spam when moving mails 2024-12-28 00:23:33 +01:00
d35763a8a2 mailsystem: Configure rspamd as spam filter 2024-12-28 00:23:33 +01:00
b7fac23bd1 mailsystem: Add basic postfix configuration 2024-12-28 00:23:33 +01:00
bc01d4d2d0 mailsystem: Add minimal dovecot configuration 2024-12-28 00:21:53 +01:00
20 changed files with 1574 additions and 47 deletions

77
flake.lock generated
View file

@ -36,10 +36,33 @@
"type": "github" "type": "github"
} }
}, },
"git-hooks-nix": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
],
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1733318908,
"narHash": "sha256-SVQVsbafSM1dJ4fpgyBqLZ+Lft+jcQuMtEL3lQWx2Sk=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "6f4e2a2112050951a314d2733a994fbab94864c6",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": { "gitignore": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
"pre-commit-hooks-nix", "git-hooks-nix",
"nixpkgs" "nixpkgs"
] ]
}, },
@ -75,11 +98,11 @@
}, },
"nixpkgs-stable": { "nixpkgs-stable": {
"locked": { "locked": {
"lastModified": 1720386169, "lastModified": 1730741070,
"narHash": "sha256-NGKVY4PjzwAa4upkGtAMz1npHGoRzWotlSnVlqI40mo=", "narHash": "sha256-edm8WG19kWozJ/GqyYx2VjW99EdhjKwbY3ZwdlPAAlo=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "194846768975b7ad2c4988bdb82572c00222c0d7", "rev": "d063c1dd113c91ab27959ba540c0d9753409edf3",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -89,34 +112,32 @@
"type": "github" "type": "github"
} }
}, },
"pre-commit-hooks-nix": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
],
"nixpkgs-stable": "nixpkgs-stable"
},
"locked": {
"lastModified": 1732021966,
"narHash": "sha256-mnTbjpdqF0luOkou8ZFi2asa1N3AA2CchR/RqCNmsGE=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "3308484d1a443fc5bc92012435d79e80458fe43c",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"git-hooks-nix": "git-hooks-nix",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"pre-commit-hooks-nix": "pre-commit-hooks-nix" "treefmt-nix": "treefmt-nix"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1733440889,
"narHash": "sha256-qKL3vjO+IXFQ0nTinFDqNq/sbbnnS5bMI1y0xX215fU=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "50862ba6a8a0255b87377b9d2d4565e96f29b410",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
} }
} }
}, },

View file

@ -5,8 +5,10 @@
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.url = "github:hercules-ci/flake-parts";
flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs";
pre-commit-hooks-nix.url = "github:cachix/git-hooks.nix"; treefmt-nix.url = "github:numtide/treefmt-nix";
pre-commit-hooks-nix.inputs.nixpkgs.follows = "nixpkgs"; treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
git-hooks-nix.url = "github:cachix/git-hooks.nix";
git-hooks-nix.inputs.nixpkgs.follows = "nixpkgs";
}; };
outputs = { outputs = {
@ -21,7 +23,8 @@
"aarch64-linux" "aarch64-linux"
]; ];
imports = [ imports = [
inputs.pre-commit-hooks-nix.flakeModule inputs.treefmt-nix.flakeModule
inputs.git-hooks-nix.flakeModule
]; ];
perSystem = { perSystem = {
@ -32,9 +35,18 @@
system, system,
... ...
}: { }: {
checks = let
tests = ["internal" "basic" "aliases"];
genTest = testName: {
"name" = testName;
"value" = import (./tests + "/${testName}.nix") {inherit pkgs;};
};
in
pkgs.lib.listToAttrs (map genTest tests);
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
packages = with pkgs; [ packages = with pkgs; [
alejandra self'.formatter.outPath # Add all formatters to environment
]; ];
shellHook = '' shellHook = ''
${config.pre-commit.installationScript} ${config.pre-commit.installationScript}
@ -42,8 +54,23 @@
}; };
pre-commit.settings.hooks = { pre-commit.settings.hooks = {
treefmt.enable = true;
};
treefmt = {
programs = {
alejandra.enable = true; alejandra.enable = true;
}; };
settings.global.excludes = [
".envrc"
"*.sieve"
];
};
};
flake.flakeModules = rec {
default = mailsystem;
mailsystem = import ./mailsystem;
}; };
}; };
} }

View file

@ -1,7 +1,25 @@
{config, ...}: let {config, ...}: let
cfg = config.mailsystem; cfg = config.mailsystem;
in rec { in rec {
sslCertPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem"; certificateDirectory = "/var/certs";
sslKeyPath = "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem"; sslCertPath =
sslCertService = ["acme-finished-${cfg.fqdn}.target"]; if cfg.certificateScheme == "acme"
then "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem"
else "${certificateDirectory}/cert-${cfg.fqdn}.pem";
sslKeyPath =
if cfg.certificateScheme == "acme"
then "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem"
else "${certificateDirectory}/key-${cfg.fqdn}.pem";
sslCertService =
if cfg.certificateScheme == "acme"
then ["acme-finished-${cfg.fqdn}.target"]
else ["mailsystem-selfsigned-certificate.service"];
dovecotDynamicStateDir = "/var/lib/dovecot";
dovecotDynamicPasswdFile = "${dovecotDynamicStateDir}/passwd";
rspamdProxySocket = "/run/rspamd-proxy.sock";
rspamdControllerSocket = "/run/rspamd-controller.sock";
} }

View file

@ -1,4 +1,10 @@
{lib, ...}: { {
config,
lib,
...
}: let
cfg = config.mailsystem;
in {
options.mailsystem = { options.mailsystem = {
enable = lib.mkEnableOption "nixos-mailsystem"; enable = lib.mkEnableOption "nixos-mailsystem";
@ -14,6 +20,32 @@
description = "Fully qualified domain name of the mail server."; description = "Fully qualified domain name of the mail server.";
}; };
reverseFqdn = lib.mkOption {
type = lib.types.str;
default = cfg.fqdn;
defaultText = lib.literalMD "{option}`mailsystem.fqdn`";
example = "server.example.com";
description = ''
Fully qualified domain name used by the server to identify
with other servers.
This needs to be set to the same value of the server's IP reverse DNS.
'';
};
domains = lib.mkOption {
type = lib.types.listOf lib.types.str;
example = ["example.com"];
default = [];
description = "List of domains to be served by the mail server";
};
messageSizeLimit = lib.mkOption {
type = lib.types.int;
default = 64 * 1024 * 1024;
description = "Maximum accepted mail size";
};
vmailUID = lib.mkOption { vmailUID = lib.mkOption {
type = lib.types.int; type = lib.types.int;
default = 5000; default = 5000;
@ -37,10 +69,141 @@
default = "/var/vmail"; default = "/var/vmail";
description = "Storage location for all mail."; description = "Storage location for all mail.";
}; };
accounts = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule ({name, ...}: {
options = {
name = lib.mkOption {
type = lib.types.str;
example = "user1@example.com";
description = "Username";
};
hashedPasswordFile = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
example = "/run/secrets/user1-passwordhash";
description = ''
A file containing the user's hashed password. Use `mkpasswd` as follows
```
nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt'
```
'';
};
aliases = lib.mkOption {
type = with lib.types; listOf types.str;
example = ["abuse@example.com" "postmaster@example.com"];
default = [];
description = ''
A list of aliases of this login account.
Note: Use list entries like "@example.com" to create a catchAll
that allows sending from all email addresses in these domain.
'';
};
quota = lib.mkOption {
type = with lib.types; nullOr types.str;
default = null;
example = "2G";
description = ''
Sets quota for the this login account. The size has to be suffixed with `k/M/G/T`.
Not setting a quota results in a standard quota of `100G`.
'';
};
isSystemUser = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
System users are not allowed to change their password and are
cannot receive any mails (-> send-only). Mails sent to such an
account will be rejected.
'';
};
};
config.name = lib.mkDefault name;
}));
example = {
user1 = {
hashedPassword = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/";
};
user2 = {
hashedPassword = "$6$oE0ZNv2n7Vk9gOf$9xcZWCCLGdMflIfuA0vR1Q1Xblw6RZqPrP94mEit2/81/7AKj2bqUai5yPyWE.QYPyv6wLMHZvjw3Rlg7yTCD/";
};
};
description = "All available login account for the mailsystem.";
default = {};
};
virtualDomainAliases = lib.mkOption {
type = with lib.types; attrsOf str;
example = {
"@aliasdomain.com" = "@domain.com";
};
description = ''
Virtual aliasing of domains. A virtual alias `"@aliasdomain.com" = "@domain.com"`
means that all mail directed at `aliasdomain.com` are forwarded to `domain.com`.
This also entails, that any account or alias of `domain.com` is partially valid
for `aliasdomain.com`. For example, `user@domain.com` can receive mails at
`user@aliasdomain.com`. However, if `user@domain.com` shall be able to dispatch
mails using `user@aliasdomain.com`, an explicit alias needs to be configured.
'';
default = {};
};
extraVirtualAliases = lib.mkOption {
type = let
account = lib.mkOptionType {
name = "Login Account";
check = account: builtins.elem account (builtins.attrNames cfg.accounts);
};
in
with lib.types; attrsOf (either account (nonEmptyListOf account));
example = {
"info@example.com" = "user1@example.com";
"postmaster@example.com" = "user1@example.com";
"abuse@example.com" = "user1@example.com";
"multi@example.com" = ["user1@example.com" "user2@example.com"];
};
description = ''
Virtual Aliases. A virtual alias `"info@example.com" = "user1@example.com"` means that
all mail to `info@example.com` is forwarded to `user1@example.com`. Note
that it is expected that `postmaster@example.com` and `abuse@example.com` is
forwarded to some valid email address. (Alternatively you can create login
accounts for `postmaster` and (or) `abuse`). Furthermore, it also allows
the user `user1@example.com` to send emails as `info@example.com`.
It's also possible to create an alias for multiple accounts. In this
example all mails for `multi@example.com` will be forwarded to both
`user1@example.com` and `user2@example.com`.
'';
default = {};
};
certificateScheme = lib.mkOption {
type = lib.types.enum ["acme" "selfsigned"];
default = "acme";
description = ''
The scheme to use for managing TLS certificates:
1. `acme`: The server retrieves letsencrypt certificates via NixOS's acme module using nginx.
2. `selfsigned`: The server creates self-signed certificates on the fly (intended for testing).
'';
internal = true;
visible = false;
};
}; };
imports = [ imports = [
./dovecot.nix
./kresd.nix
./nginx.nix ./nginx.nix
./postfix.nix
./redis.nix
./roundcube.nix
./rspamd.nix
./selfsigned.nix
./user.nix ./user.nix
]; ];
} }

313
mailsystem/dovecot.nix Normal file
View file

@ -0,0 +1,313 @@
{
config,
lib,
pkgs,
...
}:
with (import ./common.nix {inherit config;}); let
cfg = config.mailsystem;
postfixCfg = config.services.postfix;
dovecot2Cfg = config.services.dovecot2;
runtimeStateDir = "/run/dovecot2";
staticPasswdFile = "${runtimeStateDir}/passwd";
initialPasswdFile = "${runtimeStateDir}/initial-passwd";
userdbFile = "${runtimeStateDir}/userdb";
# dovecotDynamicStateDir and dovecotDynamicPasswdFile are defined in common.nix
systemUsers = lib.filterAttrs (user: value: value.isSystemUser) cfg.accounts;
normalUsers = lib.filterAttrs (user: value: !value.isSystemUser) cfg.accounts;
genUserdbEntry = user: value:
"${user}:::::::"
+ (
if lib.isString value.quota
then "userdb_quota_rule=*:storage=${value.quota}"
else ""
);
genPasswdEntry = user: value: "${user}:${"$(head -n 1 ${value.hashedPasswordFile})"}::::::";
genAuthDbsScript = pkgs.writeScript "generate-dovecot-auth-dbs" ''
#!${pkgs.stdenv.shell}
set -euo pipefail
if (! test -d "${runtimeStateDir}"); then
mkdir "${runtimeStateDir}"
chmod 755 "${runtimeStateDir}"
fi
if (! test -d "${dovecotDynamicStateDir}"); then
mkdir "${dovecotDynamicStateDir}"
chmod 755 "${dovecotDynamicStateDir}"
fi
# Ensure passwd files are not world-readable at any time
umask 077
# Ensure we have a file for every user's (initial) password hash.
for f in ${builtins.toString (lib.mapAttrsToList (user: value: value.hashedPasswordFile) cfg.accounts)}; do
if [ ! -f "$f" ]; then
echo "Expected password hash file $f does not exist!"
exit 1
fi
done
# Prepare static passwd-file for system users
cat <<EOF > "${staticPasswdFile}"
${lib.concatStringsSep "\n" (lib.mapAttrsToList genPasswdEntry systemUsers)}
EOF
# Prepare initial passwd-file for dynamic users
# (used for lookup during actual passwd-file generation)
cat <<EOF > "${initialPasswdFile}"
${lib.concatStringsSep "\n" (lib.mapAttrsToList genPasswdEntry normalUsers)}
EOF
# Check for existence of dynamic passwd-file
touch "${dovecotDynamicPasswdFile}"
if (! test -f "${dovecotDynamicPasswdFile}"); then
echo "${dovecotDynamicPasswdFile} exists and is no regular file"
exit 1
fi
# Ensure that only configured users are actually present and remove any others
truncate -s 0 "${dovecotDynamicPasswdFile}-filtered"
for u in ${builtins.toString (lib.mapAttrsToList (user: value: value.name) normalUsers)}; do
if grep -q "^$u:" "${dovecotDynamicPasswdFile}"; then
# User already has some password set -> Keep currently set password
grep "^$u:" "${dovecotDynamicPasswdFile}" >> "${dovecotDynamicPasswdFile}-filtered"
else
# User has no password set, yet -> Take password from initialPasswdFile
grep "^$u:" "${initialPasswdFile}" >> "${dovecotDynamicPasswdFile}-filtered"
fi
done
mv "${dovecotDynamicPasswdFile}-filtered" "${dovecotDynamicPasswdFile}"
# Prepare userdb-file
cat <<EOF > "${userdbFile}"
${lib.concatStringsSep "\n" (lib.mapAttrsToList genUserdbEntry cfg.accounts)}
EOF
'';
genMaildir = pkgs.writeScript "generate-maildir" ''
#!${pkgs.stdenv.shell}
# Create mail directory and set permissions accordingly.
umask 007
mkdir -p ${cfg.mailDirectory}
chgrp "${cfg.vmailGroupName}" ${cfg.mailDirectory}
chmod 02770 ${cfg.mailDirectory}
'';
junkMailboxes = builtins.attrNames (lib.filterAttrs (n: v: v ? "specialUse" && v.specialUse == "Junk") dovecot2Cfg.mailboxes);
junkMailboxNumber = builtins.length junkMailboxes;
# The assertion guarantees that there is exactly one Junk mailbox.
junkMailboxName =
if junkMailboxNumber == 1
then builtins.elemAt junkMailboxes 0
else "";
in {
options.mailsystem.dovecot.dhparamSize = lib.mkOption {
type = lib.types.int;
default = 2048;
description = "The bit size for the prime that is used during a Diffie-Hellman key exchange by dovecot.";
};
config = lib.mkIf cfg.enable {
assertions =
[
{
assertion = junkMailboxNumber == 1;
message = "mailnix requires exactly one dovecot mailbox with the 'special use' flag to 'Junk' (${builtins.toString junkMailboxNumber} have been found)";
}
]
++ lib.mapAttrsToList (
user: value: {
assertion = value.hashedPasswordFile != null;
message = "A file containing the hashed password for user ${user} needs to be set.";
}
)
cfg.accounts;
services.dovecot2 = {
enable = true;
enableImap = true;
enablePAM = false;
# TODO: enable quota and setup quota warnings
#enableQuota = true;
mailUser = cfg.vmailUserName;
mailGroup = cfg.vmailGroupName;
mailLocation = "maildir:~/Maildir";
sslServerCert = sslCertPath;
sslServerKey = sslKeyPath;
enableLmtp = true;
modules = [
# sieves + managesieve
pkgs.dovecot_pigeonhole
];
# enable managesieve
protocols = ["sieve"];
pluginSettings = {
sieve = "file:~/sieve;active=~/.dovecot.sieve";
};
sieve = {
extensions = [
"fileinto"
"mailbox"
];
scripts.after = builtins.toFile "spam.sieve" ''
require "fileinto";
require "mailbox";
if header :is "X-Spam" "Yes" {
fileinto :create "${junkMailboxName}";
stop;
}
'';
pipeBins = map lib.getExe [
(pkgs.writeShellScriptBin "learn-ham.sh"
"exec ${pkgs.rspamd}/bin/rspamc -h ${rspamdControllerSocket} learn_ham")
(pkgs.writeShellScriptBin "learn-spam.sh"
"exec ${pkgs.rspamd}/bin/rspamc -h ${rspamdControllerSocket} learn_spam")
];
};
imapsieve.mailbox = [
{
name = junkMailboxName;
causes = ["COPY" "APPEND"];
before = ./dovecot/report-spam.sieve;
}
{
name = "*";
from = junkMailboxName;
causes = ["COPY"];
before = ./dovecot/report-ham.sieve;
}
];
# TODO: move configuration to default.nix?
mailboxes = {
Drafts = {
auto = "subscribe";
specialUse = "Drafts";
};
Junk = {
auto = "subscribe";
specialUse = "Junk";
};
Trash = {
auto = "subscribe";
specialUse = "Trash";
};
Sent = {
auto = "subscribe";
specialUse = "Sent";
};
};
extraConfig = ''
service imap-login {
inet_listener imap {
# Using starttls for encryption shifts the responsibility for
# encrypted communication over to the client in the face of MITM.
# However, it has shown that clients are often not enforcing it.
port = 0 # clients should use starttls instaed
}
inet_listener imaps {
port = 993
ssl = yes
}
}
protocol imap {
mail_max_userip_connections = 100
mail_plugins = $mail_plugins imap_sieve
}
mail_access_groups = ${cfg.vmailGroupName}
ssl = required
ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = yes
service lmtp {
unix_listener dovecot-lmtp {
user = ${postfixCfg.user}
group = ${postfixCfg.group}
mode = 0600
}
}
recipient_delimiter = +
lmtp_save_to_detail_mailbox = no
protocol lmtp {
mail_plugins = $mail_plugins sieve
}
# Passwords stored among two passwd-files: One for users allowed to
# change their password and one for any other user (mostly system
# users) with immutable passwords.
passdb {
driver = passwd-file
args = ${dovecotDynamicPasswdFile}
}
passdb {
driver = passwd-file
args = ${staticPasswdFile}
}
userdb {
driver = passwd-file
args = ${userdbFile}
default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory}/%d/%n
}
service auth {
unix_listener auth {
user = ${postfixCfg.user}
group = ${postfixCfg.group}
mode = 0660
}
}
auth_mechanisms = plain login
namespace inbox {
separator = /
inbox = yes
}
lda_mailbox_autosubscribe = yes
lda_mailbox_autocreate = yes
'';
};
security.dhparams.params.dovecot2.bits = cfg.dovecot.dhparamSize;
systemd.services.dovecot2 = {
preStart = ''
${genAuthDbsScript}
${genMaildir}
'';
wants = sslCertService;
after = sslCertService;
};
systemd.services.postfix.restartTriggers = [genAuthDbsScript];
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [
993 # imaps
4190 #managesieve
];
};
};
}

View file

@ -0,0 +1,15 @@
require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"];
if environment :matches "imap.mailbox" "*" {
set "mailbox" "${1}";
}
if string "${mailbox}" "Trash" {
stop;
}
if environment :matches "imap.user" "*" {
set "username" "${1}";
}
pipe :copy "learn-ham.sh" [ "${username}" ];

View file

@ -0,0 +1,7 @@
require ["vnd.dovecot.pipe", "copy", "imapsieve", "environment", "variables"];
if environment :matches "imap.user" "*" {
set "username" "${1}";
}
pipe :copy "learn-spam.sh" [ "${username}" ];

11
mailsystem/kresd.nix Normal file
View file

@ -0,0 +1,11 @@
{
config,
lib,
...
}: let
cfg = config.mailsystem;
in {
config = lib.mkIf cfg.enable {
services.kresd.enable = true;
};
}

View file

@ -3,18 +3,28 @@
pkgs, pkgs,
lib, lib,
... ...
}: let }:
with (import ./common.nix {inherit config;}); let
cfg = config.mailsystem; cfg = config.mailsystem;
in { in {
config = lib.mkIf cfg.enable { config =
lib.mkIf cfg.enable {
services.nginx = { services.nginx = {
enable = true; enable = true;
virtualHosts."${cfg.fqdn}" = { virtualHosts."${cfg.fqdn}" = {
forceSSL = true; forceSSL = true;
enableACME = true; enableACME = cfg.certificateScheme == "acme";
sslCertificate = lib.mkIf (cfg.certificateScheme == "selfsigned") sslCertPath;
sslCertificateKey = lib.mkIf (cfg.certificateScheme == "selfsigned") sslKeyPath;
}; };
}; };
networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443]; networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [80 443];
}
// lib.mkIf (cfg.enable && cfg.certificateScheme == "acme") {
security.acme.certs."${cfg.fqdn}".reloadServices = [
"postfix.service"
"dovecot2.service"
];
}; };
} }

227
mailsystem/postfix.nix Normal file
View file

@ -0,0 +1,227 @@
{
config,
lib,
pkgs,
...
}:
with (import ./common.nix {inherit config;}); let
cfg = config.mailsystem;
mappedFile = name: "hash:/var/lib/postfix/conf/${name}";
attrsToLookupTable = aliases: let
lookupTables = lib.mapAttrsToList (from: to: {"${from}" = to;}) aliases;
in
mergeLookupTables lookupTables;
lookupTableToString = attrs: let
valueToString = value: lib.concatStringsSep ", " value;
in
lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs);
mergeLookupTables = tables: lib.zipAttrsWith (n: v: lib.flatten v) tables;
account_virtual_aliases = mergeLookupTables (lib.flatten (lib.mapAttrsToList
(name: value: let
to = name;
in
map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name))
cfg.accounts));
virtual_domain_aliases = let
alias_domains =
lib.concatMapAttrs (src: dst: {
"@${src}" = "@${dst}";
})
cfg.virtualDomainAliases;
in
attrsToLookupTable alias_domains;
extra_virtual_aliases = attrsToLookupTable cfg.extraVirtualAliases;
all_virtual_aliases = mergeLookupTables [account_virtual_aliases virtual_domain_aliases extra_virtual_aliases];
aliases_file = let
content = lookupTableToString all_virtual_aliases;
in
builtins.toFile "virtual_aliases" content;
# File containing all mappings of authenticated accounts and their sender mail addresses.
virtual_accounts_file = let
content = lookupTableToString all_virtual_aliases;
in
builtins.toFile "virtual_accounts" content;
virtual_domains_file = builtins.toFile "virtual_domains" (lib.concatStringsSep "\n" cfg.domains);
submission_header_cleanup_rules = 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
# Replace the user's submitted hostname with the server's FQDN to hide the
# user's host/network.
/^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}>
'';
tls_protocols = "TLSv1.3, TLSv1.2, !TLSv1.1, !TLSv1, !SSLv2, !SSLv3";
tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
in {
config = lib.mkIf cfg.enable {
assertions =
lib.mapAttrsToList (
src: dst: {
assertion = (builtins.elem src cfg.domains) && (builtins.elem dst cfg.domains);
message = "Both aliased domain (${src}) and actual domain (${dst}) need to be managed by the mailserver.";
}
)
cfg.virtualDomainAliases;
services.postfix = {
enable = true;
hostname = "${cfg.reverseFqdn}";
networksStyle = "host";
sslCert = sslCertPath;
sslKey = sslKeyPath;
enableSubmissions = true;
# TODO: create function to simplify this?
mapFiles."virtual_aliases" = aliases_file;
mapFiles."virtual_accounts" = virtual_accounts_file;
virtual = lookupTableToString all_virtual_aliases;
submissionsOptions = {
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";
# use mappedFile -> different path?
smtpd_sender_login_maps = "hash:/etc/postfix/virtual_accounts";
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";
};
config = {
mydestination = "";
recipient_delimiter = "+";
smtpd_banner = "${cfg.fqdn} ESMTP NO UCE";
disable_vrfy_command = true;
message_size_limit = toString cfg.messageSizeLimit;
virtual_uid_maps = "static:${toString cfg.vmailUID}";
virtual_gid_maps = "static:${toString cfg.vmailUID}";
virtual_mailbox_base = cfg.mailDirectory;
virtual_mailbox_domains = virtual_domains_file;
virtual_mailbox_maps = [
(mappedFile "virtual_aliases")
];
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 (enforce authentication via 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"
];
# quota checking # TODO: wo ist hier quota??
# smtpd_recipient_restrictions = [
# "check_policy_service inet:localhost:12340" # XXX
# ];
# TLS settings, inspired by https://github.com/jeaye/nix-files
# Submission by mail clients is handled in submissionOptions
smtpd_tls_security_level = "may";
# Disable obsolete protocols
smtpd_tls_protocols = tls_protocols;
smtp_tls_protocols = tls_protocols;
smtpd_tls_mandatory_protocols = tls_protocols;
smtp_tls_mandatory_protocols = tls_protocols;
smtp_tls_ciphers = "high";
smtpd_tls_ciphers = "high";
smtp_tls_mandatory_ciphers = "high";
smtpd_tls_mandatory_ciphers = "high";
# Disable deprecated ciphers
smtpd_tls_mandatory_exclude_ciphers = tls_exclude_ciphers;
smtpd_tls_exclude_ciphers = tls_exclude_ciphers;
smtp_tls_mandatory_exclude_ciphers = tls_exclude_ciphers;
smtp_tls_exclude_ciphers = tls_exclude_ciphers;
tls_preempt_cipherlist = true;
# Allowing AUTH on a non-encrypted connection poses a security risk
smtpd_tls_auth_only = true;
# Log only a summary message on TLS handshake completion
smtpd_tls_loglevel = "1";
# Configure a non-blocking source of randomness
tls_random_source = "dev:/dev/urandom";
smtpd_milters = [
"unix:${rspamdProxySocket}"
];
# Also use milter for outgoing mails (for e.g., dkim)
non_smtpd_milters = [
"unix:${rspamdProxySocket}"
];
milter_protocol = "6";
milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer}";
# Fix for https://www.postfix.org/smtp-smuggling.html
smtpd_forbid_bare_newline = "yes";
smtpd_forbid_bare_newline_exclusions = "$mynetworks";
};
masterConfig = {
"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:${submission_header_cleanup_rules}"];
};
};
};
systemd.services.postfix = {
wants = sslCertService;
after =
["dovecot2.service" "rspamd.service"]
++ sslCertService;
requires = ["dovecot2.service" "rspamd.service"];
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [
25 # smtp
465 # submissions
];
};
};
}

27
mailsystem/redis.nix Normal file
View file

@ -0,0 +1,27 @@
{
config,
lib,
pkgs,
...
}: let
cfg = config.mailsystem;
redisCfg = config.services.redis.servers.rspamd;
rspamdCfg = config.services.rspamd;
in {
config = lib.mkIf cfg.enable {
services.redis.servers.rspamd = {
enable = true;
# Don't accept connections via tcp
port = 0;
unixSocketPerm = 600;
};
# TODO: Run commands as service user instead of as root?
systemd.services.redis-rspamd.serviceConfig.ExecStartPost =
"+"
+ pkgs.writeShellScript "redis-rspamd-postStart" ''
${pkgs.acl.bin}/bin/setfacl -m "u:${rspamdCfg.user}:x" "${builtins.dirOf redisCfg.unixSocket}"
${pkgs.acl.bin}/bin/setfacl -m "u:${rspamdCfg.user}:rw" "${redisCfg.unixSocket}"
'';
};
}

56
mailsystem/roundcube.nix Normal file
View file

@ -0,0 +1,56 @@
{
config,
lib,
pkgs,
...
}:
with (import ./common.nix {inherit config;}); let
cfg = config.mailsystem;
roundcubeCfg = config.mailsystem.roundcube;
in {
options.mailsystem.roundcube = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable roundcube in order to provide a webmail interface";
};
hostName = lib.mkOption {
type = lib.types.str;
default = cfg.fqdn;
description = "FQDN to be used by roundcube. Defaults to {option}`mailsystem.fqdn`.";
};
passwordHashingAlgorithm = lib.mkOption {
type = lib.types.str;
default = "BLF-CRYPT";
description = "Password hashing algorithm to be used with `doveadm pw`";
};
};
config = lib.mkIf (cfg.enable && roundcubeCfg.enable) {
services.roundcube = {
enable = true;
hostName = roundcubeCfg.hostName;
plugins = ["managesieve" "password"];
extraConfig = ''
// Use starttls for authentication
$config['smtp_host'] = "tls://${cfg.fqdn}";
$config['smtp_user'] = "%u";
$config['smtp_pass'] = "%p";
$config['managesieve_host'] = "localhost";
$config['password_driver'] = "dovecot_passwdfile";
$config['password_confirm_current'] = true;
$config['password_minimum_length'] = 8;
$config['password_algorithm'] = "dovecot";
// Enables saving the new password even if it machtes the old password. Useful
// for upgrading the stored passwords after the encryption scheme has changed.
$config['password_force_save'] = true;
$config['password_dovecot_passwdfile_path'] = "${pkgs.dovecot}/bin/doveadm pw";
$config['password_dovecotpw'] = "${dovecotDynamicPasswdFile}";
$config['password_dovecotpw_method'] = "${roundcubeCfg.passwordHashingAlgorithm}";
$config['password_dovecotpw_with_method'] = true;
'';
};
};
}

129
mailsystem/rspamd.nix Normal file
View file

@ -0,0 +1,129 @@
{
config,
lib,
pkgs,
...
}:
with (import ./common.nix {inherit config;}); let
cfg = config.mailsystem;
nginxcfg = config.services.nginx;
postfixCfg = config.services.postfix;
redisCfg = config.services.redis.servers.rspamd;
rspamdCfg = config.services.rspamd;
genSystemdSocketCfg = name: socketPath: additionalUser: {
description = "rspamd ${name} worker socket";
listenStreams = [socketPath];
requiredBy = ["rspamd.service"];
socketConfig = {
Service = "rspamd.service";
SocketUser = rspamdCfg.user;
SocketMode = 0600;
ExecStartPost =
lib.mkIf (additionalUser != "")
''${pkgs.acl.bin}/bin/setfacl -m "u:${additionalUser}:rw" "${socketPath}"'';
};
};
in {
options.mailsystem.rspamd.webUi = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable the rspamd webui on `https://${config.mailsystem.fqdn}/rspamd`";
};
basicAuthFile = lib.mkOption {
type = lib.types.str;
description = "Path to basic auth file";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = !cfg.rspamd.webUi.enable || cfg.rspamd.webUi.basicAuthFile != null;
message = "Setting basicAuthFile is required if rspamd's web interface is enabled";
}
];
services.rspamd = {
enable = true;
overrides = {
"classifier-bayes.conf" = {
text = ''
autolearn {
spam_threshold = 6.0 # When to learn spam (score >= threshold)
ham_threshold = -2.0 # When to learn ham (score <= threshold)
}
'';
};
"milter_headers.conf" = {
text = ''
# Add headers related to spam-detection
extended_spam_headers = true;
'';
};
"redis.conf" = {
text = ''
servers = "${redisCfg.unixSocket}";
'';
};
"worker-controller.inc" = lib.mkIf cfg.rspamd.webUi.enable {
text = ''
secure_ip = "0.0.0.0/0";
secure_ip = "::/0";
'';
};
};
workers = {
rspamd_proxy = {
bindSockets = ["systemd:rspamd-proxy.socket"];
count = 1; # Do not spawn too many processes of this type
extraConfig = ''
milter = yes; # Enable milter mode
timeout = 120s; # Needed for Milter usually
upstream "local" {
default = yes; # Self-scan upstreams are always default
self_scan = yes; # Enable self-scan
}
'';
};
controller = {
count = 1;
bindSockets = ["systemd:rspamd-controller.socket"];
extraConfig = ''
static_dir = "''${WWWDIR}"; # Serve the web UI static assets
'';
};
};
};
systemd.sockets = {
rspamd-proxy = genSystemdSocketCfg "proxy" rspamdProxySocket postfixCfg.user;
rspamd-controller = genSystemdSocketCfg "controller" rspamdControllerSocket (
lib.optionalString cfg.rspamd.webUi.enable nginxCfg.user
);
};
systemd.services.rspamd = {
requires = ["redis-rspamd.service"];
after = ["redis-rspamd.service"];
};
services.nginx = lib.mkIf cfg.rspamd.webUi.enable {
enable = true;
virtualHosts."${cfg.fqdn}" = {
forceSSL = true;
locations."/rspamd" = {
proxyPass = "http://unix:${rspamdControllerSocket}:/";
basicAuthFile = cfg.rspamd.webUi.basicAuthFile;
};
sslCertificate = lib.mkIf (cfg.certificateScheme == "selfsigned") sslCertPath;
sslCertificateKey = lib.mkIf (cfg.certificateScheme == "selfsigned") sslKeyPath;
};
};
};
}

33
mailsystem/selfsigned.nix Normal file
View file

@ -0,0 +1,33 @@
{
config,
pkgs,
lib,
...
}:
with (import ./common.nix {inherit config;}); let
cfg = config.mailsystem;
in {
config = lib.mkIf (cfg.enable && cfg.certificateScheme == "selfsigned") {
systemd.services.mailsystem-selfsigned-certificate = {
after = ["local-fs.target"];
script = ''
# Create certificates if they do not exist yet
dir="${certificateDirectory}"
fqdn="${cfg.fqdn}"
[[ $fqdn == /* ]] && fqdn=$(< "$fqdn")
key="${sslKeyPath}"
cert="${sslCertPath}"
if [[ ! -f $key || ! -f $cert ]]; then
mkdir -p "$dir"
(umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 4096) &&
"${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" -days 3650 -out "$cert"
fi
'';
serviceConfig = {
Type = "oneshot";
PrivateTmp = true;
};
};
};
}

230
tests/aliases.nix Normal file
View file

@ -0,0 +1,230 @@
{pkgs, ...}:
with (import ./common/lib.nix {inherit pkgs;}); let
accounts = {
"normal" = {
address = "user1@example.com";
password = "secret-password1";
};
"normal2" = {
address = "user2@example.com";
password = "secret-password2;";
};
"alias" = {
address = "user3@example.com";
aliases = ["alias@example.com"];
password = "secret-password3";
};
"extra-alias" = {
address = "user4@example.com";
password = "secret-password4;";
};
"multi-alias1" = {
address = "multi-alias1@example.com";
aliases = ["multi-alias@example.com"];
password = "secret-password5;";
};
"multi-alias2" = {
address = "multi-alias2@example.com";
aliases = ["multi-alias@example.com"];
password = "secret-password6;";
};
"catchall" = {
address = "catchall@example.com";
aliases = ["@example.com"];
password = "secret-password7;";
};
"otherdomain" = {
address = "otherdomain@example.com";
aliases = ["user@otherdomain.com"];
password = "secret-password8;";
};
};
in
pkgs.nixosTest {
name = "aliases";
nodes = {
server = {pkgs, ...}: {
imports = [./common/server.nix];
environment.systemPackages = with pkgs; [netcat];
mailsystem = {
fqdn = "mail.example.com";
domains = ["example.com" "aliased.com" "otherdomain.com"];
accounts = mkAccounts accounts;
virtualDomainAliases = {
"aliased.com" = "example.com";
};
extraVirtualAliases = {
"extra-alias@example.com" = accounts."extra-alias".address;
};
};
};
client = {...}: {
imports = [./common/client.nix];
};
};
testScript = {nodes, ...}: let
serverAddr = nodes.server.networking.primaryIPAddress;
clientAddr = nodes.client.networking.primaryIPAddress;
smtpSettings = {
address = serverAddr;
port = 465;
};
sendMail = mkSendMail smtpSettings accounts;
recvMail = mkRecvMail serverAddr accounts;
cfg = nodes.server.mailsystem;
in ''
start_all()
server.wait_for_unit("multi-user.target")
client.wait_for_unit("multi-user.target")
server.wait_until_succeeds("${waitForRspamd nodes.server}")
with subtest("send mail from aliased address"):
client.succeed("${sendMail "alias" "alias@example.com" accounts."normal".address ''
Subject: Testmail1
Hello User1,
this is a mail dispatched using an alias instead of the normal address.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "normal"} >&2")
with subtest("receive mail on aliased address"):
client.succeed("${sendMail "normal" "" "alias@example.com" ''
Subject: Testmail2
Hello alias-User,
this mail should reach you on your aliased address alias@example.com.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "alias"} >&2")
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 1 when no new mail is available
# (as alias is the more specific address, catchall shouldn't receive the mail)
client.fail("${recvMail "catchall"} >&2")
with subtest("receive mail on all accounts with same alias"):
client.succeed("${sendMail "normal" "" "multi-alias@example.com" ''
Subject: Testmail3
Hello multi-alias-Users,
this mail should reach you on your aliased address multi-alias@example.com.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "multi-alias1"} >&2")
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "multi-alias2"} >&2")
with subtest("send mail to catchAll-alias"):
# send email to non-existent account
client.succeed("${sendMail "normal" "" "somerandomaddress@example.com" ''
Subject: Catchall-Test
Hello Catchall-User,
this is mail is directed at an address with no explicit user-account behind it.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "catchall"} >&2")
with subtest("send mail from catchAll-account with a non-existing account behind"):
# send email from non-existent account
client.succeed("${sendMail "catchall" "somerandomaddress@example.com" accounts."normal2".address ''
Subject: Catchall-Test2
Hello User2,
this is mail is sent from an address with no explicit user-account behind it.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "normal2"} >&2")
with subtest("catchAll-account cannot send mail from an address with an existing account behind"):
# send email to non-existent account
client.fail("${sendMail "catchall" accounts."normal".address accounts."normal2".address ''
Subject: Catchall-Test3
Hello User2,
this is mail should not be possible to be sent as it is dispatched by a catchall-account using an address with a user-account behind it.
''}")
with subtest("send mail from aliased address of other domain"):
client.succeed("${sendMail "otherdomain" "user@otherdomain.com" accounts."normal".address ''
Subject: Dispatch from other domain
Hello User1,
this is a mail dispatched using an alias to a different domain instead of the normal address.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "normal"} >&2")
with subtest("receive mail on aliased address of other domain"):
client.succeed("${sendMail "normal" "" "user@otherdomain.com" ''
Subject: Reception from other domain
Hello otherdomain-User,
this mail should reach you on your aliased address user@otherdomain.com.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "otherdomain"} >&2")
with subtest("mail incoming on extraVirtualAlias"):
client.succeed("${sendMail "normal" "" "extra-alias@example.com" ''
Subject: extraVirtualAliases-Test
Hello User4,
this is mail is sent to you by using an extraVirtualAlias as recipient.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "extra-alias"} >&2")
with subtest("receiving mail on aliased domain using normal account"):
client.succeed("${sendMail "normal" "" "user2@aliased.com" ''
Subject: aliasedDomain with normal account
Hello User2,
this is mail is sent to you by using your address @example.org.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "normal2"} >&2")
with subtest("receiving mail on aliased domain using catchall-account"):
client.succeed("${sendMail "normal" "" "somerandomaddress@aliased.com" ''
Subject: aliasedDomain using catchall-account
Hello Catchall-User,
this is mail is sent to you by using an address without any user-account behind it for neither @example.com nor @aliased.com.
''}")
server.wait_until_fails('${pendingPostqueue}')
client.execute("${cleanupMail}")
# fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("${recvMail "catchall"} >&2")
with subtest("sending mail from aliased domain fails"):
client.fail("${sendMail "normal" "user1@aliased.com" accounts."normal2".address ''
Subject: aliasedDomain
Hello User2,
this mail should not be dispatched to you as I'm using "my" address @aliased.com.
''}")
'';
}

83
tests/basic.nix Normal file
View file

@ -0,0 +1,83 @@
{pkgs, ...}:
with (import ./common/lib.nix {inherit pkgs;}); let
accounts = {
"normal" = {
address = "user1@example.com";
password = "secret-password1";
};
"normal2" = {
address = "user2@example.com";
password = "secret-password2";
};
};
in
pkgs.nixosTest {
name = "basic";
nodes = {
server = {pkgs, ...}: {
imports = [./common/server.nix];
environment.systemPackages = with pkgs; [netcat];
mailsystem = {
fqdn = "mail.example.com";
domains = ["example.com"];
accounts = mkAccounts accounts;
};
};
client = {...}: {
imports = [./common/client.nix];
};
};
testScript = {nodes, ...}: let
serverAddr = nodes.server.networking.primaryIPAddress;
clientAddr = nodes.client.networking.primaryIPAddress;
smtpSettings = {
address = serverAddr;
port = 465;
};
sendMail = mkSendMail smtpSettings accounts;
recvMail = mkRecvMail serverAddr accounts;
cfg = nodes.server.mailsystem;
in ''
start_all()
server.wait_for_unit("multi-user.target")
client.wait_for_unit("multi-user.target")
server.wait_until_succeeds("${waitForRspamd nodes.server}")
with subtest("imap works and retrieves no new mails"):
# fetchmail returns EXIT_CODE 1 when no new mail is available
client.succeed("${recvMail "normal"} || [ $? -eq 1 ] >&2")
with subtest("send succeeds for normal user"):
client.succeed("${sendMail "normal" "" accounts."normal2".address ''
Message-ID: <123456asdf@host.local.network>
Subject: Testmail1
Hello User2,
this is some text!
''}")
# give the mail server some time to process the mail
server.wait_until_fails('${pendingPostqueue}')
with subtest("mail can be retrieved via imap"):
client.succeed("${recvMail "normal2"} >&2")
with subtest("mail header contains no sensitive information"):
client.fail("grep '${clientAddr}' $HOME/mail/*")
client.succeed("grep '^Message-ID:.*@${cfg.fqdn}>$' $HOME/mail/*")
with subtest("mail header contains correct fqdn in received from"):
client.succeed("grep 'Received: from ${cfg.fqdn}' $HOME/mail/*")
with subtest("user cannot forge from-address"):
client.fail("${sendMail "normal" "someotheraddress@example.com" accounts."normal2".address ''
Subject: I actually do not own this from-address
Hello User2,
I'm pretending to be someotheraddress@example.com and the mailserver should reject this attempt.
''}")
with subtest("server issues no warnings nor errors"):
${checkLogs "server"}
'';
}

17
tests/common/client.nix Normal file
View file

@ -0,0 +1,17 @@
{pkgs, ...}: {
config = {
# added to the environment for manual verification via interactiveDriver if necessary
environment.systemPackages = with pkgs; [fetchmail msmtp procmail openssl];
systemd.tmpfiles.settings."10-mailtest" = let
dirPerms = {
user = "root";
mode = "0700";
};
in {
"/root/mail".d = dirPerms;
"/root/.procmailrc"."L+".argument = "${pkgs.writeText ".procmailrc" ''
DEFAULT=$HOME/mail
''}";
};
};
}

77
tests/common/lib.nix Normal file
View file

@ -0,0 +1,77 @@
{pkgs, ...}: let
lib = pkgs.lib;
in rec {
waitForRspamd = node: let
inherit (import ../../mailsystem/common.nix {inherit (node) config;}) rspamdProxySocket;
in "set +e; timeout 1 ${node.nixpkgs.pkgs.netcat}/bin/nc -U ${rspamdProxySocket} < /dev/null; [ $? -eq 124 ]";
mkHashedPasswordFile = password:
pkgs.runCommand "mk-password-hash-${password}" {
buildInputs = [pkgs.mkpasswd];
inherit password;
} ''
echo "$password" | mkpasswd -sm bcrypt > $out
'';
mkAccounts = accounts:
lib.concatMapAttrs (_: account: {
${account.address} =
{
hashedPasswordFile = "${mkHashedPasswordFile account.password}";
}
// builtins.removeAttrs account ["address" "password"];
})
accounts;
mkSendMail = smtpSettings: accounts: accountName: fromAddr: recipient: body: let
account = accounts.${accountName};
senderAddr =
if fromAddr == ""
then account.address
else fromAddr;
msmtprc = pkgs.writeText "msmtprc" ''
account default
auth on
tls on
tls_starttls off
tls_certcheck off
host ${smtpSettings.address}
port ${toString smtpSettings.port}
from ${senderAddr}
user ${account.address}
password ${account.password}
'';
mail = pkgs.writeText "mail-${account.address}-${recipient}" ''
From: <${account.address}>
To: <${recipient}>
${body}
'';
in "${pkgs.msmtp}/bin/msmtp -C ${msmtprc} ${recipient} < ${mail} >&2";
pendingPostqueue = "[ \"$(postqueue -p)\" != \"Mail queue is empty\" ]";
cleanupMail = "rm $HOME/mail/*";
# mkRecvMail requires procmail to be setup correctly. This is ensured by
# importing ./server.nix
mkRecvMail = imapAddr: accounts: accountName: let
mkFetchmailRcScript = imapAddr: account:
pkgs.writeScript "mk-fetchmailrc-${account.address}" ''
umask 077
readonly out=$(mktemp)
cat <<EOF > "$out"
poll ${imapAddr} with proto IMAP
user '${account.address}' there with password '${account.password}' is 'root' here
mda procmail
EOF
echo $out
'';
fetchmailrc = mkFetchmailRcScript imapAddr accounts.${accountName};
in "${pkgs.fetchmail}/bin/fetchmail -f $(${fetchmailrc}) --ssl --nosslcertck -v";
checkLogs = node: ''
${node}.fail("journalctl -u postfix | grep -i error >&2")
${node}.fail("journalctl -u postfix | grep -i warning >&2")
${node}.fail("journalctl -u dovecot2 | grep -i error >&2")
${node}.fail("journalctl -u dovecot2 | grep -i warning >&2")
'';
}

13
tests/common/server.nix Normal file
View file

@ -0,0 +1,13 @@
{...}: {
imports = [./../../mailsystem];
config = {
virtualisation.memorySize = 1024;
mailsystem = {
enable = true;
roundcube.enable = false;
rspamd.webUi.enable = false;
certificateScheme = "selfsigned";
};
};
}

50
tests/internal.nix Normal file
View file

@ -0,0 +1,50 @@
{pkgs, ...}:
pkgs.nixosTest {
name = "internal";
nodes.machine = {...}: {
imports = [./common/server.nix];
mailsystem = {
fqdn = "mail.example.com";
domains = ["example.com"];
accounts = {};
vmailUserName = "vmail";
vmailGroupName = "vmail";
vmailUID = 5000;
};
};
testScript = {nodes, ...}: let
pkgs = nodes.machine.nixpkgs.pkgs;
in ''
machine.start()
machine.wait_for_unit("multi-user.target")
with subtest("imap is only available via port 993 and is encrypted"):
machine.wait_for_closed_port(143)
machine.wait_for_open_port(993)
machine.succeed(
"echo | ${pkgs.openssl}/bin/openssl s_client -connect localhost:993 | grep 'New, TLS'"
)
with subtest("smtp is only available via port 465 and is encrypted"):
machine.wait_for_closed_port(587)
machine.wait_for_open_port(465)
machine.succeed(
"echo | ${pkgs.openssl}/bin/openssl s_client -connect localhost:465 | grep 'New, TLS'"
)
with subtest("`postfix check` succeeds"):
machine.succeed(
"${pkgs.postfix}/bin/postfix check"
)
with subtest("vmail uid is set correctly"):
machine.succeed(
"[ $(getent passwd vmail | cut -d: -f3) -eq 5000 ]"
)
with subtest("vmail gid is set correctly"):
machine.succeed(
"[ $(getent group vmail | cut -d: -f3) -eq 5000 ]"
)
'';
}