Compare commits
17 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e75d07d56 | |||
| c90e09d125 | |||
| 48bd6b8981 | |||
| fb834ec7ee | |||
| b630755ea8 | |||
| a033432bb8 | |||
| b81e8f00bb | |||
| 5c280dcedb | |||
| 9687dbaae1 | |||
| a592881b8b | |||
| 6d6b856bee | |||
| 3d0e0dd95c | |||
| 5583676384 | |||
| 0ce3ecae52 | |||
| d35763a8a2 | |||
| b7fac23bd1 | |||
| bc01d4d2d0 |
20 changed files with 1574 additions and 47 deletions
77
flake.lock
generated
77
flake.lock
generated
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
35
flake.nix
35
flake.nix
|
|
@ -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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
313
mailsystem/dovecot.nix
Normal 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
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
15
mailsystem/dovecot/report-ham.sieve
Normal file
15
mailsystem/dovecot/report-ham.sieve
Normal 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}" ];
|
||||||
7
mailsystem/dovecot/report-spam.sieve
Normal file
7
mailsystem/dovecot/report-spam.sieve
Normal 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
11
mailsystem/kresd.nix
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}: let
|
||||||
|
cfg = config.mailsystem;
|
||||||
|
in {
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
services.kresd.enable = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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
227
mailsystem/postfix.nix
Normal 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
27
mailsystem/redis.nix
Normal 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
56
mailsystem/roundcube.nix
Normal 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
129
mailsystem/rspamd.nix
Normal 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
33
mailsystem/selfsigned.nix
Normal 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
230
tests/aliases.nix
Normal 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
83
tests/basic.nix
Normal 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
17
tests/common/client.nix
Normal 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
77
tests/common/lib.nix
Normal 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
13
tests/common/server.nix
Normal 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
50
tests/internal.nix
Normal 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 ]"
|
||||||
|
)
|
||||||
|
'';
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue