Compare commits
451 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af9fc4c519 | |||
| 6c11ff592e | |||
| c894d1816d | |||
| 04094b9a1c | |||
| d86c1778e2 | |||
| f8f71c820a | |||
| 82d9924cdf | |||
| 3ad66c854d | |||
| 9764b64190 | |||
| a6bb0dde9b | |||
| 2e3a2d0980 | |||
| f8e3955323 | |||
| 7aeb1d7d76 | |||
| e5ae7b5b96 | |||
| c60d98a13c | |||
| 58ff4da02f | |||
| 4dcd114a2f | |||
| e4e18e01de | |||
| eea473ea12 | |||
| 57bfae2d7e | |||
| e5102c5502 | |||
| 1d6f18856a | |||
| 800bf95755 | |||
| c0cc5e7eff | |||
| 61e9c248c5 | |||
| 10dce12f73 | |||
| 86a2bb9afd | |||
| e4aa2d1517 | |||
| 260f38128e | |||
| 28bfef89ba | |||
| e33fbde199 | |||
| fb38d437a5 | |||
| f810a804c6 | |||
| 583a362c5b | |||
| 3ab15c2e30 | |||
| ecbe707330 | |||
| 7909eabac2 | |||
| 8d6b14c82c | |||
| e6c4a96f50 | |||
| 6e9a4420b3 | |||
| 0b1ca54241 | |||
| bd5b08681a | |||
| 198246f2c2 | |||
| f9d1435378 | |||
| 7dce7fbd5a | |||
| 99a9b6efb7 | |||
| fdb1be9b50 | |||
| 21399f334c | |||
| 7fe61cc1a3 | |||
| 25fae6f36e | |||
| 903d0cc8ad | |||
| e4017308b2 | |||
| 93b4e5f3cd | |||
| 10b577c650 | |||
| c67cc808ce | |||
| ceb3f17fe1 | |||
| bb1728f27c | |||
| 4ddd48b573 | |||
| f1e4af7184 | |||
| 0da8e2b197 | |||
| 44149c527e | |||
| ffb64609a5 | |||
| d98a6302f1 | |||
| 5688b25151 | |||
| 3277481550 | |||
| 1b33655bcb | |||
| 44c63067d4 | |||
| c45a1e4385 | |||
| 493f0ff8a7 | |||
| 42650aad4d | |||
| f18985058e | |||
| 0e176193a2 | |||
| 07e82e06d8 | |||
| 20f0e767cb | |||
| e13736db67 | |||
| 6826d11c58 | |||
| e9337b346f | |||
| 5fdb686c66 | |||
| 3a1de3713c | |||
| 854cb3ad3a | |||
| 4f3d21f386 | |||
| 2410c89f61 | |||
| ff5efdeeb6 | |||
| 31c7607ef4 | |||
| 23364b04e8 | |||
| 86d256870b | |||
| 14717e52a0 | |||
| 2e6711bbdd | |||
| 569ed84e4b | |||
| 148c2f9beb | |||
| 4ef8541b11 | |||
| 625d607365 | |||
| 097219b2dd | |||
| 5d715c4ce8 | |||
| 4b6a7450e8 | |||
| 98acd76bbf | |||
| 59eae7f3d0 | |||
| a70ae543cb | |||
| 63365fb1a8 | |||
| 762f553643 | |||
| a87d01ea79 | |||
| 609fd80936 | |||
| af480dba87 | |||
| 091eda1ed2 | |||
| fa0d5c9694 | |||
| 05968d7978 | |||
| 5544b0fa70 | |||
| fb3350c188 | |||
| 3dc19d30d1 | |||
| cbf450f06c | |||
| bf481fd2e5 | |||
| 8d5aa0b27a | |||
| b442cb49ee | |||
| 3da442701a | |||
| 83a669fb2f | |||
| 7dfcb21d35 | |||
| 75f9549a81 | |||
| 6606537c0f | |||
| 4a8f0c9da6 | |||
| e31adfca1a | |||
| 58587e09bd | |||
| 33b8946c87 | |||
| 86579c6715 | |||
| fdcb28e97e | |||
| 271e6e54fd | |||
| 06cc71c76e | |||
| 405f2180d4 | |||
| 73d3ff008d | |||
| ed13d8e253 | |||
| 6ff4a50f02 | |||
| ea775773d9 | |||
| 489fbc4e0e | |||
| 4089d73b51 | |||
| 88889601b9 | |||
| 1c57aab586 | |||
| d04d1a565b | |||
| 5e43dafc96 | |||
| b83621011f | |||
| 8d996b109d | |||
| cff7a27cfe | |||
| 1240173034 | |||
| 77205f744e | |||
| 3758b622f2 | |||
| c292d31ee7 | |||
| 6cee3e2360 | |||
| 80ce71e236 | |||
| e193287dc1 | |||
| c04152fa90 | |||
| b600abd389 | |||
| 3938a7518a | |||
| e458653769 | |||
| 85967440af | |||
| c300fdeb63 | |||
| 9b5e4d9753 | |||
| 12ae5dd89b | |||
| e1afec5b08 | |||
| ff91d3cf68 | |||
| 25eae48a09 | |||
| ea4dc17f4b | |||
| bd03afc003 | |||
| 034ca15318 | |||
| 781e833633 | |||
| 9a104e245d | |||
| 4345460d30 | |||
| 9b90a9837a | |||
| 7d433bf898 | |||
| 3579eb0001 | |||
| 1415623586 | |||
| 616a57af55 | |||
| e437760341 | |||
| 4bbe0d7bab | |||
| ff9b046f0f | |||
| 33ba1ff52b | |||
| 18ee2a44ed | |||
| e2a99f33ea | |||
| 1ccd57f177 | |||
| 0d27ef2912 | |||
| 7d359e3ff5 | |||
| f67ed85b3f | |||
| 76bd7a85e7 | |||
| e04e5b7ea6 | |||
| b8bffc8317 | |||
| 1d1a590e91 | |||
| b47decd71a | |||
| 0696fcbe9b | |||
| a38e14460f | |||
| 039389ee04 | |||
| 9c22ac0154 | |||
| 760c23fb25 | |||
| 8d35f004ee | |||
| 4987d275a9 | |||
| a35a181671 | |||
| cbdf90f639 | |||
| b88e6182f0 | |||
| b946f74261 | |||
| 345cbc11df | |||
| 1cb4295b74 | |||
| db66559815 | |||
| 17c6816f67 | |||
| 1a3a618a30 | |||
| 61cff94a28 | |||
| eeda8ba39e | |||
| b633223a33 | |||
| edb7b661e4 | |||
| b99f353ab8 | |||
| 5965fae920 | |||
| a1532a552f | |||
| e3ee0fcceb | |||
| 44dd1778a0 | |||
| 3555a546ab | |||
| bd56d97299 | |||
| 6f17c29eb8 | |||
| 1cedddf425 | |||
| 0812ca1e48 | |||
| ed771e37f7 | |||
| 619e35dce2 | |||
| 6dbbac29f9 | |||
| cc54c4fa85 | |||
| 1337e2eece | |||
| 58659fbdfd | |||
| 9f7291ce68 | |||
| 82c2225914 | |||
| 85f0a94466 | |||
| 70256c7d6e | |||
| 6005d88bed | |||
| 9b57654b31 | |||
| 4a05bb1911 | |||
| 1e80fb2594 | |||
| 0ab40d0575 | |||
| bf2b313365 | |||
| d2534fa431 | |||
| 39ead49eb4 | |||
| c709476ac5 | |||
| 54f37811dd | |||
| b49ae46f22 | |||
| 1a2d7a4bf5 | |||
| cc5f180427 | |||
| 63b8e1615f | |||
| 958c112fba | |||
| 2204f55329 | |||
| 2be40a9653 | |||
| b7d2f287f3 | |||
| 57d9624c71 | |||
| fc955088e3 | |||
| 43f87f5520 | |||
| aa06b2f489 | |||
| eb656cd361 | |||
| b76a547bec | |||
| cea6f25a40 | |||
| 027e6bcd76 | |||
| ce87c8a977 | |||
| 29de3e6865 | |||
| 80d21ed7a1 | |||
| e9953aa154 | |||
| dda91cfc15 | |||
| c2df33f76a | |||
| 2b240501e0 | |||
| 0aeb2849ad | |||
| 47786932cb | |||
| 358a44674e | |||
| 679bce8bbb | |||
| 334e370c1f | |||
| d6d2053b80 | |||
| 6004878dc6 | |||
| f9a52ca4b5 | |||
| a40574beb5 | |||
| b38dc8085c | |||
| b10c54606b | |||
| c45b8a1253 | |||
| d91d94be94 | |||
| b9e28e23af | |||
| 67f0b864cc | |||
| cfb3136cf0 | |||
| 6ef1eb9ce1 | |||
| 9d8caf5944 | |||
| 3c1cff431c | |||
| f25495cabf | |||
| 62ea8a7e00 | |||
| 601b33d2a7 | |||
| ed6d699eb4 | |||
| 64aca4f2ce | |||
| 217ec6008a | |||
| 0774c93ae6 | |||
| f08ee8da38 | |||
| cf6ef5e9ca | |||
| 7405122dde | |||
| 6652b57dda | |||
| c8f809fa76 | |||
| 5c1b9921e6 | |||
| 67b0a7e946 | |||
| a2152f9807 | |||
| fb56bcf747 | |||
| b555b3e8dc | |||
| 1a7f3d718c | |||
| 03433d472f | |||
| c7497cd5f6 | |||
| 5f592b5960 | |||
| 21ce4b4ff8 | |||
| efebf59b13 | |||
| 4fd9508d41 | |||
| 3828b00dea | |||
| e27326d317 | |||
| 23cc9a3996 | |||
| e0ab4eeb67 | |||
| 8e0074c4e5 | |||
| 3b7cda8cc5 | |||
| 3f1c6960d3 | |||
| 54cb3e5784 | |||
| f1bd4b8215 | |||
| e540dc864c | |||
| 8b27add088 | |||
| 49980abd25 | |||
| f9b15192b8 | |||
| d6d6308ba2 | |||
| c4628a4c04 | |||
| 8c835feaa7 | |||
| c9f61e02ae | |||
| 145afc5393 | |||
| ea1b0f8e2b | |||
| c8bc3e4f1f | |||
| 519a85a801 | |||
| ffd0e6f8f2 | |||
| 7cb61e6e3a | |||
| a1e9276656 | |||
| 233c5e1a70 | |||
| 506c6151d6 | |||
| 11bfdbf136 | |||
| 10cccc7706 | |||
| 6a78dc3375 | |||
| 792225e256 | |||
| 53007af63f | |||
| 51d48f1492 | |||
| b4ae17d224 | |||
| f7a221bc69 | |||
| dceb60ea7d | |||
| 826a3b2fcf | |||
| 0cbdf465e4 | |||
| e287d83ab1 | |||
| 2ed7a94782 | |||
| 433520257a | |||
| aa8366d234 | |||
| 9a6190ceea | |||
| 1e51a503b1 | |||
| fce540024a | |||
| 040f07ff45 | |||
| a73982f5b4 | |||
| fbfd948535 | |||
| 4c25278507 | |||
| 3268d8b0d8 | |||
| 4839fa6614 | |||
| ddc6ce61db | |||
| a6eb2a8f9a | |||
| a7d580b934 | |||
| f9fcbe9430 | |||
| 1615c93511 | |||
| 313f94ed8f | |||
| ff9087adb4 | |||
| d0ac5ce64c | |||
| dccca0506a | |||
| 41e513da64 | |||
| 1899fbe3fb | |||
| dd83a2c7ad | |||
| 235dba2d82 | |||
| edd828ca88 | |||
| 1ce644871b | |||
| da66510f68 | |||
| 1f82d59d67 | |||
| 61b3a2c5ec | |||
| ef1e02e555 | |||
| 1feca02008 | |||
| b92870c240 | |||
| 8970ed0849 | |||
| a7d2b05a99 | |||
| 4a09d6460a | |||
| a1ff289bf9 | |||
| 7bb0f43503 | |||
| 86b48f368f | |||
| e488e3639a | |||
| 2e254b4b5e | |||
| 1471e54b92 | |||
| fac7efe946 | |||
| 155ba08be7 | |||
| 71c5fe04f1 | |||
| 8b4990905c | |||
| f6a64f713c | |||
| b343c5e8fa | |||
| 776162c162 | |||
| 6f3ece9181 | |||
| 2d0b3fdeb0 | |||
| 4320259e34 | |||
| 7091fad860 | |||
| 2520e662f7 | |||
| 630b5c4fdd | |||
| 2c37e563fd | |||
| 8800bccab8 | |||
| 84bf0c0c07 | |||
| a071813b97 | |||
| ca69f91f6b | |||
| 35185c023e | |||
| 75b1908f24 | |||
| 95e2de368f | |||
| b859c910ab | |||
| 46fe2c25c8 | |||
| ab52efd622 | |||
| 42651ce2d3 | |||
| bba070a1fe | |||
| 745c6ee861 | |||
| 7bdf5003c7 | |||
| 1873ed0908 | |||
| efe77ce806 | |||
| b4fbffe79c | |||
| 0c40a0b2c6 | |||
| 9b5df96132 | |||
| 90539a1a99 | |||
| c8ec4d5e43 | |||
| f23faf97d6 | |||
| 8c1c4640b8 | |||
| 6b425d13f5 | |||
| ade37b2765 | |||
| dc0569066e | |||
| 87ffaad9a3 | |||
| 4a5eb4baea | |||
| 63209b1def | |||
| 26a56d0a8f | |||
| c43d8c4a3c | |||
| 6db6c0dc72 | |||
| e4aabd3de6 | |||
| 1cf6d01989 | |||
| 0a801316cd | |||
| 9919033068 | |||
| e901c56849 | |||
| 3a082011dc | |||
| af7d3bf5da | |||
| 059b50b2e7 | |||
| 290a995de5 | |||
| 54cbacb6eb | |||
| 29916981e7 | |||
| 0d51a32e47 | |||
| ed80b589d3 | |||
| 46a0829aa8 | |||
| 41059fc548 | |||
| ef4756bcfc | |||
| 9f6635a035 | |||
| 79c8cfcd58 | |||
| 799fe34c12 | |||
| d507bd9c95 | |||
| fe6d325397 | |||
| 572c1b4d69 | |||
| 9e36323ae3 | |||
| e47f3719f1 | |||
| 8aaa71f86e |
@@ -0,0 +1,12 @@
|
||||
# Ignore non-functional treewide changes by configuring
|
||||
#
|
||||
# $ git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||
#
|
||||
# or used temporarily with --ignore-revs-file=
|
||||
#
|
||||
|
||||
# nixfmt
|
||||
1a7f3d718c5a6406b7d5b54f10f5c9c69ed90ef9
|
||||
|
||||
# language hints
|
||||
06cc71c76eb52dc747704a317ac5e175ebdd2ba8
|
||||
@@ -1 +1,3 @@
|
||||
result
|
||||
.direnv
|
||||
.pre-commit-config.yaml
|
||||
|
||||
+13
-8
@@ -1,13 +1,18 @@
|
||||
.hydra-cli:
|
||||
image: docker.nix-community.org/nixpkgs/nix-flakes
|
||||
script:
|
||||
- nix run --inputs-from ./. nixpkgs#hydra-cli -- -H https://hydra.nix-community.org jobset-wait simple-nixos-mailserver "${jobset}"
|
||||
|
||||
hydra-pr:
|
||||
extends: .hydra-cli
|
||||
only:
|
||||
- merge_requests
|
||||
image: nixos/nix
|
||||
script:
|
||||
- nix-shell -I nixpkgs=channel:nixos-22.05 -p hydra-cli --run 'hydra-cli -H https://hydra.nix-community.org jobset-wait simple-nixos-mailserver ${CI_MERGE_REQUEST_IID}'
|
||||
variables:
|
||||
jobset: $CI_MERGE_REQUEST_IID
|
||||
|
||||
hydra-master:
|
||||
hydra-main:
|
||||
extends: .hydra-cli
|
||||
only:
|
||||
- master
|
||||
image: nixos/nix
|
||||
script:
|
||||
- nix-shell -I nixpkgs=channel:nixos-22.05 -p hydra-cli --run 'hydra-cli -H https://hydra.nix-community.org jobset-wait simple-nixos-mailserver master'
|
||||
- main
|
||||
variables:
|
||||
jobset: main
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
{ nixpkgs, pulls, ... }:
|
||||
|
||||
let
|
||||
pkgs = import nixpkgs {};
|
||||
pkgs = import nixpkgs { };
|
||||
|
||||
prs = builtins.fromJSON (builtins.readFile pulls);
|
||||
prJobsets = pkgs.lib.mapAttrs (num: info:
|
||||
{ enabled = 1;
|
||||
hidden = false;
|
||||
description = "PR ${num}: ${info.title}";
|
||||
checkinterval = 30;
|
||||
schedulingshares = 20;
|
||||
enableemail = false;
|
||||
emailoverride = "";
|
||||
keepnr = 1;
|
||||
type = 1;
|
||||
flake = "gitlab:simple-nixos-mailserver/nixos-mailserver/merge-requests/${info.iid}/head";
|
||||
}
|
||||
) prs;
|
||||
prJobsets = pkgs.lib.mapAttrs (num: info: {
|
||||
enabled = 1;
|
||||
hidden = false;
|
||||
description = "PR ${num}: ${info.title}";
|
||||
checkinterval = 300;
|
||||
schedulingshares = 20;
|
||||
enableemail = false;
|
||||
emailoverride = "";
|
||||
keepnr = 1;
|
||||
type = 1;
|
||||
flake = "gitlab:simple-nixos-mailserver/nixos-mailserver/merge-requests/${info.iid}/head";
|
||||
}) prs;
|
||||
mkFlakeJobset = branch: {
|
||||
description = "Build ${branch} branch of Simple NixOS MailServer";
|
||||
checkinterval = "60";
|
||||
checkinterval = 300;
|
||||
enabled = "1";
|
||||
schedulingshares = 100;
|
||||
enableemail = false;
|
||||
@@ -31,9 +30,9 @@ let
|
||||
};
|
||||
|
||||
desc = prJobsets // {
|
||||
"master" = mkFlakeJobset "master";
|
||||
"nixos-22.11" = mkFlakeJobset "nixos-22.11";
|
||||
"nixos-23.05" = mkFlakeJobset "nixos-23.05";
|
||||
"main" = mkFlakeJobset "main";
|
||||
"nixos-26.05" = mkFlakeJobset "nixos-26.05";
|
||||
"nixos-25.11" = mkFlakeJobset "nixos-25.11";
|
||||
};
|
||||
|
||||
log = {
|
||||
@@ -41,13 +40,14 @@ let
|
||||
jobsets = desc;
|
||||
};
|
||||
|
||||
in {
|
||||
jobsets = pkgs.runCommand "spec-jobsets.json" {} ''
|
||||
cat >$out <<EOF
|
||||
in
|
||||
{
|
||||
jobsets = pkgs.runCommand "spec-jobsets.json" { } ''
|
||||
cat >$out <<'EOF'
|
||||
${builtins.toJSON desc}
|
||||
EOF
|
||||
# This is to get nice .jobsets build logs on Hydra
|
||||
cat >tmp <<EOF
|
||||
cat >tmp <<'EOF'
|
||||
${builtins.toJSON log}
|
||||
EOF
|
||||
${pkgs.jq}/bin/jq . tmp
|
||||
|
||||
+2
-2
@@ -12,12 +12,12 @@
|
||||
"type": 0,
|
||||
"inputs": {
|
||||
"nixexpr": {
|
||||
"value": "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver master",
|
||||
"value": "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver main",
|
||||
"type": "git",
|
||||
"emailresponsible": false
|
||||
},
|
||||
"nixpkgs": {
|
||||
"value": "https://github.com/NixOS/nixpkgs 0f920b05cbcdb8c0f3c5c4a8ea29f1f0065c7033 ",
|
||||
"value": "https://github.com/NixOS/nixpkgs nixpkgs-unstable",
|
||||
"type": "git",
|
||||
"emailresponsible": false
|
||||
},
|
||||
|
||||
+7
-5
@@ -5,20 +5,22 @@
|
||||
version: 2
|
||||
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
os: ubuntu-24.04
|
||||
tools:
|
||||
python: "3"
|
||||
apt_packages:
|
||||
- nix
|
||||
- curl
|
||||
- proot
|
||||
jobs:
|
||||
pre_install:
|
||||
- mkdir -p ~/.nix ~/.config/nix
|
||||
- echo "experimental-features = nix-command flakes" > ~/.config/nix/nix.conf
|
||||
- proot -b ~/.nix:/nix /bin/sh -c "nix build -L .#optionsDoc && cp -v result docs/options.md"
|
||||
- curl -L https://github.com/DavHau/nix-portable/releases/latest/download/nix-portable-$(uname -m) > ./nix-portable
|
||||
- chmod +x ./nix-portable
|
||||
- ./nix-portable nix build --print-build-logs .#optionsDoc
|
||||
- ./nix-portable nix store cat $(readlink result) > docs/options.md
|
||||
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
fail_on_warning: true
|
||||
|
||||
formats:
|
||||
- pdf
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[rstcheck]
|
||||
ignore_messages = Hyperlink target ".*" is not referenced.
|
||||
@@ -0,0 +1,2 @@
|
||||
[default.extend-identifiers]
|
||||
reportd = "reportd"
|
||||
@@ -1,75 +1,78 @@
|
||||
# ![Simple Nixos MailServer][logo]
|
||||

|
||||
[](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/commits/master)
|
||||
|
||||

|
||||
[](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/commits/main)
|
||||
|
||||
## Release branches
|
||||
|
||||
For each NixOS release, we publish a branch. You then have to use the
|
||||
SNM branch corresponding to your NixOS version.
|
||||
We publish a branch for each NixOS release. Only matching branch versions are
|
||||
supported.
|
||||
|
||||
* For NixOS 23.05
|
||||
- Use the [SNM branch `nixos-23.05`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-23.05)
|
||||
- [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-23.05/)
|
||||
- [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-23.05/release-notes.html#nixos-23-05)
|
||||
* For NixOS 22.11
|
||||
- Use the [SNM branch `nixos-22.11`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-22.11)
|
||||
- [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-22.11/)
|
||||
- [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-22.11/release-notes.html#nixos-22-11)
|
||||
* For NixOS 26.05
|
||||
* Use the [`nixos-26.05`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-25.11) branch
|
||||
* [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-26.05/)
|
||||
* [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-26.05/release-notes.html#nixos-26-05)
|
||||
* For NixOS unstable
|
||||
- Use the [SNM branch `master`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/master)
|
||||
- [Documentation](https://nixos-mailserver.readthedocs.io/en/latest/)
|
||||
|
||||
[Subscribe to SNM Announcement List](https://www.freelists.org/list/snm)
|
||||
This is a very low volume list where new releases of SNM are announced, so you
|
||||
can stay up to date with bug fixes and updates.
|
||||
|
||||
* Use the [`main`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/main) branch
|
||||
* [Documentation](https://nixos-mailserver.readthedocs.io/en/latest/)
|
||||
|
||||
## Features
|
||||
### v2.0
|
||||
* [x] Continous Integration Testing
|
||||
* [x] Multiple Domains
|
||||
* Postfix MTA
|
||||
- [x] smtp on port 25
|
||||
- [x] submission tls on port 465
|
||||
- [x] submission starttls on port 587
|
||||
- [x] lmtp with dovecot
|
||||
* Dovecot
|
||||
- [x] maildir folders
|
||||
- [x] imap with tls on port 993
|
||||
- [x] pop3 with tls on port 995
|
||||
- [x] imap with starttls on port 143
|
||||
- [x] pop3 with starttls on port 110
|
||||
* Certificates
|
||||
- [x] manual certificates
|
||||
- [x] on the fly creation
|
||||
- [x] Let's Encrypt
|
||||
* Spam Filtering
|
||||
- [x] via rspamd
|
||||
* Virus Scanning
|
||||
- [x] via clamav
|
||||
* DKIM Signing
|
||||
- [x] via opendkim
|
||||
* User Management
|
||||
- [x] declarative user management
|
||||
- [x] declarative password management
|
||||
* Sieves
|
||||
- [x] A simple standard script that moves spam
|
||||
- [x] Allow user defined sieve scripts
|
||||
- [x] ManageSieve support
|
||||
* User Aliases
|
||||
- [x] Regular aliases
|
||||
- [x] Catch all aliases
|
||||
|
||||
* [x] Continuous Integration Testing
|
||||
* [x] Multiple Domains
|
||||
* Postfix
|
||||
* [x] SMTP on port 25
|
||||
* [x] Submission TLS on port 465
|
||||
* [x] Submission StartTLS on port 587
|
||||
* [x] LMTP with Dovecot
|
||||
* [x] DANE and MTA-STS validation
|
||||
* [x] SMTP TLS Reports ([RFC 8460](https://www.rfc-editor.org/rfc/rfc8460))
|
||||
* Dovecot
|
||||
* [x] Maildir folders
|
||||
* [x] IMAP with TLS on port 993
|
||||
* [x] POP3 with TLS on port 995
|
||||
* [x] IMAP with StartTLS on port 143
|
||||
* [x] POP3 with StartTLS on port 110
|
||||
* Certificates
|
||||
* [x] ACME
|
||||
* [x] Custom certificates
|
||||
* Spam Filtering
|
||||
* [x] Via Rspamd
|
||||
* Virus Scanning
|
||||
* [x] Via ClamAV
|
||||
* DKIM Signing
|
||||
* [x] Via Rspamd
|
||||
* [x] Automatic key generation
|
||||
* [x] Multiple selectors per Domain
|
||||
* User Management
|
||||
* [x] Declarative user management
|
||||
* [x] Declarative password management
|
||||
* [x] LDAP users
|
||||
* Sieve
|
||||
* [x] Allow user defined sieve scripts
|
||||
* [x] Moving mails from/to junk trains the Bayes filter
|
||||
* [x] ManageSieve support
|
||||
* User Aliases
|
||||
* [x] Regular aliases
|
||||
* [x] Catch all aliases
|
||||
* Improve the Forwarding Experience
|
||||
* [x] [Sender Rewriting Scheme](https://en.wikipedia.org/wiki/Sender_Rewriting_Scheme)
|
||||
|
||||
### In the future
|
||||
|
||||
* DKIM Signing
|
||||
- [ ] Allow a per domain selector
|
||||
* Automatic client configuration
|
||||
* [ ] [Autoconfig](https://web.archive.org/web/20210624004729/https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration)
|
||||
* [ ] [Autodiscovery](https://learn.microsoft.com/en-us/exchange/architecture/client-access/autodiscover?view=exchserver-2019)
|
||||
* [ ] [Mobileconfig](https://support.apple.com/guide/profile-manager/distribute-profiles-manually-pmdbd71ebc9/mac)
|
||||
* Improve the Forwarding Experience
|
||||
* [ ] Support [ARC](https://en.wikipedia.org/wiki/Authenticated_Received_Chain) signing with [Rspamd](https://rspamd.com/doc/modules/arc.html)
|
||||
* OpenID Connect
|
||||
* Depends on relevant clients adding support, e.g. [Thunderbird](https://bugzilla.mozilla.org/show_bug.cgi?id=1602166)
|
||||
|
||||
### Get in touch
|
||||
|
||||
- Subscribe to the [mailing list](https://www.freelists.org/archive/snm/)
|
||||
- Join the Libera Chat IRC channel `#nixos-mailserver`
|
||||
* Matrix: [#nixos-mailserver:nixos.org](https://matrix.to/#/#nixos-mailserver:nixos.org)
|
||||
* IRC: `#nixos-mailserver` on [Libera Chat](https://libera.chat/guides/connect)
|
||||
|
||||
## How to Set Up a 10/10 Mail Server Guide
|
||||
|
||||
@@ -82,16 +85,18 @@ For a complete list of options, [see in readthedocs](https://nixos-mailserver.re
|
||||
See the [How to Develop SNM](https://nixos-mailserver.readthedocs.io/en/latest/howto-develop.html) documentation page.
|
||||
|
||||
## Contributors
|
||||
See the [contributor tab](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/graphs/master)
|
||||
|
||||
See the [contributor tab](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/graphs/main)
|
||||
|
||||
### Alternative Implementations
|
||||
* [NixCloud Webservices](https://github.com/nixcloud/nixcloud-webservices)
|
||||
|
||||
* [NixCloud Webservices](https://github.com/nixcloud/nixcloud-webservices)
|
||||
|
||||
### Credits
|
||||
* send mail graphic by [tnp_dreamingmao](https://thenounproject.com/dreamingmao)
|
||||
|
||||
* send mail graphic by [tnp_dreamingmao](https://thenounproject.com/dreamingmao)
|
||||
from [TheNounProject](https://thenounproject.com/) is licensed under
|
||||
[CC BY 3.0](http://creativecommons.org/~/3.0/)
|
||||
* Logo made with [Logomakr.com](https://logomakr.com)
|
||||
|
||||
* Logo made with [Logomakr.com](https://logomakr.com)
|
||||
|
||||
[logo]: docs/logo.png
|
||||
|
||||
+1108
-606
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXOPTS ?= --fail-on-warning
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = _build
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
Add Radicale
|
||||
============
|
||||
|
||||
Configuration by @dotlambda
|
||||
|
||||
Starting with Radicale 3 (first introduced in NixOS 20.09) the traditional
|
||||
crypt passwords are no longer supported. Instead bcrypt passwords
|
||||
have to be used. These can still be generated using `mkpasswd -m bcrypt`.
|
||||
|
||||
.. code:: nix
|
||||
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
with lib;
|
||||
|
||||
let
|
||||
mailAccounts = config.mailserver.loginAccounts;
|
||||
htpasswd = pkgs.writeText "radicale.users" (concatStrings
|
||||
(flip mapAttrsToList mailAccounts (mail: user:
|
||||
mail + ":" + user.hashedPassword + "\n"
|
||||
))
|
||||
);
|
||||
|
||||
in {
|
||||
services.radicale = {
|
||||
enable = true;
|
||||
config = ''
|
||||
[auth]
|
||||
type = htpasswd
|
||||
htpasswd_filename = ${htpasswd}
|
||||
htpasswd_encryption = bcrypt
|
||||
'';
|
||||
};
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts = {
|
||||
"cal.example.com" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
locations."/" = {
|
||||
proxyPass = "http://localhost:5232/";
|
||||
extraConfig = ''
|
||||
proxy_set_header X-Script-Name /;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_pass_header Authorization;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 80 443 ];
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
Add Roundcube, a webmail
|
||||
========================
|
||||
|
||||
The NixOS module for roundcube nearly works out of the box with SNM. By
|
||||
default, it sets up a nginx virtual host to serve the webmail, other web
|
||||
servers may require more work.
|
||||
|
||||
.. code:: nix
|
||||
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
with lib;
|
||||
|
||||
{
|
||||
services.roundcube = {
|
||||
enable = true;
|
||||
# this is the url of the vhost, not necessarily the same as the fqdn of
|
||||
# the mailserver
|
||||
hostName = "webmail.example.com";
|
||||
extraConfig = ''
|
||||
# starttls needed for authentication, so the fqdn required to match
|
||||
# the certificate
|
||||
$config['smtp_server'] = "tls://${config.mailserver.fqdn}";
|
||||
$config['smtp_user'] = "%u";
|
||||
$config['smtp_pass'] = "%p";
|
||||
'';
|
||||
};
|
||||
|
||||
services.nginx.enable = true;
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 80 443 ];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
Advanced Configurations
|
||||
=======================
|
||||
|
||||
Congratulations on completing the `Setup Guide <setup-guide.html>`_!
|
||||
|
||||
If you're an experienced mailserver admin, then you probably know what you want
|
||||
to do next. Our How-to guides (accessible in the navigation sidebar)
|
||||
might help you accomplish your goals. If not, consider contributing a guide!
|
||||
|
||||
If this is your first mailserver, consider the following:
|
||||
|
||||
- Configure regular, automatic `backups <backup-guide.html>`_.
|
||||
- Enable `fulltext search <fts.html>`_ to let clients that don’t sync all
|
||||
mail efficiently find messages, by performing searches directly on the server.
|
||||
- Set up the `Sender Rewriting Scheme`_ if you rely on server-side mail
|
||||
forwarding to external mail servers using mail forwards or Sieve rules.
|
||||
- Contribute `DMARC reports`_ and `SMTP TLS reports`_ to help improve email
|
||||
security across the internet by sending feedback on authentication failures,
|
||||
spoofing attempts, and TLS encryption issues.
|
||||
|
||||
.. _DMARC reports: options.html#mailserver-dmarcreporting
|
||||
.. _SMTP TLS reports: options.html#mailserver-tlsrpt
|
||||
.. _Sender Rewriting Scheme: srs.html
|
||||
+67
-12
@@ -1,18 +1,73 @@
|
||||
Autodiscovery
|
||||
=============
|
||||
|
||||
`RFC6186 <https://www.rfc-editor.org/rfc/rfc6186>`_ allows supporting email clients to automatically discover SMTP / IMAP addresses
|
||||
of the mailserver. For that, the following records are required:
|
||||
`RFC6186`_ defines how email clients can automatically discover a mail server's
|
||||
SMTP and IMAP endpoints. To enable this, the following DNS records must be
|
||||
configured:
|
||||
|
||||
================= ==== ==== ======== ====== ==== =================
|
||||
Record TTL Type Priority Weight Port Value
|
||||
================= ==== ==== ======== ====== ==== =================
|
||||
_submission._tcp 3600 SRV 5 0 587 mail.example.com.
|
||||
_submissions._tcp 3600 SRV 5 0 465 mail.example.com.
|
||||
_imap._tcp 3600 SRV 5 0 143 mail.example.com.
|
||||
_imaps._tcp 3600 SRV 5 0 993 mail.example.com.
|
||||
================= ==== ==== ======== ====== ==== =================
|
||||
.. csv-table:: Resource record set
|
||||
:header: "Name", "TTL", "Type", "Priority", "Weight", "Port", "Value"
|
||||
:widths: 30, 5, 5, 5, 5, 5, 20
|
||||
|
||||
Please note that only a few MUAs currently implement this. For vendor-specific
|
||||
discovery mechanisms `automx <https://github.com/rseichter/automx2>`_ can be used instead.
|
||||
_submissions._tcp.example.com., 3600, SRV, 10, 1, 465, mail.example.com.
|
||||
_imaps._tcp.example.com., 3600, SRV, 10, 1, 993, mail.example.com.
|
||||
|
||||
|
||||
Legacy records
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
The following DNS records are only supported with
|
||||
:option:`mailserver.enableSubmission` and :option:`mailserver.enableImap`,
|
||||
because they only support connections with explicit TLS. These services are
|
||||
disabled by default because they are deprecated through `RFC8314 4.1`_.
|
||||
|
||||
.. csv-table:: Resource record set
|
||||
:header: "Name", "TTL", "Type", "Priority", "Weight", "Port", "Value"
|
||||
:widths: 30, 5, 5, 5, 5, 5, 20
|
||||
|
||||
_submission._tcp.example.com., 3600, SRV, 20, 1, 587, mail.example.com.
|
||||
_imap._tcp.example.com., 3600, SRV, 20, 1, 143, mail.example.com.
|
||||
|
||||
Client support
|
||||
^^^^^^^^^^^^^^
|
||||
*As researched in March 2026*
|
||||
|
||||
Only a small number of MUAs currently implement this. The most common concern
|
||||
from the bigger and security-conscious vendors is lack of widespread DNSSEC
|
||||
propagation that could be used to authenticate these SRV records.
|
||||
|
||||
- Aerc: since 0.20.1
|
||||
|
||||
- ``_submissions._tcp`` support submitted in https://lists.sr.ht/~rjarry/aerc-devel/patches/68173
|
||||
|
||||
- Evolution: Since 3.49.3 for mail accounts
|
||||
|
||||
- https://gitlab.gnome.org/GNOME/evolution/-/wikis/Autoconfig
|
||||
- https://gitlab.gnome.org/GNOME/evolution/-/issues/941
|
||||
|
||||
Unsupported
|
||||
***********
|
||||
- DeltaChat:
|
||||
|
||||
- https://github.com/chatmail/core/issues/1508
|
||||
|
||||
- Thunderbird:
|
||||
|
||||
- Desktop: https://bugzilla.mozilla.org/show_bug.cgi?id=342242
|
||||
- Android: https://github.com/thunderbird/thunderbird-android/issues/4721
|
||||
|
||||
|
||||
Vendor-specific autoconfig
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The `automx2`_ service can provide autoconfig support for Apple's
|
||||
`mobileconfig`_, Microsoft's `Autodiscover`_ and Mozilla's `Autoconfig`_
|
||||
standards. It does however lack support for multiple mail domains and isn't open for
|
||||
contributions due to copyright concerns.
|
||||
|
||||
.. _mobileconfig: https://support.apple.com/de-de/guide/profile-manager/pmdbd71ebc9/mac
|
||||
.. _Autodiscover: https://learn.microsoft.com/en-us/exchange/architecture/client-access/autodiscover?view=exchserver-2019
|
||||
.. _Autoconfig: https://benbucksch.github.io/autoconfig-spec/draft-ietf-mailmaint-autoconfig.html
|
||||
.. _automx2: https://github.com/rseichter/automx2
|
||||
.. _RFC6186: https://www.rfc-editor.org/rfc/rfc6186
|
||||
.. _RFC8314 4.1: https://www.rfc-editor.org/rfc/rfc8314#section-4.1
|
||||
|
||||
+13
-12
@@ -5,16 +5,17 @@ First off you should have a backup of your ``configuration.nix`` file
|
||||
where you have the server config (but that is already in a git
|
||||
repository right?)
|
||||
|
||||
Next you need to backup ``/var/vmail`` or whatever you have specified
|
||||
for the option ``mailDirectory``. This is where all the mails reside.
|
||||
Good options are a cron job with ``rsync`` or ``scp``. But really
|
||||
anything works, as it is simply a folder with plenty of files in it. If
|
||||
your backup solution does not preserve the owner of the files don’t
|
||||
forget to ``chown`` them to ``virtualMail:virtualMail`` if you copy them
|
||||
back (or whatever you specified as ``vmailUserName``, and
|
||||
``vmailGoupName``).
|
||||
Next you need to backup ``/var/vmail`` or whatever you have specified for the
|
||||
option :option:`mailserver.storage.path`. This is where all the mails reside.
|
||||
Good options are a cron job with ``rsync`` or ``scp``. But really anything
|
||||
works, as it is simply a folder with plenty of files in it. If your backup
|
||||
solution does not preserve the owner of the files don’t forget to ``chown`` them
|
||||
to ``virtualMail:virtualMail`` if you copy them back (or whatever you specified
|
||||
as :option:`mailserver.storage.owner`, and :option:`mailserver.storage.group`).
|
||||
|
||||
Finally you can (optionally) make a backup of ``/var/dkim`` (or whatever
|
||||
you specified as ``dkimKeyDirectory``). If you should lose those don’t
|
||||
worry, new ones will be created on the fly. But you will need to repeat
|
||||
step ``B)5`` and correct all the ``dkim`` keys.
|
||||
To backup spam and ham training data, backup ``/var/lib/redis-rspamd``.
|
||||
|
||||
Finally you can (optionally) make a backup of ``/var/dkim`` (or whatever you
|
||||
specified as :option:`mailserver.dkim.keyDirectory`). If you should lose those
|
||||
don’t worry, new ones will be created on the fly. But you will need to update
|
||||
the DKIM TXT records to reflect the new key material.
|
||||
|
||||
+11
-13
@@ -17,43 +17,41 @@
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = 'NixOS Mailserver'
|
||||
copyright = '2022, NixOS Mailserver Contributors'
|
||||
author = 'NixOS Mailserver Contributors'
|
||||
|
||||
project = "NixOS Mailserver"
|
||||
copyright = "2022, NixOS Mailserver Contributors"
|
||||
author = "NixOS Mailserver Contributors"
|
||||
version = "26.05"
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
'myst_parser'
|
||||
]
|
||||
extensions = ["myst_parser"]
|
||||
|
||||
myst_enable_extensions = [
|
||||
'colon_fence',
|
||||
'linkify',
|
||||
"colon_fence",
|
||||
"linkify",
|
||||
]
|
||||
|
||||
smartquotes = False
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
templates_path = ["_templates"]
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This pattern also affects html_static_path and html_extra_path.
|
||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
||||
|
||||
master_doc = 'index'
|
||||
master_doc = "index"
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
.. _dkim:
|
||||
|
||||
DKIM Signing
|
||||
============
|
||||
|
||||
DKIM (DomainKeys Identified Mail) is an email authentication mechanism that
|
||||
allows a mailserver to digitally sign outgoing emails for a domain. Receiving
|
||||
mail servers can verify this signature using a public key published in DNS to
|
||||
confirm the message was authorized by the domain and was not modified during
|
||||
transit.
|
||||
|
||||
How DKIM works in practice
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
1. ``bob@bar.example`` sends an email to ``alice@foo.example``. The sending
|
||||
mail server for ``bar.example`` selects one or multiple DKIM keys using a
|
||||
**selector** (e.g., ``mail``) and creates one or multiple cryptographic
|
||||
signature for selected headers and the message body, adding a ``DKIM-Signature``
|
||||
header that references the selector.
|
||||
|
||||
2. The message arrives at ``foo.example``. The receiving mail server reads the
|
||||
``DKIM-Signature`` headers, looks up the public keys for ``bar.example`` for
|
||||
each of the specified selectors (e.g., ``mail._domainkey.bar.example``), and
|
||||
verifies that at least one signature matches the message content.
|
||||
|
||||
3. With a valid signature, the receiver knows the message was authorized by
|
||||
``bar.example`` and that the signed headers and body were not modified in
|
||||
transit. If the content or signed headers were changed, the DKIM verification
|
||||
fails. The use of selectors allows ``bar.example`` to rotate or migrate keys
|
||||
without disrupting verification for previously sent messages.
|
||||
|
||||
Enabling DKIM Signing
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Because DKIM signing is crucial for reliable mail delivery it is enabled by
|
||||
default and without further configuration a DKIM keypair will be generated for
|
||||
each :option:`mailserver.domains` (including :option:`mailserver.srs.domain`,
|
||||
if set) based on :option:`mailserver.dkim.defaults
|
||||
<mailserver.dkim.defaults.keyLength>`.
|
||||
|
||||
.. code:: nix
|
||||
|
||||
{
|
||||
mailserver = {
|
||||
domains = [ "example.com" ];
|
||||
dkim.enable = true; # enabled by default
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
.. note::
|
||||
If you set up NixOS Mailserver before the `25.11 release`_ your DKIM keys were
|
||||
generated with 1024 bit RSA and we recommend replacing them with 2048 bit
|
||||
RSA key material per `RFC8301 3.2`_.
|
||||
|
||||
.. _25.11 release: release-notes.html#nixos-25-11
|
||||
.. _RFC8301 3.2: https://www.rfc-editor.org/rfc/rfc8301#section-3.2
|
||||
|
||||
.. _dkim-key-rotation:
|
||||
|
||||
DKIM Key Rotation
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
DKIM key rotation replaces a domain's signing keys to maintain
|
||||
strong email authentication and support algorithm upgrades. Rotation is
|
||||
essential for migrating away from weaker or deprecated algorithms.
|
||||
|
||||
Selectors allow multiple keys to coexist during the transition: a new key can
|
||||
be deployed under a different selector while the old key remains valid for a
|
||||
limited period to verify messages still in transit. Once all messages signed
|
||||
with the old key have been delivered, the key can be safely retired, ensuring a
|
||||
reliable migration without breaking verification.
|
||||
|
||||
|
||||
1. Make the automatically generated key explicit
|
||||
************************************************
|
||||
|
||||
First we need to make sure we keep the current DKIM key configured. If you were
|
||||
relying on automatically generated keys before, you now need to start explicitly
|
||||
defining that key, because explicit selector configuration takes precedence.
|
||||
|
||||
.. code:: nix
|
||||
|
||||
{
|
||||
mailserver = {
|
||||
domains = [ "example.com" ];
|
||||
dkim.domains = {
|
||||
"example.com".selectors = {
|
||||
"${config.mailserver.dkim.defaults.selector}" = { };
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
2. Create the new DKIM keypair
|
||||
******************************
|
||||
|
||||
Next we need to create a new DKIM key with a unique selector, you can
|
||||
for example choose the current date. Without any settings passed a new
|
||||
key will be generated from the current :option:`mailserver.dkim.defaults
|
||||
<mailserver.dkim.defaults.keyLength>`, which should be sufficient.
|
||||
|
||||
.. code:: nix
|
||||
|
||||
{
|
||||
mailserver = {
|
||||
domains = [ "example.com" ];
|
||||
dkim = {
|
||||
enable = true;
|
||||
domains."example.com".selectors = {
|
||||
"${config.mailserver.dkim.defaults.selector}" = { };
|
||||
"rsa-2026-03" = {
|
||||
keyType = "rsa";
|
||||
keyLength = 2048;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
.. warning::
|
||||
While DKIM does support Ed25519 keys (`RFC8463`_), many validators still lack
|
||||
proper support and may treat Ed25519 key material as invalid. As a result,
|
||||
mail signed only with Ed25519 DKIM keys may fail verification at some
|
||||
receivers.
|
||||
|
||||
.. _RFC8463: https://datatracker.ietf.org/doc/html/rfc8463
|
||||
|
||||
Once this configuration is applied the new keypair will be generated below
|
||||
:option:`mailserver.dkim.keyDirectory`, which defaults to ``/var/dkim``. The
|
||||
mailserver then begins signing outgoing mail with this key, so that it is now
|
||||
signing with two DKIM keys simultaneously.
|
||||
|
||||
To allow receiving servers to verify the new DKIM signature its
|
||||
public key needs to be published into DNS. Look up the public key from
|
||||
``/var/dkim/example.com.rsa-2026-03.txt`` and create the following DNS record by
|
||||
substituting selector and public key.
|
||||
|
||||
.. csv-table::
|
||||
:header: "Name", "TTL", "Type", "Value"
|
||||
:widths: 30, 10, 10, 50
|
||||
|
||||
rsa-2026-03._domainkey.example.com., 86400, TXT, v=DKIM1; k=rsa; p=<public key>
|
||||
|
||||
.. note::
|
||||
If you created an Ed25519 key, make sure to set the correct key type: ``k=ed25519``
|
||||
|
||||
Now wait for a few minutes and then check DNS propagation to show the value specified.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ nix-shell -p dig --command "dig @ns1.example.org TXT rsa-2026-03._domainkey.example.com"
|
||||
|
||||
You can use https://www.mail-tester.com to test the new DKIM signature passes
|
||||
validation. They allow you to view the message source where you can check that
|
||||
the correct number of ``DKIM-Signature`` keys are present in the mail header.
|
||||
|
||||
|
||||
3. Stop signing with the old DKIM keypair
|
||||
*****************************************
|
||||
|
||||
Once validation passes we need to stop signing with the old DKIM keypair, so
|
||||
mail in transit eventually stops depending on the key material we want to rotate
|
||||
out. Removing the selector will not remove the key material from disk, but it
|
||||
will stop using it to sign outgoing mail.
|
||||
|
||||
.. code:: nix
|
||||
|
||||
{
|
||||
mailserver = {
|
||||
domains = [ "example.com" ];
|
||||
dkim = {
|
||||
enable = true;
|
||||
domains."example.com".selectors = {
|
||||
"rsa-2026-03" = {
|
||||
keyType = "rsa";
|
||||
keyLength = 2048;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Apply the configuration.
|
||||
|
||||
|
||||
4. Remove the old DKIM selector from DNS
|
||||
****************************************
|
||||
|
||||
.. warning::
|
||||
Do not remove the DNS records for the old selector immediately. Keeping them
|
||||
in place is essential to ensure that messages still in transit can be verified
|
||||
and delivered successfully.
|
||||
|
||||
Mail delivery is not always instantaneous. In the worst case, multiple retries
|
||||
over several days may be required. According to `RFC5321 4.5.4.1`_ delivery
|
||||
should be retried for at least 4-5 days.
|
||||
|
||||
This means messages signed only with the old DKIM key could still be in transit
|
||||
and rely on the old selector to verify their signatures. To ensure reliable
|
||||
delivery, we recommend waiting **at least five days** before removing the old
|
||||
DKIM selector from DNS.
|
||||
|
||||
.. _RFC5321 4.5.4.1: https://www.rfc-editor.org/rfc/rfc5321#section-4.5.4.1
|
||||
+1
-1
@@ -13,7 +13,7 @@ domain ``example.com`` and send mails with any address of this domain:
|
||||
|
||||
.. code:: nix
|
||||
|
||||
mailserver.loginAccounts = {
|
||||
mailserver.accounts = {
|
||||
"user@example.com" = {
|
||||
aliases = [ "@example.com" ];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
description = "NixOS configuration";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05-small";
|
||||
|
||||
simple-nixos-mailserver.url = "gitlab:simple-nixos-mailserver/nixos-mailserver/nixos-26.05";
|
||||
simple-nixos-mailserver.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
nixpkgs,
|
||||
simple-nixos-mailserver,
|
||||
...
|
||||
}:
|
||||
{
|
||||
nixosConfigurations = {
|
||||
hostname = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux"; # or aarch64-linux
|
||||
modules = [
|
||||
simple-nixos-mailserver.nixosModule
|
||||
{
|
||||
mailserver = {
|
||||
enable = true;
|
||||
|
||||
# Check the setup guide if you have no idea how to continue
|
||||
# from here!
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
+13
-25
@@ -1,30 +1,18 @@
|
||||
Nix Flakes
|
||||
==========
|
||||
Flakes
|
||||
======
|
||||
|
||||
If you're using `flakes <https://nixos.wiki/wiki/Flakes>`__, you can use
|
||||
the following minimal ``flake.nix`` as an example:
|
||||
To use NixOS mailserver `Nix flakes`_, the following minimal ``flake.nix`` can
|
||||
serve as an example to get started:
|
||||
|
||||
.. code:: nix
|
||||
.. _Nix flakes: https://wiki.nixos.org/wiki/Flakes
|
||||
|
||||
{
|
||||
description = "NixOS configuration";
|
||||
.. literalinclude:: ./flakes.nix
|
||||
:language: nix
|
||||
|
||||
inputs.simple-nixos-mailserver.url = "gitlab:simple-nixos-mailserver/nixos-mailserver/nixos-20.09";
|
||||
|
||||
outputs = { self, nixpkgs, simple-nixos-mailserver }: {
|
||||
nixosConfigurations = {
|
||||
hostname = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
simple-nixos-mailserver.nixosModule
|
||||
{
|
||||
mailserver = {
|
||||
enable = true;
|
||||
# ...
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Lock the inputs and deploy the system closure:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
nix flake lock
|
||||
nixos-rebuild --target-host root@mail.example.com --flake .#hostname switch
|
||||
|
||||
+26
-26
@@ -4,7 +4,7 @@ Full text search
|
||||
By default, when your IMAP client searches for an email containing some
|
||||
text in its *body*, dovecot will read all your email sequentially. This
|
||||
is very slow and IO intensive. To speed body searches up, it is possible to
|
||||
*index* emails with a plugin to dovecot, ``fts_xapian``.
|
||||
*index* emails with the ``fts_flatcurve`` dovecot plugin.
|
||||
|
||||
Enabling full text search
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -20,50 +20,50 @@ To enable indexing for full text search here is an example configuration.
|
||||
enable = true;
|
||||
# index new email as they arrive
|
||||
autoIndex = true;
|
||||
# this only applies to plain text attachments, binary attachments are never indexed
|
||||
indexAttachments = true;
|
||||
enforced = "body";
|
||||
# only query index
|
||||
fallback = false;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
The ``enforced`` parameter tells dovecot to fail any body search query that cannot
|
||||
use an index. This prevents dovecot to fall back to the IO-intensive brute
|
||||
force search.
|
||||
Disabling the :option:`mailserver.fullTextSearch.fallback` option tells dovecot
|
||||
to fail any body search query that cannot use an index. This prevents Dovecot to
|
||||
fall back to the IO-intensive brute force search.
|
||||
|
||||
If you set ``autoIndex`` to ``false``, indices will be created when the IMAP client
|
||||
issues a search query, so latency will be high.
|
||||
If you set :option:`mailserver.fullTextSearch.autoIndex` to ``false``, indices
|
||||
will be created when the IMAP client issues a search query, so latency will
|
||||
be high.
|
||||
|
||||
Resource requirements
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Indices created by the full text search feature can take more disk
|
||||
space than the emails themselves. By default, they are kept in the
|
||||
emails location. When enabling the full text search feature, it is
|
||||
recommended to move indices in a different location, such as
|
||||
(``/var/lib/dovecot/indices``) by using the option
|
||||
``mailserver.indexDir``.
|
||||
Indices created by the full text search feature can take more disk space than
|
||||
the emails themselves. By default, they are kept within the maildir. When
|
||||
enabling the full text search feature, it is recommended to move indices in a
|
||||
different location, such as (``/var/lib/dovecot/indices``) by configuring
|
||||
:option:`mailserver.indexDir`.
|
||||
|
||||
.. warning::
|
||||
|
||||
When the value of the ``indexDir`` option is changed, all dovecot
|
||||
indices needs to be recreated: clients would need to resynchronize.
|
||||
When the value of the :option:`mailserver.indexDir` option is changed, all
|
||||
dovecot indices needs to be recreated: clients would need to resynchronize.
|
||||
|
||||
Indexation itself is rather resouces intensive, in CPU, and for emails with
|
||||
Indexation itself is rather resource intensive, in CPU, and for emails with
|
||||
large headers, in memory as well. Initial indexation of existing emails can take
|
||||
hours. If the indexer worker is killed or segfaults during indexation, it can
|
||||
be that it tried to allocate more memory than allowed. You can increase the memory
|
||||
limit by eg ``mailserver.fullTextSearch.memoryLimit = 2000`` (in MiB).
|
||||
hours. If the indexer worker is killed or segfaults during indexation, it can be
|
||||
that it tried to allocate more memory than allowed. You can increase the default
|
||||
memory limit through :option:`mailserver.fullTextSearch.memoryLimit`.
|
||||
|
||||
Mitigating resources requirements
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
You can:
|
||||
|
||||
* disable indexation of attachements ``mailserver.fullTextSearch.indexAttachments = false``
|
||||
* reduce the size of ngrams to be indexed ``mailserver.fullTextSearch.minSize`` and ``maxSize``
|
||||
* disable automatic indexation for some folders with
|
||||
``mailserver.fullTextSearch.autoIndexExclude``. Folders can be specified by
|
||||
name (``"Trash"``), by special use (``"\\Junk"``) or with a wildcard.
|
||||
* exclude some headers from indexation with :option:`mailserver.fullTextSearch.headerExcludes`
|
||||
* disable expensive token normalisation in :option:`mailserver.fullTextSearch.filters`
|
||||
* disable automatic indexation for individual mailboxes by overriding
|
||||
`fts_autoindex`_ on the mailbox level. This is exposed via
|
||||
:option:`mailserver.mailboxes`, where all default mailboxes are defined.
|
||||
|
||||
.. _fts_autoindex: https://doc.dovecot.org/main/core/plugins/fts.html#fts_autoindex
|
||||
|
||||
+57
-22
@@ -4,13 +4,33 @@ Contribute or troubleshoot
|
||||
To report an issue, please go to
|
||||
`<https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues>`_.
|
||||
|
||||
You can also chat with us on the Libera IRC channel ``#nixos-mailserver``.
|
||||
If you have questions, feel free to reach out:
|
||||
|
||||
* Matrix: `#nixos-mailserver:nixos.org <https://matrix.to/#/#nixos-mailserver:nixos.org>`__
|
||||
* IRC: `#nixos-mailserver <ircs://irc.libera.chat/nixos-mailserver>`__ on `Libera Chat <https://libera.chat/guides/connect>`__
|
||||
|
||||
All our workflows rely on Nix being configured with `Flakes <https://wiki.nixos.org/wiki/Flakes#Installing_flakes>`__.
|
||||
|
||||
Development Shell
|
||||
-----------------
|
||||
|
||||
We provide a `flake.nix` devshell that automatically sets up pre-commit hooks,
|
||||
which allows for fast feedback cycles when making changes to the repository.
|
||||
|
||||
|
||||
::
|
||||
|
||||
$ nix develop
|
||||
|
||||
|
||||
We recommend setting up `direnv <https://direnv.net/>`__ to automatically
|
||||
attach to the development environment when entering the project directories.
|
||||
|
||||
Run NixOS tests
|
||||
---------------
|
||||
|
||||
To run the test suite, you need to enable `Nix Flakes
|
||||
<https://nixos.wiki/wiki/Flakes#Installing_flakes>`_.
|
||||
<https://wiki.nixos.org/wiki/Flakes#Installing_flakes>`__.
|
||||
|
||||
You can then run the testsuite via
|
||||
|
||||
@@ -18,8 +38,8 @@ You can then run the testsuite via
|
||||
|
||||
$ nix flake check -L
|
||||
|
||||
Since Nix doesn't garantee your machine have enough resources to run
|
||||
all test VMs in parallel, some tests can fail. You would then haev to
|
||||
Since Nix doesn't guarantee your machine have enough resources to run
|
||||
all test VMs in parallel, some tests can fail. You would then have to
|
||||
run tests manually. For instance:
|
||||
|
||||
::
|
||||
@@ -37,7 +57,7 @@ For the syntax, see the `RST/Sphinx primer
|
||||
<https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html>`_.
|
||||
|
||||
To build the documentation, you need to enable `Nix Flakes
|
||||
<https://nixos.wiki/wiki/Flakes#Installing_flakes>`_.
|
||||
<https://wiki.nixos.org/wiki/Flakes#Installing_flakes>`__.
|
||||
|
||||
|
||||
::
|
||||
@@ -45,28 +65,43 @@ To build the documentation, you need to enable `Nix Flakes
|
||||
$ nix build .#documentation
|
||||
$ xdg-open result/index.html
|
||||
|
||||
Nixops
|
||||
------
|
||||
|
||||
You can test the setup via ``nixops``. After installation, do
|
||||
Manual migrations
|
||||
-----------------
|
||||
|
||||
::
|
||||
We need to take great care around providing a migration story around breaking
|
||||
changes. If manual intervention becomes necessary we provide the `stateVersion`
|
||||
option to notify the user that they need to complete a migration before
|
||||
they can deploy an update.
|
||||
|
||||
$ nixops create nixops/single-server.nix nixops/vbox.nix -d mail
|
||||
$ nixops deploy -d mail
|
||||
$ nixops info -d mail
|
||||
If that is the case for your change, find the highest `stateVersion` that is
|
||||
being asserted on in `mail-server/assertions.nix`. Then pick the next number
|
||||
and add a new assertion, write a good summary describing the issue and what
|
||||
remediation steps are necessary. Finally reference the URL to the specific
|
||||
section on the migration page in the documentation.
|
||||
|
||||
You can then test the server via e.g. \ ``telnet``. To log into it, use
|
||||
.. code-block:: nix
|
||||
|
||||
::
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 1;
|
||||
message = ''
|
||||
Problem: The home directory for the foobar service is snafu.
|
||||
Remediation:
|
||||
- Stop the `foobar.service`
|
||||
- Rename `/var/lib/foobaz` to `/var/lib/foobar`
|
||||
- Increase the `mailserver.stateVersion` to 1.
|
||||
|
||||
$ nixops ssh -d mail mailserver
|
||||
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#specific-anchor-here for further details.
|
||||
'';
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
Imap
|
||||
----
|
||||
The setup guide should always reference the latest `stateVersion`, since we
|
||||
don't require any migration steps for new setups.
|
||||
|
||||
To test imap manually use
|
||||
|
||||
::
|
||||
|
||||
$ openssl s_client -host mail.example.com -port 143 -starttls imap
|
||||
The migration documentation should paint a more complete picture about the steps
|
||||
that need to be carried out and why this has become necessary. Make sure to
|
||||
reference the correct anchor in the URL you put into the assertion message.
|
||||
|
||||
+26
-7
@@ -14,23 +14,42 @@ Welcome to NixOS Mailserver's documentation!
|
||||
:maxdepth: 2
|
||||
|
||||
setup-guide
|
||||
advanced-configurations
|
||||
howto-develop
|
||||
faq
|
||||
release-notes
|
||||
options
|
||||
migrations
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Account backends
|
||||
|
||||
ldap
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:caption: Features
|
||||
|
||||
dkim
|
||||
fts
|
||||
srs
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 0
|
||||
:caption: How-to
|
||||
|
||||
backup-guide
|
||||
add-radicale
|
||||
add-roundcube
|
||||
rspamd-tuning
|
||||
fts
|
||||
flakes
|
||||
autodiscovery
|
||||
ldap
|
||||
backup-guide
|
||||
flakes
|
||||
rspamd-tuning
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 0
|
||||
:caption: Integrations
|
||||
|
||||
radicale
|
||||
roundcube
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
mailserver = {
|
||||
ldap = {
|
||||
attributes = {
|
||||
uuid = "entryUUID";
|
||||
username = "uid";
|
||||
password = "userPassword";
|
||||
mail = "mail";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
mailserver = {
|
||||
ldap = {
|
||||
enable = true;
|
||||
uris = [
|
||||
"ldaps://ldap1.example.com"
|
||||
"ldaps://ldap2.example.com"
|
||||
];
|
||||
bind = {
|
||||
dn = "cn=mail,dc=example=dc=com";
|
||||
passwordFile = "/run/keys/ldap-bind-pw";
|
||||
};
|
||||
base = "ou=users,dc=example,dc=com";
|
||||
scope = "one";
|
||||
};
|
||||
};
|
||||
}
|
||||
+92
-10
@@ -1,14 +1,96 @@
|
||||
LDAP Support
|
||||
============
|
||||
.. _ldap-top:
|
||||
|
||||
It is possible to manage mail user accounts with LDAP rather than with
|
||||
the option `loginAccounts <options.html#mailserver-loginaccounts>`_.
|
||||
LDAP
|
||||
====
|
||||
|
||||
All related LDAP options are described in the `LDAP options section
|
||||
<options.html#mailserver-ldap>`_ and the `LDAP test
|
||||
<https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/tests/ldap.nix>`_
|
||||
provides a getting started example.
|
||||
LDAP (Lightweight Directory Access Protocol) is a protocol for accessing and
|
||||
managing a centralized directory of user and group information. It can be used
|
||||
to authenticate users and provide a single source of truth for email accounts
|
||||
and aliases across mail services.
|
||||
|
||||
.. note::
|
||||
The LDAP support can not be enabled if some accounts are also defined with ``mailserver.loginAccounts``.
|
||||
|
||||
Requirements
|
||||
~~~~~~~~~~~~
|
||||
|
||||
To enable the LDAP integration the following requirements must be fulfilled:
|
||||
|
||||
- Existing LDAP service (we currently only test against OpenLDAP)
|
||||
- Bind credentials against LDAP with permissions to
|
||||
|
||||
- search for the acceptable set of users
|
||||
- read the :option:`mailserver.ldap.attributes.password` attribute
|
||||
|
||||
- Each user entry must provide attributes that can serve as
|
||||
|
||||
- :option:`mailserver.ldap.attributes.mail` (primary mail address)
|
||||
- :option:`mailserver.ldap.attributes.username` (login name)
|
||||
- :option:`mailserver.ldap.attributes.password` (login password)
|
||||
- :option:`mailserver.ldap.attributes.uuid` (stable identifier)
|
||||
|
||||
|
||||
Features
|
||||
~~~~~~~~
|
||||
|
||||
We currently have a basic feature set covering user accounts only and try to
|
||||
follow best practices to simplify maintenance.
|
||||
|
||||
- Users authenticate with the username and password attribute
|
||||
- Maildir storage paths are constructed using the uuid attribute
|
||||
- Primary mail address read from mail attribute
|
||||
|
||||
|
||||
Limitations
|
||||
~~~~~~~~~~~
|
||||
|
||||
Design choices
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
These are intentional choices in how the mail server operates that affect the
|
||||
LDAP integration.
|
||||
|
||||
- For mail address routing local accounts always take priority over LDAP accounts.
|
||||
|
||||
Planned
|
||||
^^^^^^^
|
||||
|
||||
These are features we are interested in but require implementation,
|
||||
documentation and tests.
|
||||
|
||||
- Aliases based on LDAP attributes
|
||||
- Quotas based on LDAP attributes
|
||||
|
||||
Avoided
|
||||
^^^^^^^
|
||||
|
||||
The following features will likely never be implemented, since they would
|
||||
complicate the setup significantly.
|
||||
|
||||
- Domains based on LDAP entries (would require integration with everything we
|
||||
already do for :option:`mailserver.domains`)
|
||||
- Use of ``homeDirectory``, ``uid``, ``gid`` LDAP attributes (we are
|
||||
committed to a virtual setup with one vmail user/uid/gid and UUID based home
|
||||
directories)
|
||||
- Declarative aliases through :option:`mailserver.aliases`. These are limited
|
||||
to local accounts, because Postfix enforces sender ownership based on login
|
||||
identity and does not consult virtual aliases for authorization.
|
||||
|
||||
Enabling LDAP support
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Enable the LDAP integration by configuring an authenticated LDAP connection
|
||||
and how to locate all users. The bind DN must be allowed to read the configured
|
||||
password attribute, which may require additional configuration
|
||||
|
||||
.. literalinclude:: ./ldap-basic.nix
|
||||
:language: nix
|
||||
|
||||
We provide sensible defaults for each attribute, that can be adapted to your
|
||||
local setup.
|
||||
|
||||
.. literalinclude:: ./ldap-attrs.nix
|
||||
:language: nix
|
||||
|
||||
Refer to our `LDAP test`_ for an complete example, and see the `LDAP options`_ section for all possible settings.
|
||||
|
||||
.. _LDAP options: options.html#mailserver-ldap
|
||||
.. _LDAP test: https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/main/tests/ldap.nix
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
Migrations
|
||||
==========
|
||||
|
||||
With mail server configuration best practices changing over time we might need
|
||||
to make changes that require you to complete manual migration steps before you
|
||||
can deploy a new version of NixOS mailserver.
|
||||
|
||||
The initial :option:`mailserver.stateVersion` value should be copied from the
|
||||
setup guide that you used to initially set up your mail server. If in doubt you
|
||||
can always initialize it at ``1`` and walk through all assertions, that might
|
||||
apply to your setup.
|
||||
|
||||
NixOS 26.05
|
||||
-----------
|
||||
|
||||
.. _migration-5:
|
||||
|
||||
#5 Sieve script directory migration
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Sieve scripts managed by users via ManageSieve were previously stored in
|
||||
``/var/sieve`` (or via the now-removed option
|
||||
``mailserver.sieveDirectory``). This setup partially mirrored the mail
|
||||
directory structure in ``/var/vmail`` (``mailserver.storage.path``),
|
||||
which proved to be fragile and error-prone.
|
||||
|
||||
Thanks to a `prior migration`_, we can now migrate these directories into each
|
||||
user’s home directory, aligning with upstream recommendations — almost
|
||||
nine years later.
|
||||
|
||||
.. _prior migration: #dovecot-mail-directory-migration
|
||||
|
||||
This migration is only required if you have :option:`mailserver.enableManageSieve` enabled.
|
||||
|
||||
1. If you are coming from ``25.11`` and are using LDAP, temporarily disable
|
||||
:option:`mailserver.enableManageSieve` by setting it to ``false``, deploy,
|
||||
and only then continue with this migration.
|
||||
|
||||
If you are not coming from ``25.11`` or are not using LDAP, continue with
|
||||
this migration as is.
|
||||
|
||||
2. Copy the migration script to your mailserver and make it executable:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
cd /tmp
|
||||
wcurl https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/main/migrations/nixos-mailserver-migration-05.py
|
||||
chmod +x nixos-mailserver-migration-05.py
|
||||
|
||||
3. Stop the ``postfix.service``.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
systemctl stop postfix.service
|
||||
|
||||
4. Create a backup or snapshot of your ``mailserver.sieveDirectory``, so
|
||||
you can restore should anything go wrong.
|
||||
|
||||
5. Run the migration script and pass your ``mailserver.sieveDirectory`` as argument:
|
||||
|
||||
The script should be run under the user who owns the ``mailserver.sieveDirectory``.
|
||||
If run as root it will automatically switch into the appropriate user by itself.
|
||||
|
||||
The script will not modify your data unless called with ``--execute``.
|
||||
|
||||
The migration script finds all Sieve script directories in
|
||||
``/var/sieve/`` (or any other ``mailserver.sieveDirectory``), for
|
||||
example that of ``bob`` at ``/var/vmail/bob@example.com``.
|
||||
|
||||
It then takes ``bob@example.com`` and looks up the home directory
|
||||
for ``bob``. Finally, it starts suggesting the necessary move
|
||||
operations to migrate the Sieve directory to
|
||||
``/var/vmail/example.com/bob/sieve`` and symlinks the active script
|
||||
to ``/var/vmail/example.com/bob/.dovecot.sieve``.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
./nixos-mailserver-migration-05.py \
|
||||
/var/sieve
|
||||
|
||||
6. Review the script output.
|
||||
|
||||
The script can highlight various inconsistencies and problems, that should
|
||||
be reviewed and acted upon.
|
||||
|
||||
If in doubt, join our community chat for help before applying any changes.
|
||||
|
||||
7. Rerun the command with ``--execute`` or run the proposed commands manually.
|
||||
|
||||
8. Update the ``mailserver.stateVersion`` to ``5``.
|
||||
|
||||
9. The previous Sieve directory (``mailserver.sieveDirectory``) should now be safe to delete.
|
||||
|
||||
10. If you temporarily disabled :option:`mailserver.enableManageSieve` in step 1,
|
||||
re-enable it now by setting it back to ``true``.
|
||||
|
||||
.. _migration-4:
|
||||
|
||||
#4 Dovecot LDAP UUID-based home directories
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
LDAP Support in NixOS mailserver was introduced during the 23.11 release cycle
|
||||
and came with a number of flaws that we are correcting now, three years later.
|
||||
|
||||
This particular migration is needed because up until now we were
|
||||
relying on email addresses to construct the Dovecot home directory path
|
||||
(``var/vmail/ldap/user@example.com``) which is fragile: addresses can
|
||||
change, requiring manual homedir relocation. Switching to UUID-based homedirs
|
||||
(``/var/vmail/ldap/<uuid>``) ensures stable, unique paths and applies well-known
|
||||
best practices to mailserver management.
|
||||
|
||||
1. Copy the migration script to your mailserver and make it executable:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
cd /tmp
|
||||
wcurl https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/main/migrations/nixos-mailserver-migration-04.py
|
||||
chmod +x nixos-mailserver-migration-04.py
|
||||
|
||||
2. Stop the ``dovecot.service``.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
systemctl stop dovecot.service
|
||||
|
||||
3. Create a backup or snapshot of your :option:`mailserver.storage.path`, so
|
||||
you can restore should anything go wrong.
|
||||
|
||||
4. Run the migration script and pass the required arguments to enable LDAP lookups:
|
||||
|
||||
The script should be run under the user who owns the :option:`mailserver.storage.path`.
|
||||
If run as root it will automatically switch into the appropriate user by itself.
|
||||
|
||||
The script will not modify your data unless called with ``--execute``.
|
||||
|
||||
The migration script finds all Dovecot home directories in
|
||||
``/var/vmail/ldap/`` (or any other :option:`mailserver.storage.path`),
|
||||
for example that of bob at ``/var/vmail/ldap/bob@example.com``.
|
||||
It then takes ``bob@example.com`` and queries the LDAP server for
|
||||
``mail=bob@example.com`` to retrieve the UUID attribute. Finally
|
||||
it starts suggesting the necessary move operations to arrive at
|
||||
``/var/vmail/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092`` for bob.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
./nixos-mailserver-migration-04.py \
|
||||
--ldap-uri ldaps://ldap1.example.com
|
||||
--ldap-bind-dn cn=mail,ou=accounts,dc=example,dc=com \
|
||||
--ldap-bind-pw-file /run/keys/ldap-bind-pw \
|
||||
--ldap-base ou=people,ou=accounts,dc=example,dc=com \
|
||||
--ldap-scope sub \
|
||||
--ldap-filter "(mail=%s)" \
|
||||
--ldap-attr-uuid entryUUID \
|
||||
/var/vmail
|
||||
|
||||
For the ``--ldap-attr-uuid`` parameter we expect a long-term stable
|
||||
identifier, ideally a UUID field. The exact attribute name depends on your
|
||||
LDAP implementation, for example:
|
||||
|
||||
- Authentik: ``uid`` `[1]`_
|
||||
- Kanidm: ``uuid`` `[2]`_
|
||||
- Keycloak ``entryUUID``
|
||||
- OpenLDAP: ``entryUUID`` (`RFC4530`_)
|
||||
|
||||
If your LDAP provider isn't listed you can determine the correct
|
||||
attribute by querying a user entry with ``ldapsearch``. Finally, configure
|
||||
:option:`mailserver.ldap.attributes.uuid` accordingly.
|
||||
|
||||
Add ``--ldap-starttls`` if you use the the `ldap://` URI scheme and require
|
||||
explicit TLS.
|
||||
|
||||
.. _[1]: https://docs.goauthentik.io/add-secure-apps/providers/ldap#users
|
||||
.. _[2]: https://kanidm.github.io/kanidm/stable/integrations/ldap.html#data-mapping
|
||||
.. _RFC4530: https://www.rfc-editor.org/rfc/rfc4530.html
|
||||
|
||||
5. Review the script output.
|
||||
|
||||
It's primary job is to determine the UUID for an LDAP account, so that it
|
||||
can rename the Dovecot home directory from mail address to UUID within the
|
||||
same directory.
|
||||
|
||||
The script can highlight various inconsistencies and problems, that should
|
||||
be reviewed and acted upon.
|
||||
|
||||
If in doubt, join our community chat for help before applying any changes.
|
||||
|
||||
6. Rerun the command with ``--execute`` or run the proposed commands manually.
|
||||
|
||||
7. Update the ``mailserver.stateVersion`` to ``4``.
|
||||
|
||||
NixOS 25.11
|
||||
-----------
|
||||
|
||||
#3 Dovecot mail directory migration
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The way the Dovecot home directory for login accounts were previously set up
|
||||
resulted in shared home directories for all those users. This is not a
|
||||
supported Dovecot configuration.
|
||||
|
||||
To resolve this we migrated the home directory into the individual
|
||||
`domain/localpart` subdirectory below the `mailserver.mailDirectory`.
|
||||
|
||||
But since this now overlaps with the location of the Maildir, it must be
|
||||
migrated into the `mail/` directory below the home directory.
|
||||
And while the LDAP home directory is not affected we use this migration to
|
||||
keep the Maildir configurations of LDAP users in sync with those of local
|
||||
accounts.
|
||||
|
||||
This is a big step forward, since we can now more cleanly colocate other
|
||||
data directories, like sieve in the home directory, which in turn simplifies
|
||||
backups.
|
||||
|
||||
This migration is required for every configuration.
|
||||
|
||||
For remediating this issue the following steps are required:
|
||||
|
||||
1. Copy the `migration script <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/main/migrations/nixos-mailserver-migration-03.py>`_ script to your mailserver
|
||||
and make it executable:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
wcurl https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/main/migrations/nixos-mailserver-migration-03.py
|
||||
chmod +x nixos-mailserver-migration-03.py
|
||||
|
||||
2. Stop the ``dovecot.service``.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
systemctl stop dovecot.service
|
||||
|
||||
3. Create a backup or snapshot of your ``mailserver.mailDirectory``, so you can restore
|
||||
should anything go wrong.
|
||||
|
||||
4. Run the migration script under your virtual mail user with the following arguments:
|
||||
|
||||
- ``--layout default`` unless ``useFSLayout`` is enabled, then ``--layout folder``
|
||||
- The value of ``mailserver.mailDirectory``, which defaults to ``/var/vmail``
|
||||
|
||||
The script should be run under the user who owns the ``mailDirectory``.
|
||||
If run as root it will try to switch into the appropriate user by itself.
|
||||
|
||||
The script will not modify your data unless called with ``--execute``.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
./nixos-mailserver-migration-03.py --layout default /var/vmail
|
||||
|
||||
5. Review the commands. They should be
|
||||
|
||||
- create a ``mail`` directory for each account,
|
||||
- move maildir contents from the parent directory into it,
|
||||
- suggest removal of files that do not belong to the maildir
|
||||
|
||||
- their removal is not mandatory and the script **will not** remove them when called with ``--execute``
|
||||
- review these items carefully if you want to remove them yourself
|
||||
|
||||
- remove obsolete files from the old home directory location
|
||||
|
||||
6. Rerun the command with ``--execute`` or run the commands manually.
|
||||
|
||||
7. Update the ``mailserver.stateVersion`` to ``3``.
|
||||
|
||||
#2 Dovecot LDAP home directory migration
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The Dovecot configuration for LDAP home directories previously did not respect
|
||||
the ``mailserver.mailDirectory`` setting.
|
||||
|
||||
This means that home directories were unconditionally located at
|
||||
``/var/vmail/ldap/%{user}``.
|
||||
|
||||
This migration is required if you both:
|
||||
|
||||
* enabled the LDAP integration (``mailserver.ldap.enable``)
|
||||
* and customized the default mail directory (``mailserver.mailDirectory != "/var/vmail"``)
|
||||
|
||||
For remediating this issue the following steps are required:
|
||||
|
||||
1. Stop ``dovecot.service``.
|
||||
2. Move ``/var/vmail/ldap`` below your ``mailserver.mailDirectory``.
|
||||
3. Update the ``mailserver.stateVersion`` to ``2``.
|
||||
|
||||
#1 Initialization
|
||||
^^^^^^^^^^^^^^^^^
|
||||
|
||||
This option was introduced in the NixOS 25.11 release cycle, in which case you
|
||||
can safely initialize its value at `1`.
|
||||
|
||||
.. code-block:: nix
|
||||
|
||||
mailserver.stateVersion = 1;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
inherit (lib)
|
||||
concatStrings
|
||||
flip
|
||||
mapAttrsToList
|
||||
;
|
||||
|
||||
mailAccounts = config.mailserver.accounts;
|
||||
htpasswd = pkgs.writeText "radicale.users" (
|
||||
concatStrings (flip mapAttrsToList mailAccounts (mail: user: "${mail}+:${user.hashedPassword}\n"))
|
||||
);
|
||||
|
||||
in
|
||||
{
|
||||
services.radicale = {
|
||||
enable = true;
|
||||
settings = {
|
||||
auth = {
|
||||
type = "htpasswd";
|
||||
htpasswd_filename = "${htpasswd}";
|
||||
htpasswd_encryption = "bcrypt";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts = {
|
||||
"cal.example.com" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
locations."/" = {
|
||||
proxyPass = "http://localhost:5232/";
|
||||
extraConfig = ''
|
||||
proxy_set_header X-Script-Name /;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_pass_header Authorization;
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
80
|
||||
443
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
Radicale
|
||||
========
|
||||
|
||||
Radicale is a lightweight open-source CalDAV/CardDAV server that stores
|
||||
calendars and contacts as plain files on the filesystem, enabling simple
|
||||
self-hosted synchronization with standard clients.
|
||||
|
||||
Limitations
|
||||
^^^^^^^^^^^
|
||||
|
||||
Radicale since the 3.x release (introduced in NixOS 20.09) does not support
|
||||
traditional crypt() password hashes any longer. To establish access for
|
||||
existing :option:`mailserver.accounts`, the hashing method used
|
||||
for ``hashedPassword`` needs to be compatible with one of the available
|
||||
`htpasswd_encryption`_ methods. Such hashes can for example be created using
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
nix-shell -p mkpasswd --command "mkpasswd -m bcrypt"
|
||||
|
||||
.. _htpasswd_encryption: https://radicale.org/v3.html#htpasswd_encryption
|
||||
|
||||
Code
|
||||
^^^^
|
||||
|
||||
Configuration contributed by Robert Schütz (@dotlambda).
|
||||
|
||||
.. literalinclude:: ./radicale.nix
|
||||
:language: nix
|
||||
+179
-1
@@ -1,6 +1,185 @@
|
||||
Release Notes
|
||||
=============
|
||||
|
||||
NixOS 26.05
|
||||
-----------
|
||||
|
||||
Features
|
||||
^^^^^^^^
|
||||
|
||||
- :ref:`DKIM key management <dkim>` now supports multiple selectors per domain,
|
||||
enabling :ref:`key rotation <dkim-key-rotation>`. Pre-created key material is
|
||||
also supported. Existing automatically generated DKIM keys from before 25.11
|
||||
use 1024-bit RSA and should be rotated. See :option:`mailserver.dkim.domains`.
|
||||
|
||||
- Certificate handling was simplified. We recommend using the NixOS
|
||||
ACME module (``security.acme.certs``) and referencing a certificate
|
||||
configuration by name. Alternatively, certificate and private key can be
|
||||
managed manually. Configure either :option:`mailserver.x509.useACMEHost`
|
||||
or :option:`mailserver.x509.certificateFile` and
|
||||
:option:`mailserver.x509.privateKeyFile`. See the updated :ref:`setup guide
|
||||
<setup-guide>` for a basic ACME HTTP-01 example.
|
||||
|
||||
- Local mail accounts can now use managed cleartext passwords. This integrates
|
||||
well with secret management tools such as `agenix`_ and `sops-nix`_ while
|
||||
avoiding password leakage into the world-readable Nix store. See
|
||||
:option:`mailserver.accounts.<name>.passwordFile`.
|
||||
|
||||
- Blocked sender responses can now be customized. This is useful if you require GDPR
|
||||
compliance. See :option:`mailserver.rejectSenderMessage`.
|
||||
|
||||
Security
|
||||
^^^^^^^^
|
||||
|
||||
- TLSv1.2 cipher suites in Postfix now require `AEAD`_ and `ECDHE`_.
|
||||
|
||||
- Postfix and Dovecot now support negotiation of the ``SecP256r1MLKEM768``
|
||||
key agreement mechanism. The `standardization process
|
||||
<https://datatracker.ietf.org/doc/draft-ietf-tls-ecdhe-mlkem/>`__ is ongoing.
|
||||
|
||||
- Deprecated and obsolete TLS signature algorithms were removed from Postfix.
|
||||
|
||||
Sieve
|
||||
^^^^^
|
||||
|
||||
- **Migration**: When ManageSieve is enabled, user-created Sieve scripts must
|
||||
be migrated into their Dovecot home directory. See the :ref:`migration guide
|
||||
<migration-5>`.
|
||||
|
||||
LDAP
|
||||
^^^^
|
||||
|
||||
- **Migration**: Dovecot home directories for LDAP users must be migrated to
|
||||
UUID-based directory names. The UUID attribute can be customized through
|
||||
:option:`mailserver.ldap.attributes.uuid`. See the :ref:`migration guide
|
||||
<migration-4>`.
|
||||
|
||||
- The LDAP configuration has been revamped. Option names have been simplified,
|
||||
examples and documentation improved. The :ref:`LDAP documentation <ldap-top>`
|
||||
was written from the ground up.
|
||||
|
||||
- The default LDAP login attribute changed from ``mail`` to ``uid``.
|
||||
This allows users to login with their account name rather than
|
||||
their email address, which is more convenient and consistent with
|
||||
typical LDAP practices. The exact attribute can be customized through
|
||||
:option:`mailserver.ldap.attributes.username`.
|
||||
|
||||
- The LDAP bind password is now read verbatim without trimming whitespace. Any
|
||||
trailing newline is now preserved and may cause authentication failures.
|
||||
|
||||
- Local and LDAP accounts can now coexist. For overlapping accounts and addresses
|
||||
the local account will always win.
|
||||
|
||||
|
||||
Internals
|
||||
^^^^^^^^^
|
||||
|
||||
- Dovecot has been updated from 2.3 to 2.4 and now relies on the structured settings option.
|
||||
|
||||
Deprecations
|
||||
^^^^^^^^^^^^
|
||||
|
||||
The following integrations are deprecated and will be removed before the next
|
||||
release:
|
||||
|
||||
- :option:`mailserver.borgbackup.enable`
|
||||
- :option:`mailserver.backup.enable`
|
||||
- :option:`mailserver.monitoring.enable`
|
||||
|
||||
.. _key rotation: dkim.html#dkim-key-rotation
|
||||
.. _agenix: https://github.com/ryantm/agenix
|
||||
.. _sops-nix: https://github.com/Mic92/sops-nix
|
||||
.. _AEAD: https://en.wikipedia.org/wiki/Authenticated_encryption
|
||||
.. _ECDHE: https://www.rfc-editor.org/rfc/rfc8422
|
||||
|
||||
NixOS 25.11
|
||||
-----------
|
||||
|
||||
- The ``systemName`` and ``systemDomain`` options have been introduced to have
|
||||
reusable configurations for automated reports (DMARC, TLSRPT). They come with
|
||||
reasonable defaults, but it is suggested to check and change them as needed.
|
||||
- Support for the `Sender Rewriting Scheme`_ has been added, which allows
|
||||
forwarding mail without breaking SPF by rewriting the envelope address.
|
||||
- The default key length for new DKIM RSA keys was increased to 2048 bits as
|
||||
recommended in `RFC 8301 3.2`_.
|
||||
We recommend rotating existing keys, as the RFC advises that signatures from
|
||||
1024 bit keys should not be considered valid any longer.
|
||||
- IMAP access over port ``143/tcp`` is now default disabled in line
|
||||
with `RFC 8314 4.1`_. Use IMAP over implicit TLS on port ``993/tcp``
|
||||
instead. If you still require this feature you can re-enable it using
|
||||
``mailserver.enableImap``, but it is scheduled for removal after the 25.11
|
||||
release.
|
||||
- SMTP server and client now support and prefer a hybrid key exchange
|
||||
(X25519MLKEM768)
|
||||
- SMTP access over STARTTLS on port ``587/tcp`` is now default disabled in line
|
||||
with `RFC 8314 3.3`_. If you still require this feature you can re-enable it
|
||||
using ``mailserver.enableSubmission``.
|
||||
- DMARC reports are now sent with the ``noreply-dmarc`` localpart from the
|
||||
system domain.
|
||||
- DANE and MTA-STS are now validated for outgoing SMTP connections using
|
||||
`postfix-tlspol`_.
|
||||
- SMTP TLS connection reports (`RFC 8460`_) are now supported using
|
||||
`tlsrpt-reporter`_. They can be enabled with the ``mailserver.tlsrpt.enable``
|
||||
option.
|
||||
|
||||
.. _Sender Rewriting Scheme: srs.html
|
||||
.. _RFC 8301 3.2: https://www.rfc-editor.org/rfc/rfc8301#section-3.2
|
||||
.. _RFC 8314 3.3: https://www.rfc-editor.org/rfc/rfc8314#section-3.3
|
||||
.. _RFC 8314 4.1: https://www.rfc-editor.org/rfc/rfc8314#section-4.1
|
||||
.. _RFC 8460: https://www.rfc-editor.org/rfc/rfc8460
|
||||
.. _postfix-tlspol: https://github.com/Zuplu/postfix-tlspol
|
||||
.. _tlsrpt-reporter: https://github.com/sys4/tlsrpt-reporter
|
||||
|
||||
NixOS 25.05
|
||||
-----------
|
||||
|
||||
- OpenDKIM has been removed and DKIM signing is now handled by Rspamd, which only supports ``relaxed`` canoncalizaliaton.
|
||||
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/374>`__)
|
||||
- Rspamd now connects to Redis over its Unix Domain Socket by default
|
||||
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/375>`__)
|
||||
|
||||
- If you need to revert TCP connections, configure ``mailserver.redis.address`` to reference the value of ``config.services.redis.servers.rspamd.bind``.
|
||||
- The integration with policyd-spf was removed and SPF handling is now fully based on Rspamd scoring.
|
||||
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/380>`__)
|
||||
- Switch to the more efficient `fts-flatcurve` indexer for full text search
|
||||
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/361>`__).
|
||||
|
||||
This makes use of a new index, which will be automatically re-generated the
|
||||
next time a folder is searched.
|
||||
The operation is now quick enough to be performed "just-in-time".
|
||||
Alternatively, all indices can be immediately re-generated for all users and
|
||||
folders by running
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
doveadm fts rescan -u '*' && doveadm index -u '*' -q '*'
|
||||
|
||||
The previous index (which is not automatically discarded to allow rollbacks)
|
||||
can be cleaned up by removing all the `xapian-indexes` directories within
|
||||
``mailserver.indexDir``.
|
||||
- Individual domains can now be excluded from DMARC Reporting through ``mailserver.dmarcReporting.excludedDomains``.
|
||||
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/297>`__)
|
||||
- Configuring ``mailserver.forwards`` is now possible when the setup relies on LDAP.
|
||||
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/313>`__)
|
||||
- Support for TLS 1.1 was disabled in accordance with `Mozilla's recommendations <https://ssl-config.mozilla.org/#server=postfix>`_.
|
||||
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/234>`__)
|
||||
|
||||
NixOS 24.11
|
||||
-----------
|
||||
|
||||
- No new feature, only bug fixes and documentation improvements
|
||||
|
||||
NixOS 24.05
|
||||
-----------
|
||||
|
||||
- Add new option ``acmeCertificateName`` which can be used to support
|
||||
wildcard certificates
|
||||
|
||||
NixOS 23.11
|
||||
-----------
|
||||
|
||||
- Add basic support for LDAP users
|
||||
- Add support for regex (PCRE) aliases
|
||||
|
||||
NixOS 23.05
|
||||
-----------
|
||||
@@ -34,7 +213,6 @@ NixOS 21.11
|
||||
- New option ``certificateDomains`` to generate certificate for
|
||||
additional domains (such as ``imap.example.com``)
|
||||
|
||||
|
||||
NixOS 21.05
|
||||
-----------
|
||||
|
||||
|
||||
@@ -2,3 +2,4 @@ sphinx ~= 5.3
|
||||
sphinx_rtd_theme ~= 1.1
|
||||
myst-parser ~= 0.18
|
||||
linkify-it-py ~= 2.0
|
||||
standard-imghdr
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{ config, ... }:
|
||||
{
|
||||
services.nginx.virtualHosts.${config.services.roundcube.hostName} = {
|
||||
forceSSL = false;
|
||||
enableACME = false;
|
||||
listen = [
|
||||
{
|
||||
addr = "127.0.0.1";
|
||||
port = 8000;
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
services.caddy.virtualHosts."${config.services.roundcube.hostName}".extraConfig = ''
|
||||
reverse_proxy localhost:8000
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
{
|
||||
services.roundcube = {
|
||||
enable = true;
|
||||
hostName = "webmail.example.com"; # the nginx vhost
|
||||
package = pkgs.roundcube.withPlugins (
|
||||
plugins: with plugins; [
|
||||
# external plugins to be included
|
||||
# https://search.nixos.org/packages?query=roundcubePlugins
|
||||
persistent_login
|
||||
]
|
||||
);
|
||||
# activate plugins
|
||||
plugins = [
|
||||
"persistent_login"
|
||||
"managesieve" # built-in
|
||||
];
|
||||
dicts = with pkgs.aspellDicts; [
|
||||
# https://search.nixos.org/packages?query=aspellDicts
|
||||
en
|
||||
];
|
||||
maxAttachmentSize = config.mailserver.messageSizeLimit / 1024 / 1024;
|
||||
extraConfig = ''
|
||||
$config['imap_host'] = "ssl://${config.mailserver.fqdn}";
|
||||
$config['smtp_host'] = "ssl://${config.mailserver.fqdn}";
|
||||
$config['smtp_user'] = "%u";
|
||||
$config['smtp_pass'] = "%p";
|
||||
|
||||
$config['managesieve_host'] = "tls://${config.mailserver.fqdn}";
|
||||
$config['managesieve_port'] = 4190;
|
||||
$config['managesieve_usetls'] = true;
|
||||
'';
|
||||
};
|
||||
|
||||
services.nginx.virtualHosts.${config.services.roundcube.hostName} = {
|
||||
enableACME = true;
|
||||
forceSSL = true;
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
80
|
||||
443
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
Roundcube
|
||||
=========
|
||||
|
||||
Roundcube is a browser-based open-source webmail client that provides a
|
||||
full-featured email interface with support for IMAP, SMTP, address books, and
|
||||
extensible plugins.
|
||||
|
||||
Code
|
||||
^^^^
|
||||
|
||||
The NixOS module for Roundcube integrates almost immediately with NixOS
|
||||
mailserver, automatically configuring an Nginx virtual host and ACME-managed
|
||||
TLS for secure webmail access; using other web servers may require additional
|
||||
manual setup.
|
||||
|
||||
Once set up you can login with your login account credentials.
|
||||
|
||||
.. literalinclude:: ./roundcube.nix
|
||||
:language: nix
|
||||
|
||||
To use a different reverse proxy, such as Caddy, bind Roundcube's Nginx virtual
|
||||
host to ``127.0.0.1`` on a custom port and disable SSL and ACME, as the reverse
|
||||
proxy will handle those.
|
||||
|
||||
.. literalinclude:: ./roundcube-caddy.nix
|
||||
:language: nix
|
||||
+8
-10
@@ -24,17 +24,14 @@ You can run the training in a root shell as follows:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
# Path to the controller socket
|
||||
export RSOCK="/var/run/rspamd/worker-controller.sock"
|
||||
|
||||
# Learn the Junk folder as spam
|
||||
rspamc -h $RSOCK learn_spam /var/vmail/$DOMAIN/$USER/.Junk/cur/
|
||||
rspamc learn_spam /var/vmail/$DOMAIN/$USER/.Junk/cur/
|
||||
|
||||
# Learn the INBOX as ham
|
||||
rspamc -h $RSOCK learn_ham /var/vmail/$DOMAIN/$USER/cur/
|
||||
rspamc learn_ham /var/vmail/$DOMAIN/$USER/cur/
|
||||
|
||||
# Check that training was successful
|
||||
rspamc -h $RSOCK stat | grep learned
|
||||
rspamc stat | grep learned
|
||||
|
||||
Tune symbol weight
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
@@ -47,9 +44,10 @@ details the meaning of each symbol. You can tune the weight if a symbol if neede
|
||||
|
||||
services.rspamd.locals = {
|
||||
"groups.conf".text = ''
|
||||
symbols {
|
||||
"FORGED_RECIPIENTS" { weight = 0; }
|
||||
}'';
|
||||
symbols "FORGED_RECIPIENTS" {
|
||||
weight = 0;
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
Tune action thresholds
|
||||
@@ -96,7 +94,7 @@ With an nginx reverse-proxy
|
||||
|
||||
If you have a secured nginx reverse proxy set on the host, you can use it to expose the socket.
|
||||
**Keep in mind the UI is unsecured by default, you need to setup an authentication scheme**, for
|
||||
exemple with `basic auth <https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/>`_:
|
||||
example with `basic auth <https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/>`_:
|
||||
|
||||
.. code:: nix
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
config,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(builtins.fetchTarball {
|
||||
# This is a quick and dirty way to import a NixOS mailserver release. What
|
||||
# you should do long-term is use a proper dependency pinning tool like npins
|
||||
# or flakes.
|
||||
|
||||
# URL to the tarball for the release matching your NixOS release
|
||||
url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/nixos-26.05/nixos-mailserver-nixos-26.05.tar.gz";
|
||||
|
||||
# Hash of the unpacked tarball, run the following command to retrieve it
|
||||
# release="nixos-26.05" nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/${release}/nixos-mailserver-${release}.tar.gz" --unpack
|
||||
sha256 = "0000000000000000000000000000000000000000000000000000";
|
||||
})
|
||||
];
|
||||
|
||||
# https://letsencrypt.org/repository/#let-s-encrypt-subscriber-agreement
|
||||
security.acme.acceptTerms = true;
|
||||
|
||||
# Allow incoming HTTP connections
|
||||
networking.firewall.allowedTCPPorts = [ 80 ];
|
||||
|
||||
# Enable ACME HTTP-01 challenge with nginx
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts.${config.mailserver.fqdn}.enableACME = true;
|
||||
};
|
||||
|
||||
mailserver = {
|
||||
enable = true;
|
||||
stateVersion = 5;
|
||||
fqdn = "mail.example.com";
|
||||
domains = [ "example.com" ];
|
||||
|
||||
# Reference the existing ACME configuration created by nginx
|
||||
x509.useACMEHost = config.mailserver.fqdn;
|
||||
|
||||
# A list of all login accounts. To create the password hashes, use
|
||||
# nix-shell -p mkpasswd --run 'mkpasswd -s'
|
||||
accounts = {
|
||||
"user1@example.com" = {
|
||||
# Reads the password hash from a file on the server
|
||||
hashedPasswordFile = "/a/file/containing/a/hashed/password";
|
||||
|
||||
# Additional addresses delivered to this mailbox
|
||||
aliases = [ "postmaster@example.com" ];
|
||||
};
|
||||
"user2@example.com" = {
|
||||
# Provides the password hash inline
|
||||
hashedPassword = "$y$j9T$JqqefR6flaaJBRjD4KVZc1$QM6h4Spr5.yn/FuIT.ydTV22daEbiVd8ZprV/POtPgB";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
+235
-150
@@ -1,3 +1,5 @@
|
||||
.. _setup-guide:
|
||||
|
||||
Setup Guide
|
||||
===========
|
||||
|
||||
@@ -5,223 +7,306 @@ Mail servers can be a tricky thing to set up. This guide is supposed to
|
||||
run you through the most important steps to achieve a 10/10 score on
|
||||
`<https://mail-tester.com>`_.
|
||||
|
||||
What you need is:
|
||||
Requirements
|
||||
~~~~~~~~~~~~
|
||||
|
||||
- a server running NixOS with a public IP
|
||||
- a domain name.
|
||||
To set up a self-hosted mail server, you need the following:
|
||||
|
||||
* Small (e.g. 1C/2G) server running NixOS
|
||||
* Stable IPv4 and - strongly recommended - IPv6 addresses
|
||||
|
||||
* Ability to configure a Reverse DNS (PTR record) for your IP addresses
|
||||
* Access to SMTP traffic on port 25/tcp - some hosters make you ask for this
|
||||
|
||||
* A registered domain name with DNS record management access
|
||||
|
||||
Once these requirements are in place, you can begin setting up your selfhosted
|
||||
mailserver.
|
||||
|
||||
.. note::
|
||||
|
||||
In the following, we consider a server with the public IP ``1.2.3.4``
|
||||
and the domain ``example.com``.
|
||||
Below we'll assume that your server got assigned the public IP addresses
|
||||
``192.0.2.1`` (IPv4) and ``2001:db8::1`` (IPv6) and that you control the
|
||||
``example.com`` domain.
|
||||
|
||||
First, we will set the minimum DNS configuration to be able to deploy
|
||||
an up and running mail server. Once the server is deployed, we could
|
||||
then set all DNS entries required to send and receive mails on this
|
||||
server.
|
||||
|
||||
Setup DNS A record for server
|
||||
Configure forward DNS records
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Add a DNS record to the domain ``example.com`` with the following
|
||||
entries
|
||||
Here we set up ``mail.example.com`` as the forward hostname for your mail server
|
||||
to point to the IP addresses allocated to the server. This allows reaching
|
||||
the server under this name and to reference it later in MX records for mail
|
||||
delivery.
|
||||
|
||||
==================== ===== ==== =============
|
||||
Name (Subdomain) TTL Type Value
|
||||
==================== ===== ==== =============
|
||||
``mail.example.com`` 10800 A ``1.2.3.4``
|
||||
==================== ===== ==== =============
|
||||
Now edit the ``example.com`` zone and create the following DNS records:
|
||||
|
||||
You can check this with
|
||||
.. csv-table::
|
||||
:header: "Name", "TTL", "Type", "Value"
|
||||
:widths: 30, 10, 10, 50
|
||||
|
||||
::
|
||||
mail.example.com., 3600, A, 192.0.2.1
|
||||
mail.example.com., 3600, AAAA, 2001:db8::1
|
||||
|
||||
$ ping mail.example.com
|
||||
64 bytes from mail.example.com (1.2.3.4): icmp_seq=1 ttl=46 time=21.3 ms
|
||||
...
|
||||
.. note::
|
||||
If your server does not have an IPv6 address, you must skip the ``AAAA``
|
||||
record.
|
||||
|
||||
Note that it can take a while until a DNS entry is propagated. This
|
||||
DNS entry is required for the Let's Encrypt certificate generation
|
||||
(which is used in the below configuration example).
|
||||
Verify DNS record propagation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Before we continue with the next step, we require that the forward DNS record
|
||||
has propagated. For that it's best to check an authoritative nameserver for
|
||||
``example.com`` so that we don't look at cached DNS records.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
# Find the authoritative nameservers for example.com
|
||||
$ nix-shell -p dig --command "dig NS example.com +short"
|
||||
ns1.example.org.
|
||||
ns2.example.org.
|
||||
|
||||
# Query the A record from an authoritative nameserver
|
||||
$ nix-shell -p dig --command "dig @ns1.example.org A mail.example.com +short"
|
||||
192.0.2.1
|
||||
|
||||
# Query the AAAA record from an authoritative nameserver
|
||||
$ nix-shell -p dig --command "dig @ns1.example.org AAAA mail.example.com +short"
|
||||
2001:db8::1
|
||||
|
||||
|
||||
DNS propagation usually takes a few minutes, so you might need to retry these
|
||||
queries. Once the IP addresses appear you can continue with the next step.
|
||||
|
||||
Setup the server
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
The following describes a server setup that is fairly complete. Even
|
||||
though there are more possible options (see the `NixOS Mailserver
|
||||
options documentation <options.html>`_), these should be the most
|
||||
common ones.
|
||||
The following configuration describes a fairly complete mail server, capable
|
||||
of sending and receiving mail for statically configured accounts. It includes
|
||||
encrypted SMTP and IMAP services for secure delivery and retrieval, and relies
|
||||
on ACME HTTP-01 to automatically obtain and maintain a TLS certificate.
|
||||
|
||||
.. code:: nix
|
||||
While `more options`_ are available, the configuration below covers the most
|
||||
common settings to get your mail server up and running.
|
||||
|
||||
{ config, pkgs, ... }: {
|
||||
imports = [
|
||||
(builtins.fetchTarball {
|
||||
# Pick a release version you are interested in and set its hash, e.g.
|
||||
url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/nixos-23.05/nixos-mailserver-nixos-23.05.tar.gz";
|
||||
# To get the sha256 of the nixos-mailserver tarball, we can use the nix-prefetch-url command:
|
||||
# release="nixos-23.05"; nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/${release}/nixos-mailserver-${release}.tar.gz" --unpack
|
||||
sha256 = "0000000000000000000000000000000000000000000000000000";
|
||||
})
|
||||
];
|
||||
.. _more options: options.html
|
||||
|
||||
mailserver = {
|
||||
enable = true;
|
||||
fqdn = "mail.example.com";
|
||||
domains = [ "example.com" ];
|
||||
.. literalinclude:: ./setup-example.nix
|
||||
:language: nix
|
||||
|
||||
# A list of all login accounts. To create the password hashes, use
|
||||
# nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt'
|
||||
loginAccounts = {
|
||||
"user1@example.com" = {
|
||||
hashedPasswordFile = "/a/file/containing/a/hashed/password";
|
||||
aliases = ["postmaster@example.com"];
|
||||
};
|
||||
"user2@example.com" = { ... };
|
||||
};
|
||||
After a ``nixos-rebuild switch`` your server should be running all the necessary
|
||||
mail services.
|
||||
|
||||
# Use Let's Encrypt certificates. Note that this needs to set up a stripped
|
||||
# down nginx and opens port 80.
|
||||
certificateScheme = "acme-nginx";
|
||||
};
|
||||
security.acme.acceptTerms = true;
|
||||
security.acme.defaults.email = "security@example.com";
|
||||
}
|
||||
Configure DNS records
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
After a ``nixos-rebuild switch`` your server should be running all
|
||||
mail components.
|
||||
Reverse DNS
|
||||
^^^^^^^^^^^
|
||||
|
||||
Setup all other DNS requirements
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Earlier, we configured forward DNS from your hostname to your IP address. Now we
|
||||
will configure reverse DNS so that your IP address points back to your hostname.
|
||||
|
||||
Set rDNS (reverse DNS) entry for server
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
If your forward and reverse DNS do not match, many mail servers will reject or
|
||||
flag your emails as spam, severely impairing delivery.
|
||||
|
||||
Wherever you have rented your server, you should be able to set reverse
|
||||
DNS entries for the IP’s you own. Add an entry resolving ``1.2.3.4``
|
||||
to ``mail.example.com``.
|
||||
Your server provider should allow you to configure reverse DNS (PTR record)
|
||||
records for the IP addresses you control, typically through their control panel
|
||||
or account management interface:
|
||||
|
||||
- Configure ``192.0.2.1`` to point to ``mail.example.com.``
|
||||
- Configure ``2001:db8::1`` to point to ``mail.example.com.``, if you have IPv6
|
||||
addressing
|
||||
|
||||
Alternatively, if you interact with a proper DNS setup, the actual DNS records
|
||||
look like this:
|
||||
|
||||
.. csv-table::
|
||||
:header: "Name", "TTL", "Type", "Value"
|
||||
:widths: 30, 10, 10, 50
|
||||
|
||||
1.2.0.192.in-addr.arpa., 86400, PTR, mail.example.com.
|
||||
1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa., 86400, PTR, mail.example.com.
|
||||
|
||||
.. note::
|
||||
Reverse DNS uses reverse notation for naming its records:
|
||||
|
||||
* IPv4 reverses the order of the octets and appends ``in-addr.arpa.``, so
|
||||
``192.0.2.1`` becomes ``1.2.0.192.in-addr.arpa.``
|
||||
* IPv6 fully expands the address and reverses each hex digit before
|
||||
concatenating it with dots and appending ``ip6.arpa.``
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
nix-shell -p haskellPackages.ip6addr --command "ip6addr --ptr 2001:db8::1"
|
||||
1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.IP6.ARPA.
|
||||
|
||||
.. warning::
|
||||
|
||||
We don't recommend setting up a mail server if you are not able to
|
||||
set a reverse DNS on your public IP because sent emails would be
|
||||
mostly marked as spam. Note that many residential ISP providers
|
||||
don't allow you to set a reverse DNS entry.
|
||||
We don't recommend setting up a mail server if you are unable to configure
|
||||
reverse DNS on your public IP addresses because mails would inevitable be
|
||||
marked as spam. Note that many residential ISP providers don't allow you to
|
||||
set a reverse DNS entry and prohibit sending mail through policy blocklists
|
||||
like Spamhaus PBL.
|
||||
|
||||
You can check this with
|
||||
DNS propagation often isn't instant, so verify before continuing:
|
||||
|
||||
::
|
||||
.. code-block:: console
|
||||
|
||||
$ nix-shell -p bind --command "host 1.2.3.4"
|
||||
4.3.2.1.in-addr.arpa domain name pointer mail.example.com.
|
||||
$ nix-shell -p dig --command "dig -x 192.0.2.1 +short"
|
||||
mail.example.com.
|
||||
|
||||
Note that it can take a while until a DNS entry is propagated.
|
||||
|
||||
Set a ``MX`` record
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
$ nix-shell -p dig --command "dig -x 2001:db8::1 +short"
|
||||
mail.example.com.
|
||||
|
||||
|
||||
Add a ``MX`` record to the domain ``example.com``.
|
||||
MX record
|
||||
^^^^^^^^^
|
||||
|
||||
================ ==== ======== =================
|
||||
Name (Subdomain) Type Priority Value
|
||||
================ ==== ======== =================
|
||||
example.com MX 10 mail.example.com
|
||||
================ ==== ======== =================
|
||||
The MX record instructs other mailservers where to deliver mail for a domain
|
||||
name.
|
||||
|
||||
You can check this with
|
||||
Create the MX record for ``example.com`` to point to the hostname of the server.
|
||||
|
||||
::
|
||||
.. csv-table::
|
||||
:header: "Name", "TTL", "Priority", "Type", "Value"
|
||||
:widths: 30, 10, 10, 10, 50
|
||||
|
||||
$ nix-shell -p bind --command "host -t mx example.com"
|
||||
example.com mail is handled by 10 mail.example.com.
|
||||
example.com., 3600, MX, 10, mail.example.com.
|
||||
|
||||
Note that it can take a while until a DNS entry is propagated.
|
||||
The priority field determines the order when multiple servers are configured.
|
||||
It is not important in this scenario but setting a value is mandatory and 10
|
||||
leaves some wiggle room below and above, should you ever need that.
|
||||
|
||||
Set a ``SPF`` record
|
||||
^^^^^^^^^^^^^^^^^^^^
|
||||
.. code-block:: console
|
||||
|
||||
Add a `SPF <https://en.wikipedia.org/wiki/Sender_Policy_Framework>`_
|
||||
record to the domain ``example.com``.
|
||||
$ nix-shell -p dig --command "dig @ns1.example.org MX example.com +short"
|
||||
10 mail.example.com.
|
||||
|
||||
================ ===== ==== ================================
|
||||
Name (Subdomain) TTL Type Value
|
||||
================ ===== ==== ================================
|
||||
example.com 10800 TXT `v=spf1 a:mail.example.com -all`
|
||||
================ ===== ==== ================================
|
||||
SPF record
|
||||
^^^^^^^^^^
|
||||
|
||||
You can check this with
|
||||
With `SPF`_ we can specify which mail servers are authorized to send mail on
|
||||
behalf of a domain name.
|
||||
|
||||
::
|
||||
.. _SPF: https://en.wikipedia.org/wiki/Sender_Policy_Framework
|
||||
|
||||
$ nix-shell -p bind --command "host -t TXT example.com"
|
||||
example.com descriptive text "v=spf1 a:mail.example.com -all"
|
||||
The SPF record is TXT record and we can tie it in with the MX record we created
|
||||
in the previous step. Finishing with ``-all`` indicates that without any match
|
||||
the mail should be rejected.
|
||||
|
||||
Note that it can take a while until a DNS entry is propagated.
|
||||
.. csv-table::
|
||||
:header: "Name", "TTL", "Type", "Value"
|
||||
:widths: 30, 10, 10, 50
|
||||
|
||||
Set ``DKIM`` signature
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
example.com., 86400, TXT, v=spf1 mx -all
|
||||
|
||||
On your server, the ``opendkim`` systemd service generated a file
|
||||
containing your DKIM public key in the file
|
||||
``/var/dkim/example.com.mail.txt``. The content of this file looks
|
||||
like
|
||||
.. code-block:: console
|
||||
|
||||
::
|
||||
$ nix-shell -p dig --command "dig TXT example.com +short"
|
||||
v=spf1 mx -all
|
||||
|
||||
mail._domainkey IN TXT "v=DKIM1; k=rsa; s=email; p=<really-long-key>" ; ----- DKIM mail for domain.tld
|
||||
|
||||
where ``really-long-key`` is your public key.
|
||||
DKIM record
|
||||
^^^^^^^^^^^
|
||||
|
||||
Based on the content of this file, we can add a ``DKIM`` record to the
|
||||
domain ``example.com``.
|
||||
On system activation a `DKIM`_ keypair for ``example.com`` was generated. The
|
||||
mail server uses this key to sign outgoing emails, allowing receiving servers to
|
||||
verify the authenticity of the sender domain and ensuring that the signed parts
|
||||
of the message have not been tampered with.
|
||||
|
||||
=========================== ===== ==== ==============================
|
||||
Name (Subdomain) TTL Type Value
|
||||
=========================== ===== ==== ==============================
|
||||
mail._domainkey.example.com 10800 TXT ``v=DKIM1; p=<really-long-key>``
|
||||
=========================== ===== ==== ==============================
|
||||
.. _DKIM: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail
|
||||
|
||||
You can check this with
|
||||
Now, check ``/var/dkim/example.com.mail.txt``, which contains the proposed DNS
|
||||
record for the ``mail`` DKIM selector.
|
||||
|
||||
::
|
||||
.. code-block:: none
|
||||
|
||||
$ nix-shell -p bind --command "host -t txt mail._domainkey.example.com"
|
||||
mail._domainkey.example.com descriptive text "v=DKIM1;p=<really-long-key>"
|
||||
mail._domainkey IN TXT ( "v=DKIM1; k=rsa; "
|
||||
"p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv7hSess/UgEjaaq/NDn5KtW2iZzYljhf45DH3tN/kqcJ04JJk/Z1rS7CMJQ/pYZSSnQOju0H25uOtODvhqXPDxDdtCyDSrx54z/38lGNtA76/iWy/ikjb9hEkb2k3HuKex3P4KhhOC1pytDEFnh/T2aBxPNOigc/cpqm1U9RbnAwvArtx9dgOAgiV8rOIgPgyrPw1B3cJG3hgFYU2"
|
||||
"GwXMoiFQPgwm7bkjelmThqXozA7jFJfnYt49jjrIYCv8X/nQx9cNpVAv2852mhU/3uuy6sa4MPjT6RiK9BJCMyDnqSpTPCjIubL4VhGCuzp7RPBkayWnlaH0X8PWGq6BQ0eBwIDAQAB"
|
||||
) ;
|
||||
|
||||
Note that it can take a while until a DNS entry is propagated.
|
||||
Based on the content of this file, we can create the DKIM TXT record for the
|
||||
``mail`` selector in the ``example.com`` zone. For the ``p=`` value, glue the
|
||||
two long strings back together without any quotes and spaces and put them into
|
||||
your record below.
|
||||
|
||||
Set a ``DMARC`` record
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
.. csv-table::
|
||||
:header: "Name", "TTL", "Type", "Value"
|
||||
:widths: 30, 10, 10, 50
|
||||
|
||||
Add a ``DMARC`` record to the domain ``example.com``.
|
||||
mail._domainkey.example.com., 86400, TXT, v=DKIM1; k=rsa; p=MIIBIjANBgk...Q0eBwIDAQAB
|
||||
|
||||
======================== ===== ==== ====================
|
||||
Name (Subdomain) TTL Type Value
|
||||
======================== ===== ==== ====================
|
||||
_dmarc.example.com 10800 TXT ``v=DMARC1; p=none``
|
||||
======================== ===== ==== ====================
|
||||
.. code-block:: console
|
||||
|
||||
You can check this with
|
||||
$ nix-shell -p dig --command "dig @ns1.example.org TXT mail._domainkey.example.com +short"
|
||||
"v=DKIM1; k=rsa; p=MIIBIjANBgk...Q0eBwIDAQAB"
|
||||
|
||||
::
|
||||
|
||||
$ nix-shell -p bind --command "host -t TXT _dmarc.example.com"
|
||||
_dmarc.example.com descriptive text "v=DMARC1; p=none"
|
||||
DMARC record
|
||||
^^^^^^^^^^^^
|
||||
|
||||
Note that it can take a while until a DNS entry is propagated.
|
||||
Finally, DMARC lets you define a policy for how strictly SPF and DKIM should be
|
||||
checked and how to handle validation failures. For a new server, it’s important
|
||||
to have a DMARC record in place, even if it doesn’t enforce any actions yet,
|
||||
because it improves deliverability by showing receiving servers that your domain
|
||||
is properly managed and reducing the risk of email spoofing.
|
||||
|
||||
.. csv-table::
|
||||
:header: "Name", "TTL", "Type", "Value"
|
||||
:widths: 30, 10, 10, 50
|
||||
|
||||
_dmarc.example.com., 86400, TXT, v=DMARC1; p=none;
|
||||
|
||||
Verify propagation one final time.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ nix-shell -p dig --command "dig @ns1.example.org TXT _dmarc.example.com +short"
|
||||
"v=DMARC1; p=none"
|
||||
|
||||
|
||||
Test your Setup
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
Write an email to your aunt (who has been waiting for your reply far too
|
||||
long), and sign up for some of the finest newsletters the Internet has.
|
||||
Maybe you want to sign up for the `SNM Announcement
|
||||
List <https://www.freelists.org/list/snm>`__?
|
||||
Write an email to your aunt — she’s been waiting far too long for your reply,
|
||||
and this is your chance to finally make her day. Or, if you prefer a less
|
||||
emotional test, send a message to `mail-tester.com`_ to see how your outgoing
|
||||
mail scores.
|
||||
|
||||
Besides that, you can send an email to
|
||||
`mail-tester.com <https://www.mail-tester.com/>`__ and see how you
|
||||
score, and let `mxtoolbox.com <http://mxtoolbox.com/>`__ take a look at
|
||||
your setup, but if you followed the steps closely then everything should
|
||||
be awesome!
|
||||
You can also let `MXToolbox`_ take a peek at your setup. If you followed the
|
||||
steps carefully, everything should be working perfectly!
|
||||
|
||||
.. _mail-tester.com: https://mail-tester.com/
|
||||
.. _MXToolbox: https://mxtoolbox.com/
|
||||
|
||||
|
||||
Join the community
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The community has a lively chat room on Matrix at `#nixos-mailserver:nixos.org`_
|
||||
where you can ask questions, get help, share ideas, or discuss contributions.
|
||||
|
||||
.. _#nixos-mailserver:nixos.org: https://matrix.to/#/#nixos-mailserver:nixos.org
|
||||
|
||||
Next steps
|
||||
~~~~~~~~~~
|
||||
|
||||
Your server scored perfect results already, so these steps are entirely
|
||||
optional.
|
||||
|
||||
Are you feeling adventurous? Dive into our `advanced configurations`_ to explore
|
||||
additional features and capabilities that let you fine-tune and extend your
|
||||
mail setup.
|
||||
|
||||
If you want to take things even further, more elaborate testing services can
|
||||
give you a clearer picture of your mail service and suggest ways to improve
|
||||
it.
|
||||
|
||||
- `internet.nl`_ (supported by the Dutch Government)
|
||||
- `MECSA`_ (supported by the European Commission)
|
||||
|
||||
Finally, you can also browse the full list of `options`_ provided by NixOS mailserver.
|
||||
|
||||
.. _advanced configurations: advanced-configurations.html
|
||||
.. _options: options.html
|
||||
.. _internet.nl: https://internet.nl/test-mail/
|
||||
.. _MECSA: https://mecsa.jrc.ec.europa.eu/
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
Sender Rewriting Scheme
|
||||
=======================
|
||||
|
||||
The Sender Rewriting Scheme (SRS) allows mail servers to forward emails without
|
||||
breaking SPF checks. By rewriting the envelope sender to an address within the
|
||||
forwarder’s domain, SRS ensures that forwarded messages pass SPF validation,
|
||||
preventing them from being rejected as spoofed or unauthorized.
|
||||
|
||||
How SRS works in practice
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
1. ``alice@foo.example`` receives an E-Mail from ``bob@bar.example``. Both the
|
||||
envelope sender as well as the ``From`` header show ``bob@bar.example``. This
|
||||
results in strict SPF alignment, because ``bar.example`` is the domain used in
|
||||
both the ``Return-Path`` and ``FROM`` headers.
|
||||
|
||||
2. ``alice@foo.example`` forwards the mail to ``charlie@moo.example`` and
|
||||
uses SRS to rewrite the envelope sender to originate from the local SRS domain
|
||||
(e.g. `SRS0=HHH=TT=bar.example=alice@foo.example`). The ``FROM`` header remains
|
||||
unchanged. This ensures that the forwarded mail succeeds SPF checks.
|
||||
|
||||
3. The email reaches ``charlie@moo.example``. SPF passes because the sender
|
||||
domain in the envelope has been rewritten. The mismatch between envelope sender
|
||||
domain and ``FROM`` domain does however break strict SPF alignment.
|
||||
|
||||
Enabling SRS
|
||||
~~~~~~~~~~~~
|
||||
|
||||
In a simple setup just enabling SRS will use your ``mailserver.systemDomain``
|
||||
when rewriting the envelope sender domain.
|
||||
|
||||
.. code:: nix
|
||||
|
||||
{
|
||||
mailserver = {
|
||||
srs = {
|
||||
enable = true;
|
||||
#domain = "srs.example.com";
|
||||
};
|
||||
};
|
||||
};
|
||||
..
|
||||
|
||||
While you can reuse an existing email domain for SRS, it is recommended to
|
||||
configure a dedicated SRS domain. This is particularly important under the
|
||||
following conditions:
|
||||
|
||||
* Multiple unrelated mail domains are hosted on the mailserver
|
||||
* The mail domain requires strict SPF alignment in its DMARC policy
|
||||
|
||||
Required DNS changes
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. note::
|
||||
In the following example we assume that you want to set up a dedicated SRS
|
||||
domain. If that is not the case you already have SPF and DKIM set up for the
|
||||
system domain. If you have a DMARC record on the system domain, make sure it
|
||||
uses a relaxed SPF alignment policy (``aspf=r``).
|
||||
|
||||
First we set up an MX record. This is so that we can receive and route bounces
|
||||
that can result from forwards.
|
||||
|
||||
======================== ===== ==== ======== =====================
|
||||
Name (Subdomain) TTL Type Priority Value
|
||||
======================== ===== ==== ======== =====================
|
||||
srs.example.com 10800 MX 10 ``mail.example.com``
|
||||
======================== ===== ==== ==============================
|
||||
|
||||
Next up is the SPF record on the SRS domain to allow SPF authentication.
|
||||
|
||||
======================== ===== ==== ===================
|
||||
Name (Subdomain) TTL Type Value
|
||||
======================== ===== ==== ===================
|
||||
srs.example.com 10800 TXT ``v=spf1 mx -all``
|
||||
======================== ===== ==== ===================
|
||||
|
||||
Then we deploy the DKIM record with the `p=<value>` taken from
|
||||
``/var/dkim/srs.example.com.mail.txt``, that appears after deploying with SRS
|
||||
enabled.
|
||||
|
||||
=============================== ===== ==== ========================================
|
||||
Name (Subdomain) TTL Type Value
|
||||
=============================== ===== ==== ========================================
|
||||
mail._domainkey.srs.example.com 10800 TXT ``v=DKIM1; k=rsa; p=<really-long-key>``
|
||||
=============================== ===== ==== ========================================
|
||||
|
||||
Finally we can tie this together in the DMARC record to require receivers to
|
||||
verify the requested SPF/DKIM alignment.
|
||||
|
||||
.. note::
|
||||
|
||||
The SRS domain can only support relaxed SPF alignment due to the envelope
|
||||
sender and ``FROM`` header mismatch.
|
||||
|
||||
======================== ===== ==== =========================================
|
||||
Name (Subdomain) TTL Type Value
|
||||
======================== ===== ==== =========================================
|
||||
_dmarc.srs.example.com 10800 TXT ``v=DMARC1; p=reject; aspf=r; adkim=s;``
|
||||
======================== ===== ==== =========================================
|
||||
|
||||
We can safely configure a ``reject`` policy on the SRS domain, to enforce the
|
||||
SPF and DKIM alignment as configured above.
|
||||
Generated
+56
-57
@@ -19,87 +19,86 @@
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1668681692,
|
||||
"narHash": "sha256-Ht91NGdewz8IQLtWZ9LCeNXMSXHUss+9COoqu6JLmXU=",
|
||||
"owner": "edolstra",
|
||||
"lastModified": 1767039857,
|
||||
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"rev": "009399224d5e398d03b22badca40a37ac85412a1",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": [
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778507602,
|
||||
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1670751203,
|
||||
"narHash": "sha256-XdoH1v3shKDGlrwjgrNX/EN8s3c+kQV7xY6cLCE8vcI=",
|
||||
"lastModified": 1779622335,
|
||||
"narHash": "sha256-ViA62qtL5za7V3d5I8OA9q9JcFhsVAiL5jVHwEclWqk=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "64e0bf055f9d25928c31fb12924e59ff8ce71e60",
|
||||
"rev": "705e9929918b43bd7b715dc0a878ac870449bb03",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"ref": "nixos-unstable",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"nixpkgs-22_11": {
|
||||
"locked": {
|
||||
"lastModified": 1669558522,
|
||||
"narHash": "sha256-yqxn+wOiPqe6cxzOo4leeJOp1bXE/fjPEi/3F/bBHv8=",
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-26.05-small",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ce5fe99df1f15a09a91a86be9738d68fadfbad82",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"ref": "nixos-22.11",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"nixpkgs-23_05": {
|
||||
"locked": {
|
||||
"lastModified": 1684782344,
|
||||
"narHash": "sha256-SHN8hPYYSX0thDrMLMWPWYulK3YFgASOrCsIL3AJ78g=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "8966c43feba2c701ed624302b6a935f97bcbdf88",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"ref": "nixos-23.05",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"blobs": "blobs",
|
||||
"flake-compat": "flake-compat",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nixpkgs-22_11": "nixpkgs-22_11",
|
||||
"nixpkgs-23_05": "nixpkgs-23_05",
|
||||
"utils": "utils"
|
||||
}
|
||||
},
|
||||
"utils": {
|
||||
"locked": {
|
||||
"lastModified": 1605370193,
|
||||
"narHash": "sha256-YyMTf3URDL/otKdKgtoMChu4vfVL3vCMkRqpGifhUn0=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "5021eac20303a61fafe17224c087f5519baed54d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
"git-hooks": "git-hooks",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,126 +3,220 @@
|
||||
|
||||
inputs = {
|
||||
flake-compat = {
|
||||
url = "github:edolstra/flake-compat";
|
||||
# for shell.nix compat
|
||||
url = "github:NixOS/flake-compat";
|
||||
flake = false;
|
||||
};
|
||||
utils.url = "github:numtide/flake-utils";
|
||||
nixpkgs.url = "flake:nixpkgs/nixos-unstable";
|
||||
nixpkgs-22_11.url = "flake:nixpkgs/nixos-22.11";
|
||||
nixpkgs-23_05.url = "flake:nixpkgs/nixos-23.05";
|
||||
git-hooks = {
|
||||
url = "github:cachix/git-hooks.nix";
|
||||
inputs.flake-compat.follows = "flake-compat";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05-small";
|
||||
blobs = {
|
||||
url = "gitlab:simple-nixos-mailserver/blobs";
|
||||
flake = false;
|
||||
};
|
||||
};
|
||||
|
||||
outputs = { self, utils, blobs, nixpkgs, nixpkgs-22_11, nixpkgs-23_05, ... }: let
|
||||
lib = nixpkgs.lib;
|
||||
system = "x86_64-linux";
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
releases = [
|
||||
{
|
||||
name = "unstable";
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
}
|
||||
{
|
||||
name = "23.05";
|
||||
pkgs = nixpkgs-23_05.legacyPackages.${system};
|
||||
}
|
||||
];
|
||||
testNames = [
|
||||
"internal"
|
||||
"external"
|
||||
"clamav"
|
||||
"multiple"
|
||||
"ldap"
|
||||
];
|
||||
genTest = testName: release: {
|
||||
"name"= "${testName}-${builtins.replaceStrings ["."] ["_"] release.name}";
|
||||
"value"= import (./tests/. + "/${testName}.nix") {
|
||||
pkgs = release.pkgs;
|
||||
inherit blobs;
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
blobs,
|
||||
git-hooks,
|
||||
nixpkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
lib = nixpkgs.lib;
|
||||
system = "x86_64-linux";
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
releases = [
|
||||
{
|
||||
name = "unstable";
|
||||
nixpkgs = nixpkgs;
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
}
|
||||
];
|
||||
testNames = [
|
||||
"clamav"
|
||||
"external"
|
||||
"internal"
|
||||
"ldap"
|
||||
"multiple"
|
||||
];
|
||||
|
||||
genTest =
|
||||
testName: release:
|
||||
let
|
||||
pkgs = release.pkgs;
|
||||
nixos-lib = import (release.nixpkgs + "/nixos/lib") {
|
||||
inherit (pkgs) lib;
|
||||
};
|
||||
in
|
||||
{
|
||||
name = "${testName}-${builtins.replaceStrings [ "." ] [ "_" ] release.name}";
|
||||
value = nixos-lib.runTest {
|
||||
hostPkgs = pkgs;
|
||||
imports = [ ./tests/${testName}.nix ];
|
||||
_module.args = { inherit blobs; };
|
||||
extraBaseModules.imports = [ ./default.nix ];
|
||||
};
|
||||
};
|
||||
|
||||
# Generate an attribute set such as
|
||||
# {
|
||||
# external-unstable = <derivation>;
|
||||
# external-21_05 = <derivation>;
|
||||
# ...
|
||||
# }
|
||||
allTests = lib.listToAttrs (lib.flatten (map (t: map (r: genTest t r) releases) testNames));
|
||||
|
||||
mailserverModule = import ./.;
|
||||
|
||||
# Generate a MarkDown file describing the options of the NixOS mailserver module
|
||||
optionsDoc =
|
||||
let
|
||||
eval = lib.evalModules {
|
||||
modules = [
|
||||
mailserverModule
|
||||
{
|
||||
_module.check = false;
|
||||
mailserver = {
|
||||
fqdn = "mx.example.com";
|
||||
systemDomain = "example.com";
|
||||
domains = [
|
||||
"example.com"
|
||||
];
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
options = builtins.toFile "options.json" (
|
||||
builtins.toJSON (
|
||||
lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver") (
|
||||
lib.optionAttrSetToDocList eval.options
|
||||
)
|
||||
)
|
||||
);
|
||||
in
|
||||
pkgs.runCommand "options.md" { buildInputs = [ pkgs.python3Minimal ]; } ''
|
||||
echo "Generating options.md from ${options}"
|
||||
python ${./scripts/generate-options.py} ${options} > $out
|
||||
echo $out
|
||||
'';
|
||||
|
||||
documentation = pkgs.stdenv.mkDerivation {
|
||||
name = "documentation";
|
||||
src = lib.sourceByRegex ./docs [
|
||||
"logo\\.png"
|
||||
"conf\\.py"
|
||||
"Makefile"
|
||||
".*\\.nix"
|
||||
".*\\.rst"
|
||||
];
|
||||
buildInputs = [
|
||||
(pkgs.python3.withPackages (
|
||||
p: with p; [
|
||||
sphinx
|
||||
sphinx-rtd-theme
|
||||
myst-parser
|
||||
linkify-it-py
|
||||
]
|
||||
))
|
||||
];
|
||||
buildPhase = ''
|
||||
cp ${optionsDoc} options.md
|
||||
# Workaround for https://github.com/sphinx-doc/sphinx/issues/3451
|
||||
unset SOURCE_DATE_EPOCH
|
||||
make html
|
||||
'';
|
||||
installPhase = ''
|
||||
cp -Tr _build/html $out
|
||||
'';
|
||||
};
|
||||
};
|
||||
# Generate an attribute set such as
|
||||
# {
|
||||
# external-unstable = <derivation>;
|
||||
# external-21_05 = <derivation>;
|
||||
# ...
|
||||
# }
|
||||
allTests = lib.listToAttrs (
|
||||
lib.flatten (map (t: map (r: genTest t r) releases) testNames));
|
||||
|
||||
mailserverModule = import ./.;
|
||||
|
||||
# Generate a MarkDown file describing the options of the NixOS mailserver module
|
||||
optionsDoc = let
|
||||
eval = lib.evalModules {
|
||||
modules = [
|
||||
mailserverModule
|
||||
{
|
||||
_module.check = false;
|
||||
mailserver = {
|
||||
fqdn = "mx.example.com";
|
||||
domains = [
|
||||
"example.com"
|
||||
];
|
||||
dmarcReporting = {
|
||||
organizationName = "Example Corp";
|
||||
domain = "example.com";
|
||||
in
|
||||
{
|
||||
nixosModules = rec {
|
||||
mailserver = mailserverModule;
|
||||
default = mailserver;
|
||||
};
|
||||
nixosModule = self.nixosModules.default; # compatibility
|
||||
hydraJobs.${system} = allTests // {
|
||||
inherit documentation;
|
||||
inherit (self.checks.${system}) pre-commit;
|
||||
};
|
||||
checks.${system} = allTests // {
|
||||
pre-commit = git-hooks.lib.${system}.run {
|
||||
src = ./.;
|
||||
package = pkgs.prek;
|
||||
hooks = {
|
||||
# docs
|
||||
markdownlint = {
|
||||
enable = true;
|
||||
settings.configuration = {
|
||||
# Max line length, doesn't seem to correctly account for lines containing links
|
||||
# https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md
|
||||
MD013 = false;
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
rstcheck = {
|
||||
enable = true;
|
||||
package = pkgs.rstcheckWithSphinx;
|
||||
entry = lib.getExe pkgs.rstcheckWithSphinx;
|
||||
files = "\\.rst$";
|
||||
};
|
||||
|
||||
# spell checking
|
||||
typos = {
|
||||
enable = true;
|
||||
settings.configPath = ".typos.toml";
|
||||
};
|
||||
|
||||
# nix
|
||||
deadnix.enable = true;
|
||||
nixfmt.enable = true;
|
||||
|
||||
# python
|
||||
pyright.enable = true;
|
||||
ruff = {
|
||||
enable = true;
|
||||
args = [
|
||||
"--extend-select"
|
||||
"I"
|
||||
];
|
||||
};
|
||||
ruff-format.enable = true;
|
||||
|
||||
# scripts
|
||||
shellcheck.enable = true;
|
||||
|
||||
# sieve
|
||||
check-sieve = {
|
||||
enable = true;
|
||||
package = pkgs.check-sieve;
|
||||
entry = lib.getExe pkgs.check-sieve;
|
||||
files = "\\.sieve$";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
options = builtins.toFile "options.json" (builtins.toJSON
|
||||
(lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver")
|
||||
(lib.optionAttrSetToDocList eval.options)));
|
||||
in pkgs.runCommand "options.md" { buildInputs = [pkgs.python3Minimal]; } ''
|
||||
echo "Generating options.md from ${options}"
|
||||
python ${./scripts/generate-options.py} ${options} > $out
|
||||
'';
|
||||
packages.${system} = {
|
||||
inherit optionsDoc documentation;
|
||||
};
|
||||
devShells.${system}.default = pkgs.mkShellNoCC {
|
||||
inputsFrom = [ documentation ];
|
||||
packages =
|
||||
with pkgs;
|
||||
[
|
||||
glab
|
||||
]
|
||||
++ self.checks.${system}.pre-commit.enabledPackages;
|
||||
shellHook = self.checks.${system}.pre-commit.shellHook;
|
||||
};
|
||||
devShell.${system} = self.devShells.${system}.default; # compatibility
|
||||
|
||||
documentation = pkgs.stdenv.mkDerivation {
|
||||
name = "documentation";
|
||||
src = lib.sourceByRegex ./docs ["logo\\.png" "conf\\.py" "Makefile" ".*\\.rst"];
|
||||
buildInputs = [(
|
||||
pkgs.python3.withPackages (p: with p; [
|
||||
sphinx
|
||||
sphinx_rtd_theme
|
||||
myst-parser
|
||||
])
|
||||
)];
|
||||
buildPhase = ''
|
||||
cp ${optionsDoc} options.md
|
||||
# Workaround for https://github.com/sphinx-doc/sphinx/issues/3451
|
||||
unset SOURCE_DATE_EPOCH
|
||||
make html
|
||||
'';
|
||||
installPhase = ''
|
||||
cp -Tr _build/html $out
|
||||
'';
|
||||
formatter.${system} = pkgs.nixfmt-tree;
|
||||
};
|
||||
|
||||
in {
|
||||
nixosModules = rec {
|
||||
mailserver = mailserverModule;
|
||||
default = mailserver;
|
||||
};
|
||||
nixosModule = self.nixosModules.default; # compatibility
|
||||
hydraJobs.${system} = allTests // {
|
||||
inherit documentation;
|
||||
};
|
||||
checks.${system} = allTests;
|
||||
packages.${system} = {
|
||||
inherit optionsDoc documentation;
|
||||
};
|
||||
devShells.${system}.default = pkgs.mkShell {
|
||||
inputsFrom = [ documentation ];
|
||||
packages = with pkgs; [
|
||||
clamav
|
||||
];
|
||||
};
|
||||
devShell.${system} = self.devShells.${system}.default; # compatibility
|
||||
};
|
||||
}
|
||||
|
||||
+144
-15
@@ -1,17 +1,146 @@
|
||||
{ config, lib, pkgs, ... }:
|
||||
{
|
||||
assertions = lib.optionals config.mailserver.ldap.enable [
|
||||
{
|
||||
assertion = config.mailserver.loginAccounts == {};
|
||||
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.loginAccounts";
|
||||
}
|
||||
{
|
||||
assertion = config.mailserver.extraVirtualAliases == {};
|
||||
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.extraVirtualAliases";
|
||||
}
|
||||
{
|
||||
assertion = config.mailserver.forwards == {};
|
||||
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.forwards";
|
||||
}
|
||||
];
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
mailserverRelease = "26.05";
|
||||
nixpkgsRelease = lib.trivial.release;
|
||||
releaseMismatch =
|
||||
config.mailserver.enableNixpkgsReleaseCheck && mailserverRelease != nixpkgsRelease;
|
||||
in
|
||||
|
||||
{
|
||||
warnings =
|
||||
lib.optionals releaseMismatch [
|
||||
''
|
||||
You are using
|
||||
|
||||
NixOS Mailserver version ${mailserverRelease} and
|
||||
Nixpkgs version ${nixpkgsRelease}.
|
||||
|
||||
Using mismatched versions is likely to cause compatibility issues
|
||||
and may require migrations that make an eventual rollback tricky.
|
||||
|
||||
It is therefore highly recommended to use a release of
|
||||
NixOS mailserver that corresponds with your chosen release of Nixpkgs.
|
||||
|
||||
If you insist then you can disable this warning by adding
|
||||
|
||||
mailserver.enableNixpkgsReleaseCheck = false;
|
||||
|
||||
to your configuration.
|
||||
''
|
||||
]
|
||||
++ lib.optionals config.mailserver.borgbackup.enable [
|
||||
''
|
||||
`mailserver.borgbackup` will be removed after 26.05.
|
||||
|
||||
The borgbackup integration will be removed with the recommendation to
|
||||
migrate to the upstream `services.borgbackup` module, which receives far
|
||||
superior maintenance and testing.
|
||||
|
||||
NixOS manual: https://nixos.org/manual/nixos/stable/#module-borgbase
|
||||
''
|
||||
]
|
||||
++ lib.optionals config.mailserver.backup.enable [
|
||||
''
|
||||
`mailserver.backup` will be removed after 26.05.
|
||||
|
||||
The rsnapshot integration will be removed due to lack of maintenance,
|
||||
expertise and tests to make sure it still works. Please use the upstream
|
||||
module directly instead.
|
||||
''
|
||||
]
|
||||
++ lib.optionals config.mailserver.monitoring.enable [
|
||||
''
|
||||
`mailserver.monitoring` will be removed after 26.05.
|
||||
|
||||
The monit integration will be removed due to lack of maintenance,
|
||||
expertise and tests to make sure it still works.
|
||||
''
|
||||
];
|
||||
|
||||
# We guard all assertions by requiring mailserver to be actually enabled
|
||||
assertions = lib.optionals config.mailserver.enable (
|
||||
[
|
||||
{
|
||||
assertion = config.mailserver.stateVersion != null;
|
||||
message = "The `mailserver.stateVersion` option is not set. Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html to determine the proper value to initialize it at.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
config.mailserver.x509.useACMEHost != null
|
||||
-> config.mailserver.x509.certificateFile == null && config.mailserver.x509.privateKeyFile == null;
|
||||
message = "Configuring an ACME certificate (`mailserver.x509.useACMEHost`) is not possible while also passing an existing certificate (`mailserver.x509.certificateFile`, `mailserver.x509.privateKeyFile`).";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
config.mailserver.x509.useACMEHost != null
|
||||
|| (
|
||||
config.mailserver.x509.certificateFile != null && config.mailserver.x509.privateKeyFile != null
|
||||
);
|
||||
message = "Configure either an ACME certificate (`mailserver.x509.useACMEHost`) or pass an existing certificate (`mailserver.x509.certificateFile`, `mailserver.x509.privateKeyFile`).";
|
||||
}
|
||||
]
|
||||
++ lib.optionals config.mailserver.dkim.enable (
|
||||
lib.flatten (
|
||||
lib.mapAttrsToList (
|
||||
domain: domainAttrs:
|
||||
lib.mapAttrsToList (selector: selectorAttrs: [
|
||||
{
|
||||
assertion =
|
||||
selectorAttrs.keyFile != null -> (selectorAttrs.keyType == null && selectorAttrs.keyLength == null);
|
||||
message = "${domain} DKIM selector ${selector} can only use either `keyType`, `keyLength` OR `keyFile` not both.";
|
||||
}
|
||||
]) domainAttrs.selectors
|
||||
) config.mailserver.dkim.domains
|
||||
)
|
||||
)
|
||||
++ lib.optionals (config.mailserver.ldap.enable && config.mailserver.storage.path != "/var/vmail") [
|
||||
{
|
||||
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 2;
|
||||
message = ''
|
||||
Issue: The dovecot homedir for LDAP users was previously not respecting `mailserver.storage.path`.
|
||||
Remediation:
|
||||
- Stop the `dovecot.service`
|
||||
- Move `/var/vmail/ldap` below your `mailserver.storage.path`
|
||||
- Increase the `stateVersion` to 2.
|
||||
|
||||
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-home-directory-migration for more information.
|
||||
'';
|
||||
}
|
||||
]
|
||||
++ [
|
||||
{
|
||||
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 3;
|
||||
message = ''
|
||||
Issue: The dovecot mail location for all users has changed and need to be migrated.
|
||||
|
||||
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-mail-directory-migration for the required remediation steps.
|
||||
'';
|
||||
}
|
||||
]
|
||||
++ lib.optionals (config.mailserver.ldap.enable) [
|
||||
{
|
||||
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 4;
|
||||
message = ''
|
||||
NixOS Mailserver requires migrating LDAP home directories to UUID scheme
|
||||
|
||||
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-uuid-based-home-directories for required migration steps.
|
||||
'';
|
||||
}
|
||||
]
|
||||
++ lib.optionals (config.mailserver.enableManageSieve) [
|
||||
{
|
||||
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 5;
|
||||
message = ''
|
||||
NixOS Mailserver requires moving the Sieve script directories into Dovecot home directories.
|
||||
|
||||
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#sieve-script-directory-migration for required migration steps.
|
||||
'';
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
+32
-16
@@ -14,28 +14,43 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.mailserver.borgbackup;
|
||||
|
||||
methodFragment = lib.optional (cfg.compression.method != null) cfg.compression.method;
|
||||
autoFragment =
|
||||
if cfg.compression.auto && cfg.compression.method == null
|
||||
then throw "compression.method must be set when using auto."
|
||||
else lib.optional cfg.compression.auto "auto";
|
||||
if cfg.compression.auto && cfg.compression.method == null then
|
||||
throw "compression.method must be set when using auto."
|
||||
else
|
||||
lib.optional cfg.compression.auto "auto";
|
||||
levelFragment =
|
||||
if cfg.compression.level != null && cfg.compression.method == null
|
||||
then throw "compression.method must be set when using compression.level."
|
||||
else lib.optional (cfg.compression.level != null) (toString cfg.compression.level);
|
||||
compressionFragment = lib.concatStringsSep "," (lib.flatten [autoFragment methodFragment levelFragment]);
|
||||
if cfg.compression.level != null && cfg.compression.method == null then
|
||||
throw "compression.method must be set when using compression.level."
|
||||
else
|
||||
lib.optional (cfg.compression.level != null) (toString cfg.compression.level);
|
||||
compressionFragment = lib.concatStringsSep "," (
|
||||
lib.flatten [
|
||||
autoFragment
|
||||
methodFragment
|
||||
levelFragment
|
||||
]
|
||||
);
|
||||
compression = lib.optionalString (compressionFragment != "") "--compression ${compressionFragment}";
|
||||
|
||||
encryptionFragment = cfg.encryption.method;
|
||||
passphraseFile = lib.escapeShellArg cfg.encryption.passphraseFile;
|
||||
passphraseFragment = lib.optionalString (cfg.encryption.method != "none")
|
||||
(if cfg.encryption.passphraseFile != null then ''env BORG_PASSPHRASE="$(cat ${passphraseFile})"''
|
||||
else throw "passphraseFile must be set when using encryption.");
|
||||
passphraseFragment = lib.optionalString (cfg.encryption.method != "none") (
|
||||
if cfg.encryption.passphraseFile != null then
|
||||
''env BORG_PASSPHRASE="$(cat ${passphraseFile})"''
|
||||
else
|
||||
throw "passphraseFile must be set when using encryption."
|
||||
);
|
||||
|
||||
locations = lib.escapeShellArgs cfg.locations;
|
||||
name = lib.escapeShellArg cfg.name;
|
||||
@@ -51,14 +66,15 @@ let
|
||||
borgScript = ''
|
||||
export BORG_REPO=${repoLocation}
|
||||
${cmdPreexec}
|
||||
${passphraseFragment} ${pkgs.borgbackup}/bin/borg init ${extraInitArgs} --encryption ${encryptionFragment} || true
|
||||
${passphraseFragment} ${pkgs.borgbackup}/bin/borg create ${extraCreateArgs} ${compression} ::${name} ${locations}
|
||||
${passphraseFragment} ${lib.getExe' config.services.borgbackup.package "borg"} init ${extraInitArgs} --encryption ${encryptionFragment} || true
|
||||
${passphraseFragment} ${lib.getExe' config.services.borgbackup.package "borg"} create ${extraCreateArgs} ${compression} ::${name} ${locations}
|
||||
${cmdPostexec}
|
||||
'';
|
||||
in {
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (config.mailserver.enable && cfg.enable) {
|
||||
environment.systemPackages = with pkgs; [
|
||||
borgbackup
|
||||
environment.systemPackages = [
|
||||
config.services.borgbackup.package
|
||||
];
|
||||
|
||||
systemd.services.borgbackup = {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ config, pkgs, lib, options, ... }:
|
||||
{ config, lib, ... }:
|
||||
|
||||
let
|
||||
cfg = config.mailserver;
|
||||
|
||||
+62
-40
@@ -14,56 +14,78 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ config, pkgs, lib }:
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.mailserver;
|
||||
in
|
||||
{
|
||||
# cert :: PATH
|
||||
certificatePath = if cfg.certificateScheme == "manual"
|
||||
then cfg.certificateFile
|
||||
else if cfg.certificateScheme == "selfsigned"
|
||||
then "${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem"
|
||||
else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx"
|
||||
then "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem"
|
||||
else throw "unknown certificate scheme";
|
||||
rec {
|
||||
withACME = cfg.x509.useACMEHost != null;
|
||||
|
||||
# key :: PATH
|
||||
keyPath = if cfg.certificateScheme == "manual"
|
||||
then cfg.keyFile
|
||||
else if cfg.certificateScheme == "selfsigned"
|
||||
then "${cfg.certificateDirectory}/key-${cfg.fqdn}.pem"
|
||||
else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx"
|
||||
then "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem"
|
||||
else throw "unknown certificate scheme";
|
||||
x509CertificateFile =
|
||||
if withACME then
|
||||
"${config.security.acme.certs.${cfg.x509.useACMEHost}.directory}/fullchain.pem"
|
||||
else
|
||||
cfg.x509.certificateFile;
|
||||
|
||||
passwordFiles = let
|
||||
mkHashFile = name: hash: pkgs.writeText "${builtins.hashString "sha256" name}-password-hash" hash;
|
||||
in
|
||||
lib.mapAttrs (name: value:
|
||||
if value.hashedPasswordFile == null then
|
||||
builtins.toString (mkHashFile name value.hashedPassword)
|
||||
else value.hashedPasswordFile) cfg.loginAccounts;
|
||||
x509PrivateKeyFile =
|
||||
if withACME then
|
||||
"${config.security.acme.certs.${cfg.x509.useACMEHost}.directory}/key.pem"
|
||||
else
|
||||
cfg.x509.privateKeyFile;
|
||||
|
||||
passwordFiles =
|
||||
let
|
||||
mkHashFile = name: hash: pkgs.writeText "${builtins.hashString "sha256" name}-password-hash" hash;
|
||||
in
|
||||
lib.mapAttrs (
|
||||
name: value:
|
||||
if value.hashedPasswordFile != null then
|
||||
value.hashedPasswordFile
|
||||
else if value.hashedPassword != null then
|
||||
builtins.toString (mkHashFile name value.hashedPassword)
|
||||
else
|
||||
value.passwordFile
|
||||
) cfg.accounts;
|
||||
|
||||
# Collect accounts with plain text passwords that require hashing
|
||||
accountsWithPlaintextPasswordFiles = lib.filter (name: cfg.accounts.${name}.passwordFile != null) (
|
||||
builtins.attrNames cfg.accounts
|
||||
);
|
||||
|
||||
# Appends the LDAP bind password to files to avoid writing this
|
||||
# password into the Nix store.
|
||||
appendLdapBindPwd = {
|
||||
name, file, prefix, passwordFile, destination
|
||||
}: pkgs.writeScript "append-ldap-bind-pwd-in-${name}" ''
|
||||
#!${pkgs.stdenv.shell}
|
||||
set -euo pipefail
|
||||
appendLdapBindPwd =
|
||||
{
|
||||
name,
|
||||
file,
|
||||
prefix,
|
||||
suffix ? "",
|
||||
passwordFile,
|
||||
destination,
|
||||
}:
|
||||
pkgs.writeScript "append-ldap-bind-pwd-in-${name}"
|
||||
# bash
|
||||
''
|
||||
#!${pkgs.stdenv.shell}
|
||||
set -euo pipefail
|
||||
|
||||
baseDir=$(dirname ${destination})
|
||||
if (! test -d "$baseDir"); then
|
||||
mkdir -p $baseDir
|
||||
chmod 755 $baseDir
|
||||
fi
|
||||
baseDir=$(dirname ${destination})
|
||||
if (! test -d "$baseDir"); then
|
||||
mkdir -p $baseDir
|
||||
chmod 755 $baseDir
|
||||
fi
|
||||
|
||||
cat ${file} > ${destination}
|
||||
echo -n "${prefix}" >> ${destination}
|
||||
cat ${passwordFile} >> ${destination}
|
||||
chmod 600 ${destination}
|
||||
'';
|
||||
cat ${file} > ${destination}
|
||||
echo -n '${prefix}' >> ${destination}
|
||||
cat ${passwordFile} | tr -d '\n' >> ${destination}
|
||||
echo -n '${suffix}' >> ${destination}
|
||||
chmod 600 ${destination}
|
||||
'';
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
mailserver.policydSPFExtraConfig = lib.mkIf config.mailserver.debug "debugLevel = 4";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
imports = [
|
||||
./assertions.nix
|
||||
./borgbackup.nix
|
||||
./rsnapshot.nix
|
||||
./clamav.nix
|
||||
./monit.nix
|
||||
./users.nix
|
||||
./environment.nix
|
||||
./networking.nix
|
||||
./systemd.nix
|
||||
./dovecot.nix
|
||||
./postfix.nix
|
||||
./rspamd.nix
|
||||
./kresd.nix
|
||||
];
|
||||
}
|
||||
+487
-331
@@ -14,383 +14,539 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
config,
|
||||
options,
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
|
||||
with (import ./common.nix { inherit config pkgs lib; });
|
||||
with (import ./common.nix {
|
||||
inherit
|
||||
config
|
||||
options
|
||||
pkgs
|
||||
lib
|
||||
;
|
||||
});
|
||||
|
||||
let
|
||||
inherit (lib)
|
||||
attrNames
|
||||
concatMapStringsSep
|
||||
filterAttrs
|
||||
mapAttrs'
|
||||
mkForce
|
||||
mkIf
|
||||
mkMerge
|
||||
nameValuePair
|
||||
;
|
||||
|
||||
cfg = config.mailserver;
|
||||
|
||||
passwdDir = "/run/dovecot2";
|
||||
passwdFile = "${passwdDir}/passwd";
|
||||
userdbFile = "${passwdDir}/userdb";
|
||||
# This file contains the ldap bind password
|
||||
ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext";
|
||||
bool2int = x: if x then "1" else "0";
|
||||
|
||||
maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs";
|
||||
maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8";
|
||||
genPasswdScript =
|
||||
pkgs.writeScript "generate-password-file"
|
||||
# bash
|
||||
''
|
||||
#!${pkgs.stdenv.shell}
|
||||
|
||||
# maildir in format "/${domain}/${user}"
|
||||
dovecotMaildir =
|
||||
"maildir:${cfg.mailDirectory}/%d/%n${maildirLayoutAppendix}${maildirUTF8FolderNames}"
|
||||
+ (lib.optionalString (cfg.indexDir != null)
|
||||
":INDEX=${cfg.indexDir}/%d/%n"
|
||||
);
|
||||
set -euo pipefail
|
||||
|
||||
postfixCfg = config.services.postfix;
|
||||
dovecot2Cfg = config.services.dovecot2;
|
||||
if (! test -d "${passwdDir}"); then
|
||||
mkdir "${passwdDir}"
|
||||
chmod 755 "${passwdDir}"
|
||||
fi
|
||||
|
||||
stateDir = "/var/lib/dovecot";
|
||||
# Prevent world-readable password files, even temporarily.
|
||||
umask 077
|
||||
|
||||
pipeBin = pkgs.stdenv.mkDerivation {
|
||||
name = "pipe_bin";
|
||||
src = ./dovecot/pipe_bin;
|
||||
buildInputs = with pkgs; [ makeWrapper coreutils bash rspamd ];
|
||||
buildCommand = ''
|
||||
mkdir -p $out/pipe/bin
|
||||
cp $src/* $out/pipe/bin/
|
||||
chmod a+x $out/pipe/bin/*
|
||||
patchShebangs $out/pipe/bin
|
||||
prepend_scheme() {
|
||||
case "$1" in
|
||||
{*}*) printf '%s' "$1" ;;
|
||||
*) printf '{CRYPT}%s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
for file in $out/pipe/bin/*; do
|
||||
wrapProgram $file \
|
||||
--set PATH "${pkgs.coreutils}/bin:${pkgs.rspamd}/bin"
|
||||
done
|
||||
'';
|
||||
};
|
||||
for f in ${
|
||||
builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.accounts)
|
||||
}; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "Expected password hash file $f does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
cat <<EOF > ${passwdFile}
|
||||
${lib.concatStringsSep "\n" (
|
||||
lib.mapAttrsToList (
|
||||
name: _:
|
||||
if lib.elem name accountsWithPlaintextPasswordFiles then
|
||||
"${name}:${"$(sed -n '1{p;p;q}' ${passwordFiles."${name}"} | ${lib.getExe' config.services.dovecot2.package "doveadm"} pw)"}::::::"
|
||||
else
|
||||
"${name}:${"$(prepend_scheme \"$(head -n 1 ${passwordFiles."${name}"})\")"}::::::"
|
||||
) cfg.accounts
|
||||
)}
|
||||
EOF
|
||||
chown dovecot2:dovecot2 ${passwdFile}
|
||||
|
||||
ldapConfig = pkgs.writeTextFile {
|
||||
name = "dovecot-ldap.conf.ext.template";
|
||||
text = ''
|
||||
ldap_version = 3
|
||||
uris = ${lib.concatStringsSep " " cfg.ldap.uris}
|
||||
${lib.optionalString cfg.ldap.startTls ''
|
||||
tls = yes
|
||||
''}
|
||||
tls_require_cert = hard
|
||||
tls_ca_cert_file = ${cfg.ldap.tlsCAFile}
|
||||
dn = ${cfg.ldap.bind.dn}
|
||||
sasl_bind = no
|
||||
auth_bind = yes
|
||||
base = ${cfg.ldap.searchBase}
|
||||
scope = ${mkLdapSearchScope cfg.ldap.searchScope}
|
||||
${lib.optionalString (cfg.ldap.dovecot.userAttrs != "") ''
|
||||
user_attrs = ${cfg.ldap.dovecot.userAttrs}
|
||||
''}
|
||||
user_filter = ${cfg.ldap.dovecot.userFilter}
|
||||
${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") ''
|
||||
pass_attrs = ${cfg.ldap.dovecot.passAttrs}
|
||||
''}
|
||||
pass_filter = ${cfg.ldap.dovecot.passFilter}
|
||||
'';
|
||||
};
|
||||
cat <<EOF > ${userdbFile}
|
||||
${lib.concatStringsSep "\n" (
|
||||
lib.mapAttrsToList (
|
||||
name: value:
|
||||
# https://doc.dovecot.org/2.4.3/core/config/auth/databases/passwd_file.html
|
||||
# https://doc.dovecot.org/2.4.3/core/plugins/quota.html#per-user-quota
|
||||
# https://dovecot.org/mailman3/archives/list/dovecot@dovecot.org/thread/67DBLLW4L5QBTEYRKGA26POFZ52ZR7ZO/#67DBLLW4L5QBTEYRKGA26POFZ52ZR7ZO
|
||||
"${name}:::::::"
|
||||
+ lib.optionalString (value.quota != null) "userdb_quota/user/storage_size=${value.quota}"
|
||||
) cfg.accounts
|
||||
)}
|
||||
EOF
|
||||
chown dovecot2:dovecot2 ${userdbFile}
|
||||
'';
|
||||
|
||||
setPwdInLdapConfFile = appendLdapBindPwd {
|
||||
name = "ldap-conf-file";
|
||||
file = ldapConfig;
|
||||
prefix = "dnpass = ";
|
||||
passwordFile = cfg.ldap.bind.passwordFile;
|
||||
destination = ldapConfFile;
|
||||
};
|
||||
|
||||
genPasswdScript = pkgs.writeScript "generate-password-file" ''
|
||||
#!${pkgs.stdenv.shell}
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if (! test -d "${passwdDir}"); then
|
||||
mkdir "${passwdDir}"
|
||||
chmod 755 "${passwdDir}"
|
||||
fi
|
||||
|
||||
# Prevent world-readable password files, even temporarily.
|
||||
umask 077
|
||||
|
||||
for f in ${builtins.toString (lib.mapAttrsToList (name: value: passwordFiles."${name}") cfg.loginAccounts)}; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "Expected password hash file $f does not exist!"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
cat <<EOF > ${passwdFile}
|
||||
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value:
|
||||
"${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::"
|
||||
) cfg.loginAccounts)}
|
||||
EOF
|
||||
|
||||
cat <<EOF > ${userdbFile}
|
||||
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value:
|
||||
"${name}:::::::"
|
||||
+ (if lib.isString value.quota
|
||||
then "userdb_quota_rule=*:storage=${value.quota}"
|
||||
else "")
|
||||
) cfg.loginAccounts)}
|
||||
EOF
|
||||
'';
|
||||
|
||||
junkMailboxes = builtins.attrNames (lib.filterAttrs (n: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes);
|
||||
junkMailboxes = builtins.attrNames (
|
||||
lib.filterAttrs (_: v: v ? "special_use" && v.special_use == "\\Junk") cfg.mailboxes
|
||||
);
|
||||
junkMailboxNumber = builtins.length junkMailboxes;
|
||||
# The assertion garantees there is exactly one Junk mailbox.
|
||||
# The assertion guarantees there is exactly one Junk mailbox.
|
||||
junkMailboxName = if junkMailboxNumber == 1 then builtins.elemAt junkMailboxes 0 else "";
|
||||
|
||||
mkLdapSearchScope = scope: (
|
||||
if scope == "sub" then "subtree"
|
||||
else if scope == "one" then "onelevel"
|
||||
else scope
|
||||
);
|
||||
|
||||
mkLdapSearchScope =
|
||||
scope:
|
||||
(
|
||||
if scope == "sub" then
|
||||
"subtree"
|
||||
else if scope == "one" then
|
||||
"onelevel"
|
||||
else
|
||||
scope
|
||||
);
|
||||
in
|
||||
{
|
||||
config = with cfg; lib.mkIf enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = junkMailboxNumber == 1;
|
||||
message = "nixos-mailserver requires exactly one dovecot mailbox with the 'special use' flag set to 'Junk' (${builtins.toString junkMailboxNumber} have been found)";
|
||||
message = "nixos-mailserver requires exactly one dovecot mailbox with the 'special_use' flag set to '\\Junk' (${builtins.toString junkMailboxNumber} have been found)";
|
||||
}
|
||||
(
|
||||
let
|
||||
usersWithQuota = attrNames (
|
||||
filterAttrs (_: account: account.quota != null) config.mailserver.accounts
|
||||
);
|
||||
in
|
||||
{
|
||||
assertion = !cfg.quota.enable -> usersWithQuota == [ ];
|
||||
message = ''
|
||||
Without quota support enabled, per-user quotas cannot be applied to the following accounts:
|
||||
|
||||
${concatMapStringsSep "\n" (account: "- ${account}") usersWithQuota}
|
||||
|
||||
Either remove per user quota settings or re-enable `mailserver.quota.enable`.
|
||||
'';
|
||||
}
|
||||
)
|
||||
];
|
||||
|
||||
# for sieve-test. Shelling it in on demand usually doesnt' work, as it reads
|
||||
# the global config and tries to open shared libraries configured in there,
|
||||
# which are usually not compatible.
|
||||
warnings =
|
||||
lib.optional
|
||||
(
|
||||
(builtins.length cfg.fullTextSearch.languages > 1)
|
||||
&& (builtins.elem "stopwords" cfg.fullTextSearch.filters)
|
||||
)
|
||||
''
|
||||
Using stopwords in `mailserver.fullTextSearch.filters` with multiple
|
||||
languages in `mailserver.fullTextSearch.languages` configured WILL
|
||||
cause some searches to fail.
|
||||
|
||||
The recommended solution is to NOT use the stopword filter when
|
||||
multiple languages are present in the configuration.
|
||||
'';
|
||||
|
||||
security.acme.certs = lib.mkIf withACME {
|
||||
${cfg.x509.useACMEHost} = {
|
||||
reloadServices = [ "dovecot.service" ];
|
||||
};
|
||||
};
|
||||
|
||||
# Dovecot modules
|
||||
environment.systemPackages = [
|
||||
pkgs.dovecot_pigeonhole
|
||||
];
|
||||
|
||||
# For compatibility with python imaplib
|
||||
environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
|
||||
|
||||
services.dovecot2 = {
|
||||
enable = true;
|
||||
enableImap = enableImap || enableImapSsl;
|
||||
enablePop3 = enablePop3 || enablePop3Ssl;
|
||||
enablePAM = false;
|
||||
enableQuota = true;
|
||||
mailGroup = vmailGroupName;
|
||||
mailUser = vmailUserName;
|
||||
mailLocation = dovecotMaildir;
|
||||
sslServerCert = certificatePath;
|
||||
sslServerKey = keyPath;
|
||||
enableLmtp = true;
|
||||
modules = [ pkgs.dovecot_pigeonhole ] ++ (lib.optional cfg.fullTextSearch.enable pkgs.dovecot_fts_xapian );
|
||||
mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [ "fts" "fts_xapian" ];
|
||||
protocols = lib.optional cfg.enableManageSieve "sieve";
|
||||
package = pkgs.dovecot; # pin over stateVersion logic in nixox 26.05
|
||||
enablePAM = mkForce false;
|
||||
|
||||
sieveScripts = {
|
||||
after = builtins.toFile "spam.sieve" ''
|
||||
require "fileinto";
|
||||
sieve.pipeBins = map lib.getExe [
|
||||
(pkgs.writeShellScriptBin "rspamd-learn-ham.sh" "exec ${lib.getExe' config.services.rspamd.package "rspamc"} -h /run/rspamd/worker-controller.sock learn_ham")
|
||||
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh" "exec ${lib.getExe' config.services.rspamd.package "rspamc"} -h /run/rspamd/worker-controller.sock learn_spam")
|
||||
];
|
||||
|
||||
if header :is "X-Spam" "Yes" {
|
||||
fileinto "${junkMailboxName}";
|
||||
stop;
|
||||
# https://doc.dovecot.org/2.4.3/core/settings/syntax.html
|
||||
# https://doc.dovecot.org/2.4.3/core/settings/types.html#boolean-list
|
||||
settings = mkMerge [
|
||||
({
|
||||
# https://doc.dovecot.org/main/core/summaries/settings.html#dovecot_config_version
|
||||
dovecot_config_version = "2.4.3";
|
||||
# https://doc.dovecot.org/main/core/summaries/settings.html#dovecot_storage_version
|
||||
dovecot_storage_version = "2.3.21.1";
|
||||
|
||||
# server identity
|
||||
hostname = cfg.fqdn;
|
||||
|
||||
# vmail user
|
||||
mail_uid = cfg.storage.owner;
|
||||
mail_gid = cfg.storage.group;
|
||||
mail_access_groups = cfg.storage.group;
|
||||
|
||||
# authentication
|
||||
auth_mechanisms = [
|
||||
"plain"
|
||||
"login"
|
||||
];
|
||||
|
||||
# backend services
|
||||
"service anvil" = {
|
||||
"unix_listener anvil" = {
|
||||
mode = "0660";
|
||||
group = cfg.storage.group;
|
||||
};
|
||||
};
|
||||
"service auth" = {
|
||||
"unix_listener auth" = {
|
||||
user = config.services.postfix.user;
|
||||
group = config.services.postfix.group;
|
||||
mode = "0660";
|
||||
};
|
||||
};
|
||||
"service lmtp" = {
|
||||
"unix_listener dovecot-lmtp" = {
|
||||
user = config.services.postfix.user;
|
||||
group = config.services.postfix.group;
|
||||
mode = "0600";
|
||||
};
|
||||
user = cfg.storage.owner;
|
||||
vsz_limit = "${toString cfg.lmtpMemoryLimit} MB";
|
||||
};
|
||||
|
||||
# frontend services
|
||||
"service imap-login" = mkIf (cfg.enableImap || cfg.enableImapSsl) {
|
||||
"inet_listener imap" = {
|
||||
# https://dovecot.org/pipermail/dovecot/2010-March/047479.html
|
||||
port = if cfg.enableImap then 143 else 0;
|
||||
};
|
||||
"inet_listener imaps" = mkIf cfg.enableImapSsl {
|
||||
port = 993;
|
||||
ssl = true;
|
||||
};
|
||||
};
|
||||
"service pop3-login" = mkIf (cfg.enablePop3 || cfg.enablePop3Ssl) {
|
||||
"inet_listener pop3" = {
|
||||
# https://dovecot.org/pipermail/dovecot/2010-March/047479.html
|
||||
port = if cfg.enablePop3 then 110 else 0;
|
||||
};
|
||||
"inet_listener pop3s" = mkIf cfg.enablePop3Ssl {
|
||||
port = 995;
|
||||
ssl = true;
|
||||
};
|
||||
};
|
||||
"service imap" = {
|
||||
vsz_limit = "${toString cfg.imapMemoryLimit} MB";
|
||||
};
|
||||
|
||||
# protocols
|
||||
protocols = {
|
||||
lmtp = true;
|
||||
imap = cfg.enableImap || cfg.enableImapSsl;
|
||||
pop3 = cfg.enablePop3 || cfg.enablePop3Ssl;
|
||||
sieve = cfg.enableManageSieve;
|
||||
};
|
||||
|
||||
"protocol lmtp" = {
|
||||
mail_plugins = {
|
||||
sieve = true;
|
||||
};
|
||||
};
|
||||
"protocol imap" = {
|
||||
mail_max_userip_connections = cfg.maxConnectionsPerUser;
|
||||
mail_plugins = {
|
||||
imap_sieve = true;
|
||||
};
|
||||
};
|
||||
"protocol pop3" = {
|
||||
mail_max_userip_connections = cfg.maxConnectionsPerUser;
|
||||
};
|
||||
|
||||
# tls settings
|
||||
ssl_server_cert_file = x509CertificateFile;
|
||||
ssl_server_key_file = x509PrivateKeyFile;
|
||||
# https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7
|
||||
ssl = "required";
|
||||
ssl_min_protocol = "TLSv1";
|
||||
ssl_server_prefer_ciphers = "client";
|
||||
ssl_cipher_list = lib.concatStringsSep ":" [
|
||||
# TLS1.3
|
||||
"TLS_AES_128_GCM_SHA256"
|
||||
"TLS_CHACHA20_POLY1305_SHA256"
|
||||
"TLS_AES_256_GCM_SHA384"
|
||||
# TLS1.2
|
||||
# EC key material
|
||||
"ECDHE-ECDSA-AES128-GCM-SHA256"
|
||||
"ECDHE-ECDSA-CHACHA20-POLY1305"
|
||||
"ECDHE-ECDSA-AES256-GCM-SHA384"
|
||||
# RSA key material
|
||||
"ECDHE-RSA-AES128-GCM-SHA256"
|
||||
"ECDHE-RSA-CHACHA20-POLY1305"
|
||||
"ECDHE-RSA-AES256-GCM-SHA384"
|
||||
];
|
||||
ssl_curve_list = lib.concatStringsSep ":" [
|
||||
"X25519MLKEM768"
|
||||
"X25519"
|
||||
"SecP256r1MLKEM768"
|
||||
"prime256v1"
|
||||
"secp384r1"
|
||||
];
|
||||
|
||||
# default user mailboxes
|
||||
"namespace inbox" = {
|
||||
inbox = true;
|
||||
separator = cfg.hierarchySeparator;
|
||||
}
|
||||
'';
|
||||
};
|
||||
// mapAttrs' (name: value: nameValuePair ''mailbox "${name}"'' value) cfg.mailboxes;
|
||||
lda_mailbox_autosubscribe = true;
|
||||
lda_mailbox_autocreate = true;
|
||||
|
||||
mailboxes = cfg.mailboxes;
|
||||
# subaddressing
|
||||
recipient_delimiter = cfg.recipientDelimiter;
|
||||
lmtp_save_to_detail_mailbox = cfg.lmtpSaveToDetailMailbox;
|
||||
|
||||
extraConfig = ''
|
||||
#Extra Config
|
||||
${lib.optionalString debug ''
|
||||
mail_debug = yes
|
||||
auth_debug = yes
|
||||
verbose_ssl = yes
|
||||
''}
|
||||
# sieve filtering
|
||||
"sieve_script spamfilter" = {
|
||||
# junk filter
|
||||
path = pkgs.writeText "after.sieve" ''
|
||||
require "fileinto";
|
||||
|
||||
${lib.optionalString (cfg.enableImap || cfg.enableImapSsl) ''
|
||||
service imap-login {
|
||||
inet_listener imap {
|
||||
${if cfg.enableImap then ''
|
||||
port = 143
|
||||
'' else ''
|
||||
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
|
||||
port = 0
|
||||
''}
|
||||
}
|
||||
inet_listener imaps {
|
||||
${if cfg.enableImapSsl then ''
|
||||
port = 993
|
||||
ssl = yes
|
||||
'' else ''
|
||||
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
|
||||
port = 0
|
||||
''}
|
||||
}
|
||||
if header :is "X-Spam" "Yes" {
|
||||
fileinto "${junkMailboxName}";
|
||||
stop;
|
||||
}
|
||||
'';
|
||||
type = "after";
|
||||
};
|
||||
"sieve_script default" = {
|
||||
# declarative
|
||||
type = "default";
|
||||
name = "default";
|
||||
# TODO: Pre-compile Sieve scripts with 'sievec' (requires a Dovecot config in build sandbox)
|
||||
path = "${
|
||||
pkgs.runCommand "declarative-sieve-scripts" { } (
|
||||
''
|
||||
mkdir "$out"
|
||||
''
|
||||
+ lib.concatMapAttrsStringSep "\n" (_: value: ''
|
||||
mkdir "$out/${value.name}"
|
||||
cp -v "${builtins.toFile "default.sieve" value.sieveScript}" "$out/${value.name}/default.sieve"
|
||||
'') (lib.filterAttrs (_: value: value.sieveScript != null) cfg.accounts)
|
||||
)
|
||||
}/%{user}/default.sieve";
|
||||
};
|
||||
|
||||
"sieve_script personal" = {
|
||||
# managesieve
|
||||
type = "personal";
|
||||
# Upstream default, but we want to be explicit about it
|
||||
# https://doc.dovecot.org/main/core/plugins/sieve.html#script-storage-type-personal
|
||||
active_path = "~/.dovecot.sieve";
|
||||
path = "~/sieve";
|
||||
};
|
||||
|
||||
sieve_extensions = {
|
||||
fileinto = true;
|
||||
};
|
||||
sieve_global_extensions = {
|
||||
"vnd.dovecot.pipe" = true;
|
||||
};
|
||||
sieve_plugins = {
|
||||
sieve_imapsieve = true;
|
||||
sieve_extprograms = true;
|
||||
};
|
||||
|
||||
# imapsieve (spam/ham learning)
|
||||
"mailbox ${junkMailboxName}" = {
|
||||
"sieve_script spam" = {
|
||||
cause = [
|
||||
"APPEND"
|
||||
"COPY"
|
||||
];
|
||||
path = ./dovecot/imap_sieve/report-spam.sieve;
|
||||
type = "before";
|
||||
};
|
||||
};
|
||||
"imapsieve_from ${junkMailboxName}" = {
|
||||
"sieve_script ham" = {
|
||||
cause = "copy";
|
||||
path = ./dovecot/imap_sieve/report-ham.sieve;
|
||||
type = "before";
|
||||
};
|
||||
};
|
||||
|
||||
mailbox_list_layout = cfg.storage.directoryLayout;
|
||||
mailbox_list_utf8 = cfg.useUTF8FolderNames;
|
||||
mail_driver = "maildir";
|
||||
mail_path = "~/mail";
|
||||
|
||||
# declarative users
|
||||
"userdb declarative" = {
|
||||
driver = "passwd-file";
|
||||
passwd_file_path = userdbFile;
|
||||
fields = {
|
||||
home = "${cfg.storage.path}/%{user | domain}/%{user | username}";
|
||||
inherit (cfg.storage) uid gid;
|
||||
mail_index_path = mkIf (cfg.indexDir != null) "${cfg.indexDir}/%{user | domain}/%{user | username}";
|
||||
};
|
||||
};
|
||||
"passdb declarative" = {
|
||||
driver = "passwd-file";
|
||||
passwd_file_path = passwdFile;
|
||||
};
|
||||
|
||||
})
|
||||
(mkIf cfg.ldap.enable {
|
||||
# ldap users
|
||||
ssl_client_ca_file = cfg.ldap.caFile;
|
||||
ssl_client_require_valid_cert = true;
|
||||
|
||||
ldap_version = 3;
|
||||
ldap_uris = cfg.ldap.uris;
|
||||
ldap_starttls = cfg.ldap.startTls;
|
||||
ldap_auth_dn = cfg.ldap.bind.dn;
|
||||
ldap_auth_dn_password = "</run/credentials/dovecot.service/ldap-bind-pw";
|
||||
ldap_base = cfg.ldap.base;
|
||||
ldap_scope = mkLdapSearchScope cfg.ldap.scope;
|
||||
|
||||
"userdb ldap" = {
|
||||
driver = "ldap";
|
||||
filter = cfg.ldap.dovecot.userFilter;
|
||||
fields = {
|
||||
home = "${cfg.storage.path}/ldap/%{ldap:${cfg.ldap.attributes.uuid}}";
|
||||
inherit (cfg.storage) uid gid;
|
||||
mail_index_path = mkIf (
|
||||
cfg.indexDir != null
|
||||
) "${cfg.indexDir}/ldap/%{ldap:${cfg.ldap.attributes.uuid}}";
|
||||
};
|
||||
ldap_connection_group = "ldap-userdb-conn";
|
||||
};
|
||||
"passdb ldap" = {
|
||||
driver = "ldap";
|
||||
filter = cfg.ldap.dovecot.passFilter;
|
||||
bind = cfg.ldap.attributes.password == null;
|
||||
fields = {
|
||||
password = mkIf (cfg.ldap.attributes.password != null) "%{ldap:${cfg.ldap.attributes.password}}";
|
||||
};
|
||||
ldap_connection_group = "ldap-passdb-conn";
|
||||
};
|
||||
})
|
||||
(mkIf cfg.quota.enable {
|
||||
mail_plugins.quota = true;
|
||||
|
||||
"protocol imap".mail_plugins.imap_quota = true;
|
||||
|
||||
"service quota-status" = {
|
||||
executable = toString [
|
||||
"${config.services.dovecot2.package}/libexec/dovecot/quota-status"
|
||||
"-p"
|
||||
"postfix"
|
||||
];
|
||||
"unix_listener quota-status" = {
|
||||
user = "postfix";
|
||||
};
|
||||
client_limit = 1;
|
||||
vsz_limit = "${toString cfg.quotaStatusMemoryLimit} MB";
|
||||
};
|
||||
|
||||
quota_status_success = "DUNNO";
|
||||
quota_status_nouser = "DUNNO";
|
||||
quota_status_overquota = "552 5.2.2 Mailbox is full";
|
||||
# quota_storage_grace = "10M";
|
||||
|
||||
"quota user" = {
|
||||
driver = "count";
|
||||
storage_size = mkIf (cfg.quota.defaults.perUser != null) cfg.quota.defaults.perUser;
|
||||
};
|
||||
})
|
||||
(mkIf cfg.fullTextSearch.enable (
|
||||
{
|
||||
mail_plugins = {
|
||||
fts = true;
|
||||
fts_flatcurve = true;
|
||||
};
|
||||
|
||||
"service indexer-worker" = mkIf (cfg.fullTextSearch.memoryLimit != null) {
|
||||
vsz_limit = "${toString cfg.fullTextSearch.memoryLimit} MB";
|
||||
};
|
||||
|
||||
fts_autoindex = cfg.fullTextSearch.autoIndex;
|
||||
fts_driver = "flatcurve";
|
||||
fts_search_add_missing = "yes";
|
||||
fts_search_read_fallback = cfg.fullTextSearch.fallback;
|
||||
fts_header_excludes = lib.genAttrs cfg.fullTextSearch.headerExcludes (_: true);
|
||||
|
||||
"fts flatcurve" = {
|
||||
flatcurve_substring_search = cfg.fullTextSearch.substringSearch;
|
||||
};
|
||||
|
||||
# languages
|
||||
language_filters = lib.genAttrs cfg.fullTextSearch.filters (_: true);
|
||||
language_tokenizer_address_token_maxlen = 100; # default 250 too large for Xapian
|
||||
}
|
||||
''}
|
||||
${lib.optionalString (cfg.enablePop3 || cfg.enablePop3Ssl) ''
|
||||
service pop3-login {
|
||||
inet_listener pop3 {
|
||||
${if cfg.enablePop3 then ''
|
||||
port = 110
|
||||
'' else ''
|
||||
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
|
||||
port = 0
|
||||
''}
|
||||
}
|
||||
inet_listener pop3s {
|
||||
${if cfg.enablePop3Ssl then ''
|
||||
port = 995
|
||||
ssl = yes
|
||||
'' else ''
|
||||
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
|
||||
port = 0
|
||||
''}
|
||||
}
|
||||
}
|
||||
''}
|
||||
|
||||
protocol imap {
|
||||
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
|
||||
mail_plugins = $mail_plugins imap_sieve
|
||||
}
|
||||
|
||||
protocol pop3 {
|
||||
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
|
||||
}
|
||||
|
||||
mail_access_groups = ${vmailGroupName}
|
||||
ssl = required
|
||||
ssl_min_protocol = TLSv1.2
|
||||
ssl_prefer_server_ciphers = yes
|
||||
|
||||
service lmtp {
|
||||
unix_listener dovecot-lmtp {
|
||||
group = ${postfixCfg.group}
|
||||
mode = 0600
|
||||
user = ${postfixCfg.user}
|
||||
}
|
||||
}
|
||||
|
||||
recipient_delimiter = ${cfg.recipientDelimiter}
|
||||
lmtp_save_to_detail_mailbox = ${cfg.lmtpSaveToDetailMailbox}
|
||||
|
||||
protocol lmtp {
|
||||
mail_plugins = $mail_plugins sieve
|
||||
}
|
||||
|
||||
passdb {
|
||||
driver = passwd-file
|
||||
args = ${passwdFile}
|
||||
}
|
||||
|
||||
userdb {
|
||||
driver = passwd-file
|
||||
args = ${userdbFile}
|
||||
default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory}
|
||||
}
|
||||
|
||||
${lib.optionalString cfg.ldap.enable ''
|
||||
passdb {
|
||||
driver = ldap
|
||||
args = ${ldapConfFile}
|
||||
}
|
||||
|
||||
userdb {
|
||||
driver = ldap
|
||||
args = ${ldapConfFile}
|
||||
default_fields = home=/var/vmail/ldap/%u uid=${toString cfg.vmailUID} gid=${toString cfg.vmailUID}
|
||||
}
|
||||
''}
|
||||
|
||||
service auth {
|
||||
unix_listener auth {
|
||||
mode = 0660
|
||||
user = ${postfixCfg.user}
|
||||
group = ${postfixCfg.group}
|
||||
}
|
||||
}
|
||||
|
||||
auth_mechanisms = plain login
|
||||
|
||||
namespace inbox {
|
||||
separator = ${cfg.hierarchySeparator}
|
||||
inbox = yes
|
||||
}
|
||||
|
||||
plugin {
|
||||
sieve_plugins = sieve_imapsieve sieve_extprograms
|
||||
sieve = file:${cfg.sieveDirectory}/%u/scripts;active=${cfg.sieveDirectory}/%u/active.sieve
|
||||
sieve_default = file:${cfg.sieveDirectory}/%u/default.sieve
|
||||
sieve_default_name = default
|
||||
|
||||
# From elsewhere to Spam folder
|
||||
imapsieve_mailbox1_name = ${junkMailboxName}
|
||||
imapsieve_mailbox1_causes = COPY,APPEND
|
||||
imapsieve_mailbox1_before = file:${stateDir}/imap_sieve/report-spam.sieve
|
||||
|
||||
# From Spam folder to elsewhere
|
||||
imapsieve_mailbox2_name = *
|
||||
imapsieve_mailbox2_from = ${junkMailboxName}
|
||||
imapsieve_mailbox2_causes = COPY
|
||||
imapsieve_mailbox2_before = file:${stateDir}/imap_sieve/report-ham.sieve
|
||||
|
||||
sieve_pipe_bin_dir = ${pipeBin}/pipe/bin
|
||||
|
||||
sieve_global_extensions = +vnd.dovecot.pipe +vnd.dovecot.environment
|
||||
}
|
||||
|
||||
${lib.optionalString cfg.fullTextSearch.enable ''
|
||||
plugin {
|
||||
plugin = fts fts_xapian
|
||||
fts = xapian
|
||||
fts_xapian = partial=${toString cfg.fullTextSearch.minSize} full=${toString cfg.fullTextSearch.maxSize} attachments=${bool2int cfg.fullTextSearch.indexAttachments} verbose=${bool2int cfg.debug}
|
||||
|
||||
fts_autoindex = ${if cfg.fullTextSearch.autoIndex then "yes" else "no"}
|
||||
|
||||
${lib.strings.concatImapStringsSep "\n" (n: x: "fts_autoindex_exclude${if n==1 then "" else toString n} = ${x}") cfg.fullTextSearch.autoIndexExclude}
|
||||
|
||||
fts_enforced = ${cfg.fullTextSearch.enforced}
|
||||
}
|
||||
|
||||
${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) ''
|
||||
service indexer-worker {
|
||||
vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit*1024*1024)}
|
||||
}
|
||||
''}
|
||||
''}
|
||||
|
||||
lda_mailbox_autosubscribe = yes
|
||||
lda_mailbox_autocreate = yes
|
||||
'';
|
||||
# build languages from list, the first one becomes the default language
|
||||
// lib.listToAttrs (
|
||||
lib.imap0 (i: lang: {
|
||||
name = "language ${lang}";
|
||||
value = (if i == 0 then { default = true; } else { }) // {
|
||||
language_tokenizers = [
|
||||
"generic"
|
||||
"email-address"
|
||||
];
|
||||
};
|
||||
}) cfg.fullTextSearch.languages
|
||||
)
|
||||
))
|
||||
(mkIf cfg.debug.dovecot {
|
||||
mail_debug = true;
|
||||
# https://doc.dovecot.org/2.4.3/core/config/events/filter.html#common-unified-filter-language
|
||||
log_debug = "category=ssl OR category=auth";
|
||||
})
|
||||
];
|
||||
};
|
||||
|
||||
systemd.services.dovecot2 = {
|
||||
systemd.services.dovecot = {
|
||||
preStart = ''
|
||||
${genPasswdScript}
|
||||
rm -rf '${stateDir}/imap_sieve'
|
||||
mkdir '${stateDir}/imap_sieve'
|
||||
cp -p "${./dovecot/imap_sieve}"/*.sieve '${stateDir}/imap_sieve/'
|
||||
for k in "${stateDir}/imap_sieve"/*.sieve ; do
|
||||
${pkgs.dovecot_pigeonhole}/bin/sievec "$k"
|
||||
done
|
||||
chown -R '${dovecot2Cfg.mailUser}:${dovecot2Cfg.mailGroup}' '${stateDir}/imap_sieve'
|
||||
'' + (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile);
|
||||
};
|
||||
|
||||
systemd.services.postfix.restartTriggers = [ genPasswdScript ] ++ (lib.optional cfg.ldap.enable [setPwdInLdapConfFile]);
|
||||
|
||||
systemd.services.dovecot-fts-xapian-optimize = lib.mkIf (cfg.fullTextSearch.enable && cfg.fullTextSearch.maintenance.enable) {
|
||||
description = "Optimize dovecot indices for fts_xapian";
|
||||
requisite = [ "dovecot2.service" ];
|
||||
after = [ "dovecot2.service" ];
|
||||
startAt = cfg.fullTextSearch.maintenance.onCalendar;
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${pkgs.dovecot}/bin/doveadm fts optimize -A";
|
||||
PrivateDevices = true;
|
||||
PrivateNetwork = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectControlGroups = true;
|
||||
ProtectHome = true;
|
||||
ProtectSystem = true;
|
||||
PrivateTmp = true;
|
||||
};
|
||||
};
|
||||
systemd.timers.dovecot-fts-xapian-optimize = lib.mkIf (cfg.fullTextSearch.enable && cfg.fullTextSearch.maintenance.enable && cfg.fullTextSearch.maintenance.randomizedDelaySec != 0) {
|
||||
timerConfig = {
|
||||
RandomizedDelaySec = cfg.fullTextSearch.maintenance.randomizedDelaySec;
|
||||
'';
|
||||
reloadTriggers = lib.mkIf (!withACME) [
|
||||
x509CertificateFile
|
||||
x509PrivateKeyFile
|
||||
];
|
||||
serviceConfig = lib.optionalAttrs cfg.ldap.enable {
|
||||
LoadCredential = [
|
||||
"ldap-bind-pw:${cfg.ldap.bind.passwordFile}"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.postfix.restartTriggers = [
|
||||
genPasswdScript
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,4 +12,4 @@ if environment :matches "imap.user" "*" {
|
||||
set "username" "${1}";
|
||||
}
|
||||
|
||||
pipe :copy "sa-learn-ham.sh" [ "${username}" ];
|
||||
pipe :copy "rspamd-learn-ham.sh" [ "${username}" ];
|
||||
|
||||
@@ -4,4 +4,4 @@ if environment :matches "imap.user" "*" {
|
||||
set "username" "${1}";
|
||||
}
|
||||
|
||||
pipe :copy "sa-learn-spam.sh" [ "${username}" ];
|
||||
pipe :copy "rspamd-learn-spam.sh" [ "${username}" ];
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
exec rspamc -h /run/rspamd/worker-controller.sock learn_ham
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
exec rspamc -h /run/rspamd/worker-controller.sock learn_spam
|
||||
@@ -14,15 +14,23 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.mailserver;
|
||||
in
|
||||
{
|
||||
config = with cfg; lib.mkIf enable {
|
||||
environment.systemPackages = with pkgs; [
|
||||
dovecot opendkim openssh postfix rspamd
|
||||
] ++ (if certificateScheme == "selfsigned" then [ openssl ] else []);
|
||||
config = lib.mkIf cfg.enable {
|
||||
environment.systemPackages = [
|
||||
config.services.dovecot2.package
|
||||
pkgs.openssh
|
||||
config.services.postfix.package
|
||||
config.services.rspamd.package
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ config, pkgs, lib, ... }:
|
||||
{ config, lib, ... }:
|
||||
|
||||
let
|
||||
cfg = config.mailserver;
|
||||
@@ -24,4 +24,3 @@ in
|
||||
services.kresd.enable = true;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ config, pkgs, lib, ... }:
|
||||
{ config, lib, ... }:
|
||||
|
||||
let
|
||||
cfg = config.mailserver;
|
||||
|
||||
+11
-10
@@ -20,18 +20,19 @@ let
|
||||
cfg = config.mailserver;
|
||||
in
|
||||
{
|
||||
config = with cfg; lib.mkIf (enable && openFirewall) {
|
||||
config = lib.mkIf (cfg.enable && cfg.openFirewall) {
|
||||
|
||||
networking.firewall = {
|
||||
allowedTCPPorts = [ 25 ]
|
||||
++ lib.optional enableSubmission 587
|
||||
++ lib.optional enableSubmissionSsl 465
|
||||
++ lib.optional enableImap 143
|
||||
++ lib.optional enableImapSsl 993
|
||||
++ lib.optional enablePop3 110
|
||||
++ lib.optional enablePop3Ssl 995
|
||||
++ lib.optional enableManageSieve 4190
|
||||
++ lib.optional (certificateScheme == "acme-nginx") 80;
|
||||
allowedTCPPorts = [
|
||||
25
|
||||
]
|
||||
++ lib.optional cfg.enableSubmission 587
|
||||
++ lib.optional cfg.enableSubmissionSsl 465
|
||||
++ lib.optional cfg.enableImap 143
|
||||
++ lib.optional cfg.enableImapSsl 993
|
||||
++ lib.optional cfg.enablePop3 110
|
||||
++ lib.optional cfg.enablePop3Ssl 995
|
||||
++ lib.optional cfg.enableManageSieve 4190;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# nixos-mailserver: a simple mail server
|
||||
# Copyright (C) 2016-2018 Robin Raymond
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
with (import ./common.nix { inherit config; });
|
||||
|
||||
let
|
||||
cfg = config.mailserver;
|
||||
acmeRoot = "/var/lib/acme/acme-challenge";
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (cfg.enable && (cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx")) {
|
||||
services.nginx = lib.mkIf (cfg.certificateScheme == "acme-nginx") {
|
||||
enable = true;
|
||||
virtualHosts."${cfg.fqdn}" = {
|
||||
serverName = cfg.fqdn;
|
||||
serverAliases = cfg.certificateDomains;
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
acmeRoot = acmeRoot;
|
||||
};
|
||||
};
|
||||
|
||||
security.acme.certs."${cfg.fqdn}".reloadServices = [
|
||||
"postfix.service"
|
||||
"dovecot2.service"
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
# nixos-mailserver: a simple mail server
|
||||
# Copyright (C) 2017 Brian Olsen
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
with lib;
|
||||
|
||||
let
|
||||
cfg = config.mailserver;
|
||||
|
||||
dkimUser = config.services.opendkim.user;
|
||||
dkimGroup = config.services.opendkim.group;
|
||||
|
||||
createDomainDkimCert = dom:
|
||||
let
|
||||
dkim_key = "${cfg.dkimKeyDirectory}/${dom}.${cfg.dkimSelector}.key";
|
||||
dkim_txt = "${cfg.dkimKeyDirectory}/${dom}.${cfg.dkimSelector}.txt";
|
||||
in
|
||||
''
|
||||
if [ ! -f "${dkim_key}" ]
|
||||
then
|
||||
${pkgs.opendkim}/bin/opendkim-genkey -s "${cfg.dkimSelector}" \
|
||||
-d "${dom}" \
|
||||
--bits="${toString cfg.dkimKeyBits}" \
|
||||
--directory="${cfg.dkimKeyDirectory}"
|
||||
mv "${cfg.dkimKeyDirectory}/${cfg.dkimSelector}.private" "${dkim_key}"
|
||||
mv "${cfg.dkimKeyDirectory}/${cfg.dkimSelector}.txt" "${dkim_txt}"
|
||||
chmod 644 "${dkim_txt}"
|
||||
echo "Generated key for domain ${dom} selector ${cfg.dkimSelector}"
|
||||
fi
|
||||
'';
|
||||
createAllCerts = lib.concatStringsSep "\n" (map createDomainDkimCert cfg.domains);
|
||||
|
||||
keyTable = pkgs.writeText "opendkim-KeyTable"
|
||||
(lib.concatStringsSep "\n" (lib.flip map cfg.domains
|
||||
(dom: "${dom} ${dom}:${cfg.dkimSelector}:${cfg.dkimKeyDirectory}/${dom}.${cfg.dkimSelector}.key")));
|
||||
signingTable = pkgs.writeText "opendkim-SigningTable"
|
||||
(lib.concatStringsSep "\n" (lib.flip map cfg.domains (dom: "${dom} ${dom}")));
|
||||
|
||||
dkim = config.services.opendkim;
|
||||
args = [ "-f" "-l" ] ++ lib.optionals (dkim.configFile != null) [ "-x" dkim.configFile ];
|
||||
in
|
||||
{
|
||||
config = mkIf (cfg.dkimSigning && cfg.enable) {
|
||||
services.opendkim = {
|
||||
enable = true;
|
||||
selector = cfg.dkimSelector;
|
||||
keyPath = cfg.dkimKeyDirectory;
|
||||
domains = "csl:${builtins.concatStringsSep "," cfg.domains}";
|
||||
configFile = pkgs.writeText "opendkim.conf" (''
|
||||
Canonicalization ${cfg.dkimHeaderCanonicalization}/${cfg.dkimBodyCanonicalization}
|
||||
UMask 0002
|
||||
Socket ${dkim.socket}
|
||||
KeyTable file:${keyTable}
|
||||
SigningTable file:${signingTable}
|
||||
'' + (lib.optionalString cfg.debug ''
|
||||
Syslog yes
|
||||
SyslogSuccess yes
|
||||
LogWhy yes
|
||||
''));
|
||||
};
|
||||
|
||||
users.users = optionalAttrs (config.services.postfix.user == "postfix") {
|
||||
postfix.extraGroups = [ "${dkimGroup}" ];
|
||||
};
|
||||
systemd.services.opendkim = {
|
||||
preStart = lib.mkForce createAllCerts;
|
||||
serviceConfig = {
|
||||
ExecStart = lib.mkForce "${pkgs.opendkim}/bin/opendkim ${escapeShellArgs args}";
|
||||
PermissionsStartOnly = lib.mkForce false;
|
||||
};
|
||||
};
|
||||
systemd.tmpfiles.rules = [
|
||||
"d '${cfg.dkimKeyDirectory}' - ${dkimUser} ${dkimGroup} - -"
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
# nixos-mailserver: a simple mail server
|
||||
# Copyright (C) 2016-2018 Robin Raymond
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
with lib;
|
||||
|
||||
let
|
||||
cfg = config.mailserver;
|
||||
in
|
||||
{
|
||||
config = mkIf (cfg.enable && cfg.rebootAfterKernelUpgrade.enable) {
|
||||
systemd.services.nixos-upgrade.serviceConfig.ExecStartPost = pkgs.writeScript "post-upgrade-check" ''
|
||||
#!${pkgs.stdenv.shell}
|
||||
|
||||
# Checks whether the "current" kernel is different from the booted kernel
|
||||
# and then triggers a reboot so that the "current" kernel will be the booted one.
|
||||
# This is just an educated guess. If the links do not differ the kernels might still be different, according to spacefrogg in #nixos.
|
||||
|
||||
current=$(readlink -f /run/current-system/kernel)
|
||||
booted=$(readlink -f /run/booted-system/kernel)
|
||||
|
||||
if [ "$current" == "$booted" ]; then
|
||||
echo "kernel version seems unchanged, skipping reboot" | systemd-cat --priority 4 --identifier "post-upgrade-check";
|
||||
else
|
||||
echo "kernel path changed, possibly a new version" | systemd-cat --priority 2 --identifier "post-upgrade-check"
|
||||
echo "$booted" | systemd-cat --priority 2 --identifier "post-upgrade-kernel-check"
|
||||
echo "$current" | systemd-cat --priority 2 --identifier "post-upgrade-kernel-check"
|
||||
${cfg.rebootAfterKernelUpgrade.method}
|
||||
fi
|
||||
'';
|
||||
};
|
||||
}
|
||||
+331
-151
@@ -14,84 +14,140 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
config,
|
||||
options,
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
|
||||
with (import ./common.nix { inherit config pkgs lib; });
|
||||
with (import ./common.nix {
|
||||
inherit
|
||||
config
|
||||
options
|
||||
lib
|
||||
pkgs
|
||||
;
|
||||
});
|
||||
|
||||
let
|
||||
inherit (lib.strings) concatStringsSep;
|
||||
cfg = config.mailserver;
|
||||
|
||||
iniFormat = pkgs.formats.iniWithGlobalSection { };
|
||||
|
||||
# Merge several lookup tables. A lookup table is a attribute set where
|
||||
# - the key is an address (user@example.com) or a domain (@example.com)
|
||||
# - the value is a list of addresses
|
||||
mergeLookupTables = tables: lib.zipAttrsWith (n: v: lib.flatten v) tables;
|
||||
mergeLookupTables = tables: lib.zipAttrsWith (_: v: lib.flatten v) tables;
|
||||
|
||||
# valiases_postfix :: Map String [String]
|
||||
valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
|
||||
(name: value:
|
||||
let to = name;
|
||||
in map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name))
|
||||
cfg.loginAccounts));
|
||||
regex_valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
|
||||
(name: value:
|
||||
let to = name;
|
||||
in map (from: {"${from}" = to;}) value.aliasesRegexp)
|
||||
cfg.loginAccounts));
|
||||
valiases_postfix = mergeLookupTables (
|
||||
lib.flatten (
|
||||
lib.mapAttrsToList (
|
||||
name: value:
|
||||
let
|
||||
to = name;
|
||||
in
|
||||
map (from: { "${from}" = to; }) (value.aliases ++ lib.singleton name)
|
||||
) cfg.accounts
|
||||
)
|
||||
);
|
||||
regex_valiases_postfix = mergeLookupTables (
|
||||
lib.flatten (
|
||||
lib.mapAttrsToList (
|
||||
name: value:
|
||||
let
|
||||
to = name;
|
||||
in
|
||||
map (from: { "${from}" = to; }) value.aliasesRegexp
|
||||
) cfg.accounts
|
||||
)
|
||||
);
|
||||
|
||||
# catchAllPostfix :: Map String [String]
|
||||
catchAllPostfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
|
||||
(name: value:
|
||||
let to = name;
|
||||
in map (from: {"@${from}" = to;}) value.catchAll)
|
||||
cfg.loginAccounts));
|
||||
catchAllPostfix = mergeLookupTables (
|
||||
lib.flatten (
|
||||
lib.mapAttrsToList (
|
||||
name: value:
|
||||
let
|
||||
to = name;
|
||||
in
|
||||
map (from: { "@${from}" = to; }) value.catchAll
|
||||
) cfg.accounts
|
||||
)
|
||||
);
|
||||
|
||||
# all_valiases_postfix :: Map String [String]
|
||||
all_valiases_postfix = mergeLookupTables [valiases_postfix extra_valiases_postfix];
|
||||
all_valiases_postfix = mergeLookupTables [
|
||||
valiases_postfix
|
||||
extra_valiases_postfix
|
||||
];
|
||||
|
||||
# attrsToLookupTable :: Map String (Either String [ String ]) -> Map String [String]
|
||||
attrsToLookupTable = aliases: let
|
||||
lookupTables = lib.mapAttrsToList (from: to: {"${from}" = to;}) aliases;
|
||||
in mergeLookupTables lookupTables;
|
||||
attrsToLookupTable =
|
||||
aliases:
|
||||
let
|
||||
lookupTables = lib.mapAttrsToList (from: to: { "${from}" = to; }) aliases;
|
||||
in
|
||||
mergeLookupTables lookupTables;
|
||||
|
||||
# extra_valiases_postfix :: Map String [String]
|
||||
extra_valiases_postfix = attrsToLookupTable cfg.extraVirtualAliases;
|
||||
extra_valiases_postfix = attrsToLookupTable cfg.aliases;
|
||||
|
||||
# forwards :: Map String [String]
|
||||
forwards = attrsToLookupTable cfg.forwards;
|
||||
|
||||
# lookupTableToString :: Map String [String] -> String
|
||||
lookupTableToString = attrs: let
|
||||
valueToString = value: lib.concatStringsSep ", " value;
|
||||
in lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs);
|
||||
lookupTableToString =
|
||||
attrs:
|
||||
let
|
||||
valueToString = value: lib.concatStringsSep ", " value;
|
||||
in
|
||||
lib.concatStringsSep "\n" (
|
||||
lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs
|
||||
);
|
||||
|
||||
# valiases_file :: Path
|
||||
valiases_file = let
|
||||
content = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix]);
|
||||
in builtins.toFile "valias" content;
|
||||
valiases_file =
|
||||
let
|
||||
content = lookupTableToString (mergeLookupTables [
|
||||
all_valiases_postfix
|
||||
catchAllPostfix
|
||||
]);
|
||||
in
|
||||
builtins.toFile "valias" content;
|
||||
|
||||
regex_valiases_file = let
|
||||
content = lookupTableToString regex_valiases_postfix;
|
||||
in builtins.toFile "regex_valias" content;
|
||||
regex_valiases_file =
|
||||
let
|
||||
content = lookupTableToString regex_valiases_postfix;
|
||||
in
|
||||
builtins.toFile "regex_valias" content;
|
||||
|
||||
# denied_recipients_postfix :: [ String ]
|
||||
denied_recipients_postfix = (map
|
||||
(acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}")
|
||||
(lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts)));
|
||||
denied_recipients_file = builtins.toFile "denied_recipients" (lib.concatStringsSep "\n" denied_recipients_postfix);
|
||||
denied_recipients_postfix = map (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") (
|
||||
lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.accounts)
|
||||
);
|
||||
denied_recipients_file = builtins.toFile "denied_recipients" (
|
||||
lib.concatStringsSep "\n" denied_recipients_postfix
|
||||
);
|
||||
|
||||
reject_senders_postfix = (map
|
||||
(sender:
|
||||
"${sender} REJECT")
|
||||
(cfg.rejectSender));
|
||||
reject_senders_file = builtins.toFile "reject_senders" (lib.concatStringsSep "\n" (reject_senders_postfix)) ;
|
||||
reject_senders_postfix = map (
|
||||
sender:
|
||||
"${sender} REJECT${
|
||||
lib.optionalString (cfg.rejectSenderMessage != "") " ${cfg.rejectSenderMessage}"
|
||||
}"
|
||||
) cfg.rejectSender;
|
||||
reject_senders_file = builtins.toFile "reject_senders" (
|
||||
lib.concatStringsSep "\n" reject_senders_postfix
|
||||
);
|
||||
|
||||
reject_recipients_postfix = (map
|
||||
(recipient:
|
||||
"${recipient} REJECT")
|
||||
(cfg.rejectRecipients));
|
||||
reject_recipients_postfix = map (recipient: "${recipient} REJECT") cfg.rejectRecipients;
|
||||
# rejectRecipients :: [ Path ]
|
||||
reject_recipients_file = builtins.toFile "reject_recipients" (lib.concatStringsSep "\n" (reject_recipients_postfix)) ;
|
||||
reject_recipients_file = builtins.toFile "reject_recipients" (
|
||||
lib.concatStringsSep "\n" reject_recipients_postfix
|
||||
);
|
||||
|
||||
# vhosts_file :: Path
|
||||
vhosts_file = builtins.toFile "vhosts" (concatStringsSep "\n" cfg.domains);
|
||||
@@ -103,71 +159,71 @@ let
|
||||
# every alias is owned (uniquely) by its user.
|
||||
# The user's own address is already in all_valiases_postfix.
|
||||
vaccounts_file = builtins.toFile "vaccounts" (lookupTableToString all_valiases_postfix);
|
||||
regex_vaccounts_file = builtins.toFile "regex_vaccounts" (lookupTableToString regex_valiases_postfix);
|
||||
regex_vaccounts_file = builtins.toFile "regex_vaccounts" (
|
||||
lookupTableToString regex_valiases_postfix
|
||||
);
|
||||
|
||||
submissionHeaderCleanupRules = 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.
|
||||
submissionHeaderCleanupRules = 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
|
||||
'' + lib.optionalString cfg.rewriteMessageId ''
|
||||
/^Received:/ IGNORE
|
||||
/^X-Originating-IP:/ IGNORE
|
||||
/^X-Mailer:/ IGNORE
|
||||
/^User-Agent:/ IGNORE
|
||||
/^X-Enigmail:/ IGNORE
|
||||
''
|
||||
+ lib.optionalString cfg.rewriteMessageId ''
|
||||
|
||||
# Replaces the user submitted hostname with the server's FQDN to hide the
|
||||
# user's host or network.
|
||||
# Replaces the user submitted hostname with the server's FQDN to hide the
|
||||
# user's host or network.
|
||||
|
||||
/^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}>
|
||||
'');
|
||||
/^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}>
|
||||
''
|
||||
);
|
||||
|
||||
inetSocket = addr: port: "inet:[${toString port}@${addr}]";
|
||||
unixSocket = sock: "unix:${sock}";
|
||||
|
||||
smtpdMilters =
|
||||
(lib.optional cfg.dkimSigning "unix:/run/opendkim/opendkim.sock")
|
||||
++ [ "unix:/run/rspamd/rspamd-milter.sock" ];
|
||||
|
||||
policyd-spf = pkgs.writeText "policyd-spf.conf" cfg.policydSPFExtraConfig;
|
||||
smtpdMilters = [ "unix:/run/rspamd/rspamd-milter.sock" ];
|
||||
|
||||
mappedFile = name: "hash:/var/lib/postfix/conf/${name}";
|
||||
mappedRegexFile = name: "pcre:/var/lib/postfix/conf/${name}";
|
||||
|
||||
submissionOptions =
|
||||
{
|
||||
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";
|
||||
smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}${lib.optionalString (regex_valiases_postfix != {}) ",pcre:/etc/postfix/regex_vaccounts"}";
|
||||
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";
|
||||
};
|
||||
submissionOptions = {
|
||||
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";
|
||||
smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}${
|
||||
lib.optionalString (regex_valiases_postfix != { }) ",pcre:/etc/postfix/regex_vaccounts"
|
||||
}";
|
||||
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";
|
||||
};
|
||||
|
||||
commonLdapConfig = ''
|
||||
server_host = ${lib.concatStringsSep " " cfg.ldap.uris}
|
||||
start_tls = ${if cfg.ldap.startTls then "yes" else "no"}
|
||||
version = 3
|
||||
tls_ca_cert_file = ${cfg.ldap.tlsCAFile}
|
||||
tls_ca_cert_file = ${cfg.ldap.caFile}
|
||||
tls_require_cert = yes
|
||||
|
||||
search_base = ${cfg.ldap.searchBase}
|
||||
scope = ${cfg.ldap.searchScope}
|
||||
search_base = ${cfg.ldap.base}
|
||||
scope = ${cfg.ldap.scope}
|
||||
|
||||
bind = yes
|
||||
bind_dn = ${cfg.ldap.bind.dn}
|
||||
'';
|
||||
|
||||
# Enforce a mapping between SMTP user and envelope sender address
|
||||
ldapSenderLoginMap = pkgs.writeText "ldap-sender-login-map.cf" ''
|
||||
${commonLdapConfig}
|
||||
query_filter = ${cfg.ldap.postfix.filter}
|
||||
result_attribute = ${cfg.ldap.postfix.mailAttribute}
|
||||
result_attribute = ${cfg.ldap.attributes.username}
|
||||
'';
|
||||
ldapSenderLoginMapFile = "/run/postfix/ldap-sender-login-map.cf";
|
||||
appendPwdInSenderLoginMap = appendLdapBindPwd {
|
||||
@@ -178,10 +234,11 @@ let
|
||||
destination = ldapSenderLoginMapFile;
|
||||
};
|
||||
|
||||
# Check whether a recipient address exists, before accepting mail for it
|
||||
ldapVirtualMailboxMap = pkgs.writeText "ldap-virtual-mailbox-map.cf" ''
|
||||
${commonLdapConfig}
|
||||
query_filter = ${cfg.ldap.postfix.filter}
|
||||
result_attribute = ${cfg.ldap.postfix.uidAttribute}
|
||||
result_attribute = ${cfg.ldap.attributes.username}
|
||||
'';
|
||||
ldapVirtualMailboxMapFile = "/run/postfix/ldap-virtual-mailbox-map.cf";
|
||||
appendPwdInVirtualMailboxMap = appendLdapBindPwd {
|
||||
@@ -193,20 +250,66 @@ let
|
||||
};
|
||||
in
|
||||
{
|
||||
config = with cfg; lib.mkIf enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
# SMTP TLS error reporting (RFC 8460)
|
||||
services.tlsrpt = {
|
||||
inherit (cfg.tlsrpt) enable;
|
||||
configurePostfix = true;
|
||||
reportd.settings = {
|
||||
organization_name = cfg.systemName;
|
||||
contact_info = "${cfg.systemContact}";
|
||||
sender_address = "noreply-tlsrpt@${cfg.systemDomain}";
|
||||
};
|
||||
};
|
||||
|
||||
# SMTP client policy mapping for DANE (RFC 6698) and MTA-STS (RFC 8461)
|
||||
services.postfix-tlspol = {
|
||||
enable = true;
|
||||
configurePostfix = true;
|
||||
};
|
||||
|
||||
# Sender Rewriting Scheme (https://www.libsrs2.net/srs/srs.pdf)
|
||||
services.postsrsd = {
|
||||
inherit (cfg.srs) enable;
|
||||
configurePostfix = true;
|
||||
settings = {
|
||||
domains = lib.unique (
|
||||
[
|
||||
cfg.fqdn
|
||||
cfg.sendingFqdn
|
||||
cfg.systemDomain
|
||||
]
|
||||
++ cfg.domains
|
||||
);
|
||||
separator = "=";
|
||||
srs-domain = cfg.srs.domain;
|
||||
};
|
||||
};
|
||||
|
||||
security.acme.certs = lib.mkIf withACME {
|
||||
${cfg.x509.useACMEHost} = {
|
||||
reloadServices = [ "postfix.service" ];
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.postfix.reloadTriggers = lib.mkIf (!withACME) [
|
||||
x509CertificateFile
|
||||
x509PrivateKeyFile
|
||||
];
|
||||
|
||||
systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable {
|
||||
preStart = ''
|
||||
${appendPwdInVirtualMailboxMap}
|
||||
${appendPwdInSenderLoginMap}
|
||||
'';
|
||||
restartTriggers = [ appendPwdInVirtualMailboxMap appendPwdInSenderLoginMap ];
|
||||
restartTriggers = [
|
||||
appendPwdInVirtualMailboxMap
|
||||
appendPwdInSenderLoginMap
|
||||
];
|
||||
};
|
||||
|
||||
services.postfix = {
|
||||
enable = true;
|
||||
hostname = "${sendingFqdn}";
|
||||
networksStyle = "host";
|
||||
mapFiles."valias" = valiases_file;
|
||||
mapFiles."regex_valias" = regex_valiases_file;
|
||||
mapFiles."vaccounts" = vaccounts_file;
|
||||
@@ -214,36 +317,40 @@ in
|
||||
mapFiles."denied_recipients" = denied_recipients_file;
|
||||
mapFiles."reject_senders" = reject_senders_file;
|
||||
mapFiles."reject_recipients" = reject_recipients_file;
|
||||
sslCert = certificatePath;
|
||||
sslKey = keyPath;
|
||||
enableSubmission = cfg.enableSubmission;
|
||||
enableSubmissions = cfg.enableSubmissionSsl;
|
||||
virtual = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix forwards]);
|
||||
virtual = lookupTableToString (mergeLookupTables [
|
||||
all_valiases_postfix
|
||||
catchAllPostfix
|
||||
forwards
|
||||
]);
|
||||
|
||||
config = {
|
||||
# Extra Config
|
||||
mydestination = "";
|
||||
settings.main = {
|
||||
myhostname = cfg.sendingFqdn;
|
||||
mydestination = ""; # disable local mail delivery
|
||||
recipient_delimiter = cfg.recipientDelimiter;
|
||||
smtpd_banner = "${fqdn} ESMTP NO UCE";
|
||||
smtpd_banner = "${cfg.fqdn} ESMTP NO UCE";
|
||||
disable_vrfy_command = true;
|
||||
message_size_limit = toString cfg.messageSizeLimit;
|
||||
message_size_limit = cfg.messageSizeLimit;
|
||||
|
||||
# virtual mail system
|
||||
virtual_uid_maps = "static:5000";
|
||||
virtual_gid_maps = "static:5000";
|
||||
virtual_mailbox_base = mailDirectory;
|
||||
virtual_mailbox_domains = vhosts_file;
|
||||
virtual_mailbox_maps = [
|
||||
(mappedFile "valias")
|
||||
] ++ lib.optionals (cfg.ldap.enable) [
|
||||
]
|
||||
++ lib.optionals cfg.ldap.enable [
|
||||
"ldap:${ldapVirtualMailboxMapFile}"
|
||||
] ++ lib.optionals (regex_valiases_postfix != {}) [
|
||||
]
|
||||
++ lib.optionals (regex_valiases_postfix != { }) [
|
||||
(mappedRegexFile "regex_valias")
|
||||
];
|
||||
virtual_alias_maps = lib.mkAfter (lib.optionals (regex_valiases_postfix != {}) [
|
||||
(mappedRegexFile "regex_valias")
|
||||
]);
|
||||
virtual_alias_maps = lib.mkAfter (
|
||||
lib.optionals (regex_valiases_postfix != { }) [
|
||||
(mappedRegexFile "regex_valias")
|
||||
]
|
||||
);
|
||||
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";
|
||||
|
||||
@@ -252,91 +359,164 @@ in
|
||||
smtpd_sasl_path = "/run/dovecot2/auth";
|
||||
smtpd_sasl_auth_enable = true;
|
||||
smtpd_relay_restrictions = [
|
||||
"permit_mynetworks" "permit_sasl_authenticated" "reject_unauth_destination"
|
||||
"permit_mynetworks"
|
||||
"permit_sasl_authenticated"
|
||||
"reject_unauth_destination"
|
||||
];
|
||||
|
||||
policy-spf_time_limit = "3600s";
|
||||
|
||||
# reject selected senders
|
||||
smtpd_sender_restrictions = [
|
||||
"check_sender_access ${mappedFile "reject_senders"}"
|
||||
];
|
||||
|
||||
# quota and spf checking
|
||||
smtpd_recipient_restrictions = [
|
||||
# reject selected recipients
|
||||
"check_recipient_access ${mappedFile "denied_recipients"}"
|
||||
"check_recipient_access ${mappedFile "reject_recipients"}"
|
||||
"check_policy_service inet:localhost:12340"
|
||||
"check_policy_service unix:private/policy-spf"
|
||||
]
|
||||
++ lib.optionals cfg.quota.enable [
|
||||
# quota checking
|
||||
"check_policy_service unix:/run/dovecot2/quota-status"
|
||||
];
|
||||
|
||||
# TLS settings, inspired by https://github.com/jeaye/nix-files
|
||||
# Submission by mail clients is handled in submissionOptions
|
||||
# The X509 private key followed by the corresponding certificate
|
||||
smtpd_tls_chain_files = [
|
||||
"${x509PrivateKeyFile}"
|
||||
"${x509CertificateFile}"
|
||||
];
|
||||
|
||||
# TLS for incoming mail is optional
|
||||
smtpd_tls_security_level = "may";
|
||||
|
||||
# strong might suffice and is computationally less expensive
|
||||
smtpd_tls_eecdh_grade = "ultra";
|
||||
# But required for authentication attempts
|
||||
smtpd_tls_auth_only = true;
|
||||
|
||||
# Disable obselete protocols
|
||||
smtpd_tls_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3";
|
||||
smtp_tls_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3";
|
||||
smtpd_tls_mandatory_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3";
|
||||
smtp_tls_mandatory_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3";
|
||||
# TLS versions supported for the SMTP server
|
||||
smtpd_tls_protocols = ">=TLSv1";
|
||||
smtpd_tls_mandatory_protocols = ">=TLSv1";
|
||||
|
||||
smtp_tls_ciphers = "high";
|
||||
# Require ciphersuites that OpenSSL classifies as "High"
|
||||
smtpd_tls_ciphers = "high";
|
||||
smtp_tls_mandatory_ciphers = "high";
|
||||
smtpd_tls_mandatory_ciphers = "high";
|
||||
|
||||
# Disable deprecated ciphers
|
||||
smtpd_tls_mandatory_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
|
||||
smtpd_tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
|
||||
smtp_tls_mandatory_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
|
||||
smtp_tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
|
||||
# Enable DNSSEC/DANE support for outgoing SMTP connections
|
||||
# https://www.postfix.org/postconf.5.html#smtp_tls_security_level
|
||||
smtp_dns_support_level = "dnssec";
|
||||
smtp_tls_security_level = "dane";
|
||||
|
||||
tls_preempt_cipherlist = true;
|
||||
# TLS versions supported for the SMTP client
|
||||
smtp_tls_protocols = ">=TLSv1.2";
|
||||
smtp_tls_mandatory_protocols = ">=TLSv1.2";
|
||||
|
||||
# Require ciphersuites that OpenSSL classifies as "High"
|
||||
smtp_tls_ciphers = "high";
|
||||
smtp_tls_mandatory_ciphers = "high";
|
||||
|
||||
tls_config_file =
|
||||
let
|
||||
mkGroupString = groups: concatStringsSep " / " (map (concatStringsSep ":") groups);
|
||||
in
|
||||
iniFormat.generate "postfix-openssl.cnf" {
|
||||
globalSection.postfix = "postfix_settings";
|
||||
sections = {
|
||||
postfix_settings.ssl_conf = "postfix_ssl_settings";
|
||||
postfix_ssl_settings.system_default = "baseline_postfix_settings";
|
||||
baseline_postfix_settings = {
|
||||
# Allow all TLSv1.3 cipher suites
|
||||
Ciphersuites = concatStringsSep ":" [
|
||||
"TLS_AES_256_GCM_SHA384"
|
||||
"TLS_AES_128_GCM_SHA256"
|
||||
"TLS_CHACHA20_POLY1305_SHA256"
|
||||
];
|
||||
|
||||
# Full list: openssl list -tls-groups
|
||||
# Restrict and prioritize the following curves in the given order
|
||||
# Excludes curves that have no widespread support, so we don't bloat the handshake needlessly.
|
||||
# https://www.postfix.org/postconf.5.html#tls_eecdh_auto_curves
|
||||
Groups = mkGroupString [
|
||||
[ "*X25519MLKEM768" ]
|
||||
[ "*X25519" ]
|
||||
[ "SecP256r1MLKEM768" ]
|
||||
[
|
||||
"P-256"
|
||||
"P-384"
|
||||
]
|
||||
];
|
||||
SignatureAlgorithms = concatStringsSep ":" [
|
||||
# Full list: openssl list -tls-signature-algorithms
|
||||
# Reduced to algorithms with key material supported in CA/B
|
||||
# baseline requirements and excluding deprecated algorithms
|
||||
# like SHA1.
|
||||
|
||||
# EcDSA certificates
|
||||
# https://cabforum.org/working-groups/server/baseline-requirements/requirements/#71312-ecdsa
|
||||
"ecdsa_secp256r1_sha256"
|
||||
"ecdsa_secp384r1_sha384"
|
||||
"ecdsa_secp521r1_sha512"
|
||||
|
||||
# RSA certificates
|
||||
# https://cabforum.org/working-groups/server/baseline-requirements/requirements/#71311-rsa
|
||||
"rsa_pss_rsae_sha256"
|
||||
"rsa_pss_rsae_sha384"
|
||||
"rsa_pss_rsae_sha512"
|
||||
"rsa_pss_pss_sha256"
|
||||
"rsa_pss_pss_sha384"
|
||||
"rsa_pss_pss_sha512"
|
||||
"rsa_pkcs1_sha256"
|
||||
"rsa_pkcs1_sha384"
|
||||
"rsa_pkcs1_sha512"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
tls_config_name = "postfix";
|
||||
|
||||
# Algorithm selection happens through `tls_config_file` instead.
|
||||
tls_eecdh_auto_curves = [ ];
|
||||
tls_ffdhe_auto_groups = [ ];
|
||||
|
||||
# Require AEAD & ECDHE for TLSv1.2.
|
||||
tls_high_cipherlist = concatStringsSep ":" [
|
||||
"ECDHE-ECDSA-AES256-GCM-SHA384"
|
||||
"ECDHE-RSA-AES256-GCM-SHA384"
|
||||
"ECDHE-ECDSA-AES128-GCM-SHA256"
|
||||
"ECDHE-RSA-AES128-GCM-SHA256"
|
||||
"ECDHE-ECDSA-CHACHA20-POLY1305"
|
||||
"ECDHE-RSA-CHACHA20-POLY1305"
|
||||
];
|
||||
|
||||
# As long as all cipher suites are considered safe, let the client use its preferred cipher
|
||||
tls_preempt_cipherlist = false;
|
||||
|
||||
# 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
|
||||
smtp_tls_loglevel = "1";
|
||||
smtpd_tls_loglevel = "1";
|
||||
|
||||
# Configure a non blocking source of randomness
|
||||
tls_random_source = "dev:/dev/urandom";
|
||||
|
||||
smtpd_milters = smtpdMilters;
|
||||
non_smtpd_milters = lib.mkIf cfg.dkimSigning ["unix:/run/opendkim/opendkim.sock"];
|
||||
non_smtpd_milters = lib.mkIf cfg.dkim.enable [ "unix:/run/rspamd/rspamd-milter.sock" ];
|
||||
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 = cfg.smtpdForbidBareNewline;
|
||||
smtpd_forbid_bare_newline_exclusions = "$mynetworks";
|
||||
milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_authen}";
|
||||
};
|
||||
|
||||
submissionOptions = submissionOptions;
|
||||
submissionsOptions = submissionOptions;
|
||||
|
||||
masterConfig = {
|
||||
settings.master = {
|
||||
"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" ];
|
||||
};
|
||||
"policy-spf" = {
|
||||
type = "unix";
|
||||
privileged = true;
|
||||
chroot = false;
|
||||
command = "spawn";
|
||||
args = [ "user=nobody" "argv=${pkgs.pypolicyd-spf}/bin/policyd-spf" "${policyd-spf}"];
|
||||
};
|
||||
"submission-header-cleanup" = {
|
||||
type = "unix";
|
||||
private = false;
|
||||
chroot = false;
|
||||
maxproc = 0;
|
||||
command = "cleanup";
|
||||
args = ["-o" "header_checks=pcre:${submissionHeaderCleanupRules}"];
|
||||
args = [
|
||||
"-o"
|
||||
"header_checks=pcre:${submissionHeaderCleanupRules}"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,11 +14,19 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
with lib;
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
inherit (lib)
|
||||
optionalString
|
||||
mkIf
|
||||
;
|
||||
|
||||
cfg = config.mailserver;
|
||||
|
||||
preexecDefined = cfg.backup.cmdPreexec != null;
|
||||
@@ -38,7 +46,8 @@ let
|
||||
${cfg.backup.cmdPostexec}
|
||||
'';
|
||||
postexecString = optionalString postexecDefined "cmd_postexec ${postexecWrapped}";
|
||||
in {
|
||||
in
|
||||
{
|
||||
config = mkIf (cfg.enable && cfg.backup.enable) {
|
||||
services.rsnapshot = {
|
||||
enable = true;
|
||||
@@ -52,7 +61,7 @@ in {
|
||||
retain hourly ${toString cfg.backup.retain.hourly}
|
||||
retain daily ${toString cfg.backup.retain.daily}
|
||||
retain weekly ${toString cfg.backup.retain.weekly}
|
||||
backup ${cfg.mailDirectory}/ localhost/
|
||||
backup ${cfg.storage.path}/ localhost/
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
+238
-70
@@ -14,75 +14,217 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.mailserver;
|
||||
|
||||
postfixCfg = config.services.postfix;
|
||||
rspamdCfg = config.services.rspamd;
|
||||
rspamdSocket = "rspamd.service";
|
||||
|
||||
rspamdPkg = config.services.rspamd.package;
|
||||
rspamdUser = config.services.rspamd.user;
|
||||
rspamdGroup = config.services.rspamd.group;
|
||||
|
||||
createDkimKeypair =
|
||||
{
|
||||
domain,
|
||||
selector,
|
||||
type,
|
||||
bits,
|
||||
...
|
||||
}:
|
||||
let
|
||||
privkey = "${cfg.dkim.keyDirectory}/${domain}.${selector}.key";
|
||||
pubkey = "${cfg.dkim.keyDirectory}/${domain}.${selector}.txt";
|
||||
in
|
||||
pkgs.writeShellScript "dkim-keygen-${domain}-${selector}" ''
|
||||
if [ ! -f "${privkey}" ]
|
||||
then
|
||||
${lib.getExe' rspamdPkg "rspamadm"} dkim_keygen ${
|
||||
lib.cli.toCommandLineShellGNU { } {
|
||||
inherit
|
||||
domain
|
||||
selector
|
||||
type
|
||||
bits
|
||||
privkey
|
||||
;
|
||||
}
|
||||
} > "${pubkey}"
|
||||
chmod 0644 "${pubkey}"
|
||||
echo "Generated key for domain ${domain} and selector ${selector}"
|
||||
fi
|
||||
'';
|
||||
|
||||
mailDomains = lib.unique (
|
||||
# primary mailserver domains
|
||||
config.mailserver.domains
|
||||
# all dkim domains, even extra domains specified
|
||||
++ lib.attrNames cfg.dkim.domains
|
||||
# and the srs domain, if one is configured
|
||||
++ lib.optionals (cfg.srs.domain != null) [ cfg.srs.domain ]
|
||||
);
|
||||
|
||||
dkimKeys = lib.concatMap (
|
||||
domain:
|
||||
let
|
||||
configuredSelectors = config.mailserver.dkim.domains.${domain}.selectors or { };
|
||||
|
||||
finalSelectors =
|
||||
if configuredSelectors == { } then
|
||||
# synthesize default dkim key, if none configured
|
||||
{
|
||||
"${config.mailserver.dkim.defaults.selector}" = {
|
||||
keyType = null;
|
||||
keyLength = null;
|
||||
keyFile = null;
|
||||
};
|
||||
}
|
||||
else
|
||||
configuredSelectors;
|
||||
in
|
||||
lib.mapAttrsToList (selector: settings: rec {
|
||||
inherit domain selector;
|
||||
keyFile = settings.keyFile;
|
||||
keyPath = if keyFile != null then keyFile else "${cfg.dkim.keyDirectory}/${domain}.${selector}.key";
|
||||
bits =
|
||||
if settings.keyLength != null then
|
||||
settings.keyLength
|
||||
else
|
||||
config.mailserver.dkim.defaults.keyLength;
|
||||
type =
|
||||
if settings.keyType != null then settings.keyType else config.mailserver.dkim.defaults.keyType;
|
||||
}) finalSelectors
|
||||
) mailDomains;
|
||||
|
||||
dkimKeysToGenerate = lib.filter (key: key.keyFile == null) dkimKeys;
|
||||
|
||||
dkimKeysByDomain = lib.groupBy (item: item.domain) dkimKeys;
|
||||
in
|
||||
{
|
||||
config = with cfg; lib.mkIf enable {
|
||||
config = lib.mkIf cfg.enable {
|
||||
environment.systemPackages = lib.mkBefore [
|
||||
(pkgs.runCommand "rspamc-wrapped"
|
||||
{
|
||||
nativeBuildInputs = with pkgs; [ makeWrapper ];
|
||||
}
|
||||
''
|
||||
makeWrapper ${lib.getExe' rspamdPkg "rspamc"} $out/bin/rspamc \
|
||||
--add-flags "-h /run/rspamd/worker-controller.sock"
|
||||
''
|
||||
)
|
||||
];
|
||||
|
||||
services.rspamd = {
|
||||
enable = true;
|
||||
inherit debug;
|
||||
debug = cfg.debug.rspamd;
|
||||
locals = {
|
||||
"milter_headers.conf" = { text = ''
|
||||
extended_spam_headers = yes;
|
||||
''; };
|
||||
"redis.conf" = { text = ''
|
||||
servers = "${cfg.redis.address}:${toString cfg.redis.port}";
|
||||
'' + (lib.optionalString (cfg.redis.password != null) ''
|
||||
password = "${cfg.redis.password}";
|
||||
''); };
|
||||
"classifier-bayes.conf" = { text = ''
|
||||
cache {
|
||||
backend = "redis";
|
||||
}
|
||||
''; };
|
||||
"antivirus.conf" = lib.mkIf cfg.virusScanning { text = ''
|
||||
clamav {
|
||||
action = "reject";
|
||||
symbol = "CLAM_VIRUS";
|
||||
type = "clamav";
|
||||
log_clean = true;
|
||||
servers = "/run/clamav/clamd.ctl";
|
||||
scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all
|
||||
}
|
||||
''; };
|
||||
"dkim_signing.conf" = { text = ''
|
||||
# Disable outbound email signing, we use opendkim for this
|
||||
enabled = false;
|
||||
''; };
|
||||
"dmarc.conf" = { text = ''
|
||||
${lib.optionalString cfg.dmarcReporting.enable ''
|
||||
reporting {
|
||||
enabled = true;
|
||||
email = "${cfg.dmarcReporting.email}";
|
||||
domain = "${cfg.dmarcReporting.domain}";
|
||||
org_name = "${cfg.dmarcReporting.organizationName}";
|
||||
from_name = "${cfg.dmarcReporting.fromName}";
|
||||
msgid_from = "dmarc-rua";
|
||||
}''}
|
||||
''; };
|
||||
};
|
||||
|
||||
overrides = {
|
||||
"milter_headers.conf" = {
|
||||
text = ''
|
||||
use = [ "authentication-results" ];
|
||||
extended_spam_headers = true;
|
||||
'';
|
||||
};
|
||||
"redis.conf" = {
|
||||
text = ''
|
||||
servers = "${
|
||||
if cfg.redis.port == null then
|
||||
cfg.redis.address
|
||||
else
|
||||
"${cfg.redis.address}:${toString cfg.redis.port}"
|
||||
}";
|
||||
''
|
||||
+ (lib.optionalString (cfg.redis.password != null) ''
|
||||
password = "${cfg.redis.password}";
|
||||
'');
|
||||
};
|
||||
"classifier-bayes.conf" = {
|
||||
text = ''
|
||||
cache {
|
||||
backend = "redis";
|
||||
}
|
||||
'';
|
||||
};
|
||||
"antivirus.conf" = lib.mkIf cfg.virusScanning {
|
||||
text = ''
|
||||
clamav {
|
||||
action = "reject";
|
||||
symbol = "CLAM_VIRUS";
|
||||
type = "clamav";
|
||||
log_clean = true;
|
||||
servers = "/run/clamav/clamd.ctl";
|
||||
scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all
|
||||
}
|
||||
'';
|
||||
};
|
||||
"dkim_signing.conf" = {
|
||||
text = ''
|
||||
enabled = ${lib.boolToString cfg.dkim.enable};
|
||||
# Only sign explicitly configured domains
|
||||
try_fallback = false;
|
||||
# Allow for usernames w/o domain part
|
||||
allow_username_mismatch = true;
|
||||
# Don't normalize DKIM key selection for subdomains
|
||||
use_esld = false;
|
||||
|
||||
domain {
|
||||
${lib.concatStringsSep "\n\n" (
|
||||
map (domain: ''
|
||||
${domain} {
|
||||
selectors [
|
||||
${lib.concatStringsSep ",\n" (
|
||||
map (selector: ''
|
||||
{
|
||||
path: "${selector.keyPath}";
|
||||
selector: "${selector.selector}";
|
||||
}'') dkimKeysByDomain.${domain}
|
||||
)}
|
||||
]
|
||||
}
|
||||
'') (lib.attrNames dkimKeysByDomain)
|
||||
)}
|
||||
}
|
||||
'';
|
||||
};
|
||||
"dmarc.conf" = {
|
||||
text = ''
|
||||
${lib.optionalString cfg.dmarcReporting.enable ''
|
||||
reporting {
|
||||
enabled = true;
|
||||
email = "noreply-dmarc@${cfg.systemDomain}";
|
||||
domain = "${cfg.systemDomain}";
|
||||
org_name = "${cfg.systemName}";
|
||||
from_name = "${cfg.systemName}";
|
||||
msgid_from = "${cfg.systemDomain}";
|
||||
${lib.optionalString (cfg.dmarcReporting.excludeDomains != [ ]) ''
|
||||
exclude_domains = ${builtins.toJSON cfg.dmarcReporting.excludeDomains};
|
||||
''}
|
||||
}''}
|
||||
'';
|
||||
};
|
||||
};
|
||||
overrides = {
|
||||
"options.inc" = {
|
||||
text = ''
|
||||
local_addrs = [::1/128, 127.0.0.0/8]
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
workers.rspamd_proxy = {
|
||||
type = "rspamd_proxy";
|
||||
bindSockets = [{
|
||||
socket = "/run/rspamd/rspamd-milter.sock";
|
||||
mode = "0664";
|
||||
}];
|
||||
bindSockets = [
|
||||
{
|
||||
socket = "/run/rspamd/rspamd-milter.sock";
|
||||
mode = "0664";
|
||||
}
|
||||
];
|
||||
count = 1; # Do not spawn too many processes of this type
|
||||
extraConfig = ''
|
||||
milter = yes; # Enable milter mode
|
||||
@@ -97,11 +239,13 @@ in
|
||||
workers.controller = {
|
||||
type = "controller";
|
||||
count = 1;
|
||||
bindSockets = [{
|
||||
socket = "/run/rspamd/worker-controller.sock";
|
||||
mode = "0666";
|
||||
}];
|
||||
includes = [];
|
||||
bindSockets = [
|
||||
{
|
||||
socket = "/run/rspamd/worker-controller.sock";
|
||||
mode = "0666";
|
||||
}
|
||||
];
|
||||
includes = [ ];
|
||||
extraConfig = ''
|
||||
static_dir = "''${WWWDIR}"; # Serve the web UI static assets
|
||||
'';
|
||||
@@ -109,28 +253,51 @@ in
|
||||
|
||||
};
|
||||
|
||||
services.redis.servers.rspamd = {
|
||||
enable = lib.mkDefault true;
|
||||
port = lib.mkDefault 6380;
|
||||
services.redis.servers.rspamd.enable = lib.mkDefault cfg.redis.configureLocally;
|
||||
|
||||
systemd.tmpfiles.settings."10-rspamd.conf" = {
|
||||
"${cfg.dkim.keyDirectory}" = {
|
||||
d = {
|
||||
# Create /var/dkim owned by rspamd user/group
|
||||
user = rspamdUser;
|
||||
group = rspamdGroup;
|
||||
};
|
||||
Z = {
|
||||
# Recursively adjust permissions in /var/dkim
|
||||
user = rspamdUser;
|
||||
group = rspamdGroup;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.rspamd = {
|
||||
requires = [ "redis-rspamd.service" ] ++ (lib.optional cfg.virusScanning "clamav-daemon.service");
|
||||
after = [ "redis-rspamd.service" ] ++ (lib.optional cfg.virusScanning "clamav-daemon.service");
|
||||
serviceConfig = lib.mkMerge [
|
||||
{
|
||||
SupplementaryGroups = [ config.services.redis.servers.rspamd.group ];
|
||||
}
|
||||
(lib.optionalAttrs cfg.dkim.enable {
|
||||
ExecStartPre = map createDkimKeypair dkimKeysToGenerate;
|
||||
ReadWritePaths = [ cfg.dkim.keyDirectory ];
|
||||
})
|
||||
];
|
||||
};
|
||||
|
||||
systemd.services.rspamd-dmarc-reporter = lib.optionalAttrs (cfg.dmarcReporting.enable) {
|
||||
systemd.services.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable {
|
||||
# Explicitly select yesterday's date to work around broken
|
||||
# default behaviour when called without a date.
|
||||
# https://github.com/rspamd/rspamd/issues/4062
|
||||
script = ''
|
||||
${pkgs.rspamd}/bin/rspamadm dmarc_report $(date -d "yesterday" "+%Y%m%d")
|
||||
'';
|
||||
script = toString [
|
||||
(lib.getExe' rspamdPkg "rspamadm")
|
||||
"dmarc_report"
|
||||
"$(date -d 'yesterday' '+%Y%m%d')"
|
||||
];
|
||||
serviceConfig = {
|
||||
User = "${config.services.rspamd.user}";
|
||||
Group = "${config.services.rspamd.group}";
|
||||
|
||||
AmbientCapabilities = [];
|
||||
AmbientCapabilities = [ ];
|
||||
CapabilityBoundingSet = "";
|
||||
DevicePolicy = "closed";
|
||||
IPAddressAllow = "localhost";
|
||||
@@ -151,10 +318,17 @@ in
|
||||
ProcSubset = "pid";
|
||||
ProtectSystem = "strict";
|
||||
RemoveIPC = true;
|
||||
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
||||
RestrictAddressFamilies = [
|
||||
"AF_INET"
|
||||
"AF_INET6"
|
||||
"AF_UNIX"
|
||||
];
|
||||
RestrictNamespaces = true;
|
||||
RestrictRealtime = true;
|
||||
RestrictSUIDSGID = true;
|
||||
SupplementaryGroups = lib.optionals cfg.redis.configureLocally [
|
||||
config.services.redis.servers.rspamd.group
|
||||
];
|
||||
SystemCallArchitectures = "native";
|
||||
SystemCallFilter = [
|
||||
"@system-service"
|
||||
@@ -164,7 +338,7 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs (cfg.dmarcReporting.enable) {
|
||||
systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable {
|
||||
description = "Daily delivery of aggregated DMARC reports";
|
||||
wantedBy = [
|
||||
"timers.target"
|
||||
@@ -177,12 +351,6 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.postfix = {
|
||||
after = [ rspamdSocket ];
|
||||
requires = [ rspamdSocket ];
|
||||
};
|
||||
|
||||
users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+45
-56
@@ -14,72 +14,61 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ config, pkgs, lib, ... }:
|
||||
{
|
||||
config,
|
||||
options,
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
|
||||
with (import ./common.nix {
|
||||
inherit
|
||||
config
|
||||
options
|
||||
lib
|
||||
pkgs
|
||||
;
|
||||
});
|
||||
|
||||
let
|
||||
cfg = config.mailserver;
|
||||
certificatesDeps =
|
||||
if cfg.certificateScheme == "manual" then
|
||||
[]
|
||||
else if cfg.certificateScheme == "selfsigned" then
|
||||
[ "mailserver-selfsigned-certificate.service" ]
|
||||
else
|
||||
[ "acme-finished-${cfg.fqdn}.target" ];
|
||||
certificateDeps = lib.optionals withACME [
|
||||
"acme-order-renew-${cfg.x509.useACMEHost}.service"
|
||||
];
|
||||
in
|
||||
{
|
||||
config = with cfg; lib.mkIf enable {
|
||||
# Create self signed certificate
|
||||
systemd.services.mailserver-selfsigned-certificate = lib.mkIf (cfg.certificateScheme == "selfsigned") {
|
||||
after = [ "local-fs.target" ];
|
||||
script = ''
|
||||
# Create certificates if they do not exist yet
|
||||
dir="${cfg.certificateDirectory}"
|
||||
fqdn="${cfg.fqdn}"
|
||||
[[ $fqdn == /* ]] && fqdn=$(< "$fqdn")
|
||||
key="$dir/key-${cfg.fqdn}.pem";
|
||||
cert="$dir/cert-${cfg.fqdn}.pem";
|
||||
config = lib.mkIf cfg.enable {
|
||||
|
||||
if [[ ! -f $key || ! -f $cert ]]; then
|
||||
mkdir -p "${cfg.certificateDirectory}"
|
||||
(umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) &&
|
||||
"${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \
|
||||
-days 3650 -out "$cert"
|
||||
fi
|
||||
'';
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
PrivateTmp = true;
|
||||
};
|
||||
};
|
||||
|
||||
# Create maildir folder before dovecot startup
|
||||
systemd.services.dovecot2 = {
|
||||
wants = certificatesDeps;
|
||||
after = certificatesDeps;
|
||||
preStart = let
|
||||
directories = lib.strings.escapeShellArgs (
|
||||
[ mailDirectory ]
|
||||
++ lib.optional (cfg.indexDir != null) cfg.indexDir
|
||||
);
|
||||
in ''
|
||||
# Create mail directory and set permissions. See
|
||||
# <http://wiki2.dovecot.org/SharedMailboxes/Permissions>.
|
||||
# Prevent world-readable paths, even temporarily.
|
||||
umask 007
|
||||
mkdir -p ${directories}
|
||||
chgrp "${vmailGroupName}" ${directories}
|
||||
chmod 02770 ${directories}
|
||||
'';
|
||||
systemd.services.dovecot = {
|
||||
wants = certificateDeps;
|
||||
after = certificateDeps;
|
||||
preStart =
|
||||
let
|
||||
directories = lib.strings.escapeShellArgs (
|
||||
[ cfg.storage.path ] ++ lib.optional (cfg.indexDir != null) cfg.indexDir
|
||||
);
|
||||
in
|
||||
''
|
||||
# Create mail directory and set permissions. See
|
||||
# <https://doc.dovecot.org/main/core/config/shared_mailboxes.html#filesystem-permissions-1>.
|
||||
# Prevent world-readable paths, even temporarily.
|
||||
umask 007
|
||||
mkdir -p ${directories}
|
||||
chgrp "${cfg.storage.group}" ${directories}
|
||||
chmod 02770 ${directories}
|
||||
'';
|
||||
};
|
||||
|
||||
# Postfix requires dovecot lmtp socket, dovecot auth socket and certificate to work
|
||||
systemd.services.postfix = {
|
||||
wants = certificatesDeps;
|
||||
after = [ "dovecot2.service" ]
|
||||
++ lib.optional cfg.dkimSigning "opendkim.service"
|
||||
++ certificatesDeps;
|
||||
requires = [ "dovecot2.service" ]
|
||||
++ lib.optional cfg.dkimSigning "opendkim.service";
|
||||
wants = certificateDeps;
|
||||
after = [
|
||||
"dovecot.service"
|
||||
]
|
||||
++ lib.optional cfg.dkim.enable "rspamd.service"
|
||||
++ certificateDeps;
|
||||
requires = [ "dovecot.service" ] ++ lib.optional cfg.dkim.enable "rspamd.service";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+38
-78
@@ -14,91 +14,51 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
with config.mailserver;
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
vmail_user = {
|
||||
name = vmailUserName;
|
||||
isSystemUser = true;
|
||||
uid = vmailUID;
|
||||
home = mailDirectory;
|
||||
createHome = true;
|
||||
group = vmailGroupName;
|
||||
};
|
||||
|
||||
|
||||
virtualMailUsersActivationScript = pkgs.writeScript "activate-virtual-mail-users" ''
|
||||
#!${pkgs.stdenv.shell}
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Prevent world-readable paths, even temporarily.
|
||||
umask 007
|
||||
|
||||
# Create directory to store user sieve scripts if it doesn't exist
|
||||
if (! test -d "${sieveDirectory}"); then
|
||||
mkdir "${sieveDirectory}"
|
||||
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}"
|
||||
chmod 770 "${sieveDirectory}"
|
||||
fi
|
||||
|
||||
# Copy user's sieve script to the correct location (if it exists). If it
|
||||
# is null, remove the file.
|
||||
${lib.concatMapStringsSep "\n" ({ name, sieveScript }:
|
||||
if lib.isString sieveScript then ''
|
||||
if (! test -d "${sieveDirectory}/${name}"); then
|
||||
mkdir -p "${sieveDirectory}/${name}"
|
||||
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}"
|
||||
chmod 770 "${sieveDirectory}/${name}"
|
||||
fi
|
||||
cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve"
|
||||
${sieveScript}
|
||||
EOF
|
||||
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve"
|
||||
'' else ''
|
||||
if (test -f "${sieveDirectory}/${name}/default.sieve"); then
|
||||
rm "${sieveDirectory}/${name}/default.sieve"
|
||||
fi
|
||||
if (test -f "${sieveDirectory}/${name}.svbin"); then
|
||||
rm "${sieveDirectory}/${name}/default.svbin"
|
||||
fi
|
||||
'') (map (user: { inherit (user) name sieveScript; })
|
||||
(lib.attrValues loginAccounts))}
|
||||
'';
|
||||
in {
|
||||
config = lib.mkIf enable {
|
||||
cfg = config.mailserver;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
# assert that all accounts provide a password
|
||||
assertions = (map (acct: {
|
||||
assertion = (acct.hashedPassword != null || acct.hashedPasswordFile != null);
|
||||
message = "${acct.name} must provide either a hashed password or a password hash file";
|
||||
}) (lib.attrValues loginAccounts));
|
||||
assertions = map (acct: {
|
||||
assertion =
|
||||
lib.length (
|
||||
lib.filter (value: value != null) [
|
||||
acct.hashedPassword
|
||||
acct.hashedPasswordFile
|
||||
acct.passwordFile
|
||||
]
|
||||
) == 1;
|
||||
message = "Login account ${acct.name} must provide exactly one of password file, hashed password, or hashed password file";
|
||||
}) (lib.attrValues cfg.accounts);
|
||||
|
||||
# warn for accounts that specify both password and file
|
||||
warnings = (map
|
||||
(acct: "${acct.name} specifies both a password hash and hash file; hash file will be used")
|
||||
(lib.filter
|
||||
(acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null))
|
||||
(lib.attrValues loginAccounts)));
|
||||
warnings =
|
||||
map (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used")
|
||||
(
|
||||
lib.filter (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null)) (
|
||||
lib.attrValues cfg.accounts
|
||||
)
|
||||
);
|
||||
|
||||
# set the vmail gid to a specific value
|
||||
users.groups = {
|
||||
"${vmailGroupName}" = { gid = vmailUID; };
|
||||
users.groups.${cfg.storage.group} = {
|
||||
inherit (cfg.storage) gid;
|
||||
};
|
||||
|
||||
# define all users
|
||||
users.users = {
|
||||
"${vmail_user.name}" = lib.mkForce vmail_user;
|
||||
};
|
||||
|
||||
systemd.services.activate-virtual-mail-users = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "dovecot2.service" ];
|
||||
serviceConfig = {
|
||||
ExecStart = virtualMailUsersActivationScript;
|
||||
};
|
||||
enable = true;
|
||||
users.users.${cfg.storage.owner} = lib.mkForce {
|
||||
inherit (cfg.storage)
|
||||
group
|
||||
uid
|
||||
;
|
||||
name = cfg.storage.owner;
|
||||
isSystemUser = true;
|
||||
home = cfg.storage.path;
|
||||
createHome = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env nix-shell
|
||||
#!nix-shell -i python3 -p python3
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from pwd import getpwnam
|
||||
|
||||
|
||||
class FolderLayout(Enum):
|
||||
Default = 1
|
||||
Folder = 2
|
||||
|
||||
|
||||
def check_user(vmail_root: Path):
|
||||
owner = vmail_root.owner()
|
||||
owner_uid = getpwnam(owner).pw_uid
|
||||
|
||||
if os.geteuid() == owner_uid:
|
||||
return
|
||||
|
||||
try:
|
||||
print(
|
||||
f"Trying to switch effective user id to {owner_uid} ({owner})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
os.seteuid(owner_uid)
|
||||
return
|
||||
except PermissionError:
|
||||
print(
|
||||
f"Failed switching to virtual mail user. Please run this script under it, for example by using `sudo -u {owner}`)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def is_maildir_related(path: Path, layout: FolderLayout) -> bool:
|
||||
if path.name in [
|
||||
"subscriptions",
|
||||
# https://doc.dovecot.org/2.3/admin_manual/mailbox_formats/maildir/#imap-uid-mapping
|
||||
"dovecot-uidlist",
|
||||
# https://doc.dovecot.org/2.3/admin_manual/mailbox_formats/maildir/#imap-keywords
|
||||
"dovecot-keywords",
|
||||
]:
|
||||
return True
|
||||
if not path.is_dir():
|
||||
return False
|
||||
if path.name in ["cur", "new", "tmp"]:
|
||||
return True
|
||||
if layout is FolderLayout.Default and path.name.startswith("."):
|
||||
return True
|
||||
if layout is FolderLayout.Folder:
|
||||
if path.name in ["mail"]:
|
||||
return False
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def mkdir(dst: Path, dry_run: bool = True):
|
||||
print(f'mkdir "{dst}"')
|
||||
if not dry_run:
|
||||
# u+rwx, setgid
|
||||
dst.mkdir(mode=0o2700)
|
||||
|
||||
|
||||
def move(src: Path, dst: Path, dry_run: bool = True):
|
||||
print(f'mv "{src}" "{dst}"')
|
||||
if not dry_run:
|
||||
src.rename(dst)
|
||||
|
||||
|
||||
def delete(dst: Path, dry_run: bool = True):
|
||||
if not dst.exists():
|
||||
return
|
||||
|
||||
if dst.is_dir():
|
||||
print(f'rm --recursive "{dst}"')
|
||||
if not dry_run:
|
||||
shutil.rmtree(dst)
|
||||
else:
|
||||
print(f'rm "{dst}"')
|
||||
if not dry_run:
|
||||
dst.unlink()
|
||||
|
||||
|
||||
def main(vmail_root: Path, layout: FolderLayout, dry_run: bool = True):
|
||||
maildirs = {path.parent for path in vmail_root.glob("*/*/cur")}
|
||||
maybe_delete = []
|
||||
|
||||
# The old maildir will be the new home directory
|
||||
for homedir in maildirs:
|
||||
maildir = homedir / "mail"
|
||||
mkdir(maildir, dry_run)
|
||||
|
||||
for path in homedir.iterdir():
|
||||
if is_maildir_related(path, layout):
|
||||
move(path, maildir / path.name, dry_run)
|
||||
else:
|
||||
maybe_delete.append(path)
|
||||
|
||||
# Files that are part of the previous home directory, but now obsolete
|
||||
for path in [
|
||||
vmail_root / ".dovecot.lda-dupes",
|
||||
vmail_root / ".dovecot.lda-dupes.locks",
|
||||
]:
|
||||
delete(path, dry_run)
|
||||
|
||||
# The remaining files are likely obsolete, but should still be checked with care
|
||||
for path in maybe_delete:
|
||||
print(f"# rm {str(path)}")
|
||||
|
||||
if dry_run:
|
||||
print("\nNo changes were made.")
|
||||
print("Run the script with `--execute` to apply the listed changes.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="""
|
||||
NixOS Mailserver Migration #3: Dovecot mail directory migration
|
||||
(https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-mail-directory-migration)
|
||||
"""
|
||||
)
|
||||
parser.add_argument(
|
||||
"vmail_root", type=Path, help="Path to the `mailserver.mailDirectory`"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--layout",
|
||||
choices=["default", "folder"],
|
||||
required=True,
|
||||
help="Folder layout: 'default' unless `mailserver.useFsLayout` was enabled, then'folder'",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--execute", action="store_true", help="Actually perform changes"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
layout = FolderLayout.Default if args.layout == "default" else FolderLayout.Folder
|
||||
|
||||
check_user(args.vmail_root)
|
||||
main(args.vmail_root, layout, not args.execute)
|
||||
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env nix-shell
|
||||
#!nix-shell -i python3 -p "python3.withPackages (ps: with ps; [ ldap3 ])"
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from pwd import getpwnam
|
||||
from typing import Literal, cast
|
||||
|
||||
from ldap3 import BASE, LEVEL, SUBTREE, Connection, Server
|
||||
from ldap3.core.exceptions import LDAPException
|
||||
|
||||
LDAPSearchScope = Literal["BASE", "LEVEL", "SUBTREE"]
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_ERROR = 1
|
||||
EXIT_LDAP_STARTTLS = 2
|
||||
EXIT_LDAP_BIND = 3
|
||||
|
||||
GREEN = "32"
|
||||
YELLOW = "33"
|
||||
RED = "31"
|
||||
BOLD = "1"
|
||||
|
||||
NO_COLOR = "NO_COLOR" in os.environ
|
||||
|
||||
|
||||
def color(text, code):
|
||||
if NO_COLOR:
|
||||
return text
|
||||
return f"\033[{code}m{text}\033[0m"
|
||||
|
||||
|
||||
def check_user(vmail_root: Path):
|
||||
owner = vmail_root.owner()
|
||||
owner_uid = getpwnam(owner).pw_uid
|
||||
|
||||
if os.geteuid() == owner_uid:
|
||||
return
|
||||
|
||||
try:
|
||||
print(f"Trying to switch effective user id to {owner_uid} ({owner})")
|
||||
os.seteuid(owner_uid)
|
||||
return
|
||||
except PermissionError:
|
||||
print(
|
||||
f"Failed switching to virtual mail user. Please run this script under it, for example by using `sudo -u {owner}`)"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def move(*, src: Path, dst: Path, dry_run: bool = True) -> bool:
|
||||
print(f'mv "{src}" "{dst}"')
|
||||
if not dry_run:
|
||||
try:
|
||||
src.rename(dst)
|
||||
except OSError as exc:
|
||||
print(f"Rename failed ({src=!s}, {dst=!s}): {exc}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main(
|
||||
*,
|
||||
vmail_root: Path,
|
||||
ldap_uri: str,
|
||||
ldap_starttls: bool,
|
||||
ldap_bind_dn: str,
|
||||
ldap_bind_pw: str,
|
||||
ldap_base: str,
|
||||
ldap_scope: LDAPSearchScope,
|
||||
ldap_filter: str,
|
||||
ldap_attr_uuid: str,
|
||||
dry_run: bool = True,
|
||||
verbose: bool = False,
|
||||
):
|
||||
# Begin with LDAP connection for fast feedback
|
||||
server = Server(ldap_uri)
|
||||
conn = Connection(server, ldap_bind_dn, ldap_bind_pw)
|
||||
|
||||
if ldap_starttls:
|
||||
try:
|
||||
if ldap_starttls:
|
||||
conn.start_tls()
|
||||
except LDAPException as exc:
|
||||
print(color(f"LDAP connection setup failed: {exc!r}", RED))
|
||||
sys.exit(EXIT_LDAP_STARTTLS)
|
||||
|
||||
if not conn.bind():
|
||||
err = conn.result
|
||||
print(
|
||||
color(
|
||||
f"""
|
||||
LDAP bind failed for {ldap_bind_dn}@{ldap_uri}
|
||||
Result: {err.get("result")} ({err.get("description")})
|
||||
Message: {err.get("message")!r}""",
|
||||
RED,
|
||||
)
|
||||
)
|
||||
sys.exit(EXIT_LDAP_BIND)
|
||||
|
||||
# Find existing dovecot home directories and collect account identifier
|
||||
print(
|
||||
color(
|
||||
f"\nEnumerate accounts based on existing home directories in {(vmail_root / 'ldap')!s}",
|
||||
BOLD,
|
||||
)
|
||||
)
|
||||
|
||||
skipped = 0
|
||||
accounts = set()
|
||||
homedirs = vmail_root.glob("ldap/*")
|
||||
for path in homedirs:
|
||||
if not path.is_dir():
|
||||
print(f"- Not a directory ({path=!s}) (skipping)")
|
||||
skipped += 1
|
||||
continue
|
||||
elif not (path / "mail").is_dir():
|
||||
print(f"- No maildir in home ({path=!s}) (skipping)")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
account = path.name
|
||||
accounts.add(account)
|
||||
if verbose:
|
||||
print(f"- Home directory found ({path=!s}, {account=})")
|
||||
|
||||
print(
|
||||
color(
|
||||
f"\nFinding matching LDAP entries to retrieve `{ldap_attr_uuid}` attribute",
|
||||
BOLD,
|
||||
)
|
||||
)
|
||||
|
||||
no_entry = 0
|
||||
multiple_entries = 0
|
||||
plan = {}
|
||||
for account in sorted(accounts):
|
||||
filter = ldap_filter % account
|
||||
conn.search(
|
||||
search_base=ldap_base,
|
||||
search_filter=filter,
|
||||
search_scope=ldap_scope,
|
||||
attributes=[ldap_attr_uuid],
|
||||
)
|
||||
|
||||
if conn.response is None:
|
||||
print(f"- LDAP search produced no result for {filter}")
|
||||
|
||||
count = len(conn.entries)
|
||||
|
||||
if count < 1:
|
||||
print(f"- No LDAP entry found ({account=}, {filter=}) (skipping)")
|
||||
no_entry += 1
|
||||
continue
|
||||
elif count > 1:
|
||||
print(f"- Multiple LDAP entries found ({account=}, {filter=}) (skipping)")
|
||||
multiple_entries += 1
|
||||
continue
|
||||
else:
|
||||
entry = conn.entries[0]
|
||||
uuid = str(entry[ldap_attr_uuid].value)
|
||||
if verbose:
|
||||
print(f"- LDAP entry mapped ({account=}, {uuid=})")
|
||||
plan.update({account: uuid})
|
||||
|
||||
print(color("\nThe following operations will be executed:", BOLD))
|
||||
moved = 0
|
||||
moves_failed = 0
|
||||
for src, dst in plan.items():
|
||||
_src = vmail_root / "ldap" / src
|
||||
_dst = vmail_root / "ldap" / dst
|
||||
if not move(src=_src, dst=_dst, dry_run=dry_run):
|
||||
moves_failed += 1
|
||||
else:
|
||||
moved += 1
|
||||
|
||||
print(
|
||||
color(
|
||||
"\nMigration summary",
|
||||
BOLD,
|
||||
)
|
||||
)
|
||||
|
||||
if any([skipped, no_entry, multiple_entries, not accounts, moves_failed]):
|
||||
print("""
|
||||
We strongly recommend reviewing and remediating all potential issues before
|
||||
running with `--execute`. Specific details can be found further up.""")
|
||||
|
||||
if moved:
|
||||
print(f"""
|
||||
- {color(f"{moved} home directories were migrated successfully.", GREEN)} {"(dry run)" if dry_run else ""}
|
||||
This is great news, they are now UUID-based and will be immune to username changes!""")
|
||||
|
||||
if skipped and accounts:
|
||||
print(f"""
|
||||
- {color(f"{skipped} paths in {(vmail_root / 'ldap')!s} were skipped.", YELLOW)}
|
||||
These were not a directory or did not contain a maildir. They should be
|
||||
reviewed but can most likely be deleted.""")
|
||||
|
||||
if no_entry:
|
||||
print(f"""
|
||||
- {color(f"{no_entry} LDAP queries found no entry.", YELLOW)}
|
||||
This could be a problem, because we cannot migrate home directories without
|
||||
finding the LDAP entry and retrieving its {ldap_attr_uuid} field. In practice
|
||||
this can happen if an LDAP account was deleted but its mail home directory
|
||||
remained.""")
|
||||
|
||||
if multiple_entries:
|
||||
print(f"""
|
||||
- {color(f"{multiple_entries} LDAP queries returned multiple entries.", RED)}
|
||||
This is a problem, because we cannot decide which LDAP entry owns the home
|
||||
directory.""")
|
||||
|
||||
if not accounts:
|
||||
print(f"""
|
||||
- {color("No home directories were found.", RED)}
|
||||
Make sure you are passing the correct `vmail_root` argument. It must match
|
||||
your `mailserver.mailDirectory` setting.""")
|
||||
|
||||
if moves_failed:
|
||||
print(f"""
|
||||
- {color("{moves_failed} home directories could not be renamed", RED)}
|
||||
No reason to panic, but the script tried to rename a home directory and that
|
||||
triggered and error. Check further up what went wrong.""")
|
||||
|
||||
if dry_run:
|
||||
print(f"\n{color('No changes were made.', YELLOW)}")
|
||||
print("Run the script with `--execute` to apply the listed changes.")
|
||||
|
||||
sys.exit(EXIT_OK if moves_failed == 0 else EXIT_ERROR)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="""
|
||||
NixOS Mailserver Migration #4: Dovecot LDAP UUID-based home directories
|
||||
(https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-uuid-based-home-directory)
|
||||
"""
|
||||
)
|
||||
parser.add_argument(
|
||||
"vmail_root", type=Path, help="Path to the `mailserver.mailDirectory`"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ldap-uri",
|
||||
type=str,
|
||||
required=True,
|
||||
help="URI for your LDAP server; ldaps://ldap1.example.com (TLS) or ldap://ldap1.example.com (Plain)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ldap-starttls",
|
||||
action="store_true",
|
||||
help="Enable StartTLS on plain LDAP connections",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ldap-bind-dn",
|
||||
type=str,
|
||||
required=True,
|
||||
help="The distinguished user allow to bind and search the LDAP server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ldap-bind-pw-file",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="Path to a file containing the bind password for the LDAP DN",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ldap-base",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Base DN below which to search for LDAP accounts",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ldap-scope",
|
||||
choices=[
|
||||
"sub",
|
||||
"base",
|
||||
"one",
|
||||
],
|
||||
default="sub",
|
||||
help="Scope relative to the base DN",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ldap-filter",
|
||||
default="(mail=%s)",
|
||||
help="LDAP query that filters for an account by the name in /var/vmail/ldap/<name> field, e.g. mail=%%s or uid=%%s if the name is not an email address.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ldap-attr-uuid",
|
||||
default="entryUUID",
|
||||
help="UUID attribute that uniquely identifies an LDAP account across login name changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--execute", action="store_true", help="Actually perform changes"
|
||||
)
|
||||
parser.add_argument("--verbose", action="store_true", help="Print more details")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.ldap_filter.count("%s") != 1:
|
||||
print(
|
||||
"The --ldap-filter argument must contain exactly one '%s' as a placeholder for the primary email address.",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
def read_ldap_bind_pw():
|
||||
try:
|
||||
with open(args.ldap_bind_pw_file) as fd:
|
||||
return fd.read().strip()
|
||||
except OSError as exc:
|
||||
print(f"Unable to read LDAP bind password file: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
ldap_bind_pw = None
|
||||
if os.geteuid() == 0:
|
||||
# if we're root, read before priv drop
|
||||
ldap_bind_pw = read_ldap_bind_pw()
|
||||
|
||||
check_user(args.vmail_root)
|
||||
|
||||
if ldap_bind_pw is None:
|
||||
ldap_bind_pw = read_ldap_bind_pw()
|
||||
|
||||
ldap_scope: LDAPSearchScope = cast(
|
||||
LDAPSearchScope,
|
||||
{
|
||||
"sub": SUBTREE,
|
||||
"base": BASE,
|
||||
"one": LEVEL,
|
||||
}[args.ldap_scope],
|
||||
)
|
||||
|
||||
main(
|
||||
vmail_root=args.vmail_root,
|
||||
ldap_uri=args.ldap_uri,
|
||||
ldap_starttls=args.ldap_starttls,
|
||||
ldap_bind_dn=args.ldap_bind_dn,
|
||||
ldap_bind_pw=ldap_bind_pw,
|
||||
ldap_base=args.ldap_base,
|
||||
ldap_scope=ldap_scope,
|
||||
ldap_filter=args.ldap_filter,
|
||||
ldap_attr_uuid=args.ldap_attr_uuid,
|
||||
dry_run=not args.execute,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env nix-shell
|
||||
#!nix-shell -i python3 -p python3
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from pwd import getpwnam
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_ERROR = 1
|
||||
|
||||
GREEN = "32"
|
||||
YELLOW = "33"
|
||||
RED = "31"
|
||||
BOLD = "1"
|
||||
|
||||
NO_COLOR = "NO_COLOR" in os.environ
|
||||
|
||||
|
||||
def color(text, code):
|
||||
if NO_COLOR:
|
||||
return text
|
||||
return f"\033[{code}m{text}\033[0m"
|
||||
|
||||
|
||||
def check_user(sieve_root: Path):
|
||||
owner = sieve_root.owner()
|
||||
owner_uid = getpwnam(owner).pw_uid
|
||||
|
||||
if os.geteuid() == owner_uid:
|
||||
return
|
||||
|
||||
try:
|
||||
print(f"Trying to switch effective user id to {owner_uid} ({owner})")
|
||||
os.seteuid(owner_uid)
|
||||
return
|
||||
except PermissionError:
|
||||
print(
|
||||
f"Failed switching to virtual mail user. Please run this script under it, for example by using `sudo -u {owner}`)"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def doveadm_get_user_home(user: str) -> Path:
|
||||
output = subprocess.check_output(
|
||||
["doveadm", "user", "-f", "home", user], text=True, stderr=subprocess.DEVNULL
|
||||
)
|
||||
homedir = Path(output.strip())
|
||||
return homedir
|
||||
|
||||
|
||||
def move(src: Path, dst: Path, dry_run: bool = True) -> bool:
|
||||
print(f'mv "{src}" "{dst}"')
|
||||
if not dry_run:
|
||||
try:
|
||||
shutil.move(src, dst)
|
||||
except OSError as exc:
|
||||
print(f"Rename failed ({src=!s}, {dst=!s}): {exc}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def symlink(target: Path, link: Path, dry_run: bool = True) -> bool:
|
||||
print(f'ln --symbolic --relative "{target}" "{link}"')
|
||||
if not dry_run:
|
||||
try:
|
||||
target_relative = target.relative_to(link.parent)
|
||||
link.symlink_to(target_relative)
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f"Symlinking failed ({target=!s}, {link=!s}): {exc}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main(sieve_root: Path, dry_run: bool = True):
|
||||
print(
|
||||
color(
|
||||
f"\nFind accounts based on existing Sieve script directories in {sieve_root!s}",
|
||||
BOLD,
|
||||
)
|
||||
)
|
||||
|
||||
skipped = 0
|
||||
accounts = set()
|
||||
for path in sieve_root.glob("*"):
|
||||
if not path.is_dir():
|
||||
print(f"- Not a directory ({path=!s}) (skipping)")
|
||||
skipped += 1
|
||||
continue
|
||||
elif not set(path.glob("scripts/*.sieve")):
|
||||
print(f"- No Sieve scripts in directory ({path=!s}) (skipping)")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
account = path.name
|
||||
accounts.add(account)
|
||||
print(f"- Sieve directory found ({path=!s}, {account=})")
|
||||
|
||||
print(
|
||||
color(
|
||||
f"\nLookup home directory of accounts based on the remaining Sieve directories found in {sieve_root!s}",
|
||||
BOLD,
|
||||
)
|
||||
)
|
||||
|
||||
lookup_failed = 0
|
||||
homedirs = {}
|
||||
for account in accounts:
|
||||
try:
|
||||
homedir = doveadm_get_user_home(account)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f"- Home directory lookup failed ({account=}): {exc}")
|
||||
lookup_failed += 1
|
||||
continue
|
||||
print(f"- Home directory retrieved ({account=}, {homedir=!s})")
|
||||
homedirs.update({account: homedir})
|
||||
|
||||
print(
|
||||
color(
|
||||
"\nEnumerate Sieve directories of accounts",
|
||||
BOLD,
|
||||
)
|
||||
)
|
||||
|
||||
plan = {}
|
||||
for account, homedir in homedirs.items():
|
||||
sieve_src = sieve_root / account / "scripts"
|
||||
sieve_dst = homedir / "sieve"
|
||||
plan.update({sieve_src: sieve_dst})
|
||||
|
||||
active = sieve_root / account / "active.sieve"
|
||||
link = homedir / ".dovecot.sieve"
|
||||
# An account may have Sieve scripts but none enabled,
|
||||
# e.g. an out-of-office auto-reply but currently in-office.
|
||||
if not active.is_symlink():
|
||||
print(
|
||||
f"- Account has Sieve scripts but none enabled ({account=}, {active=!s})"
|
||||
)
|
||||
continue
|
||||
active_relative = active.resolve().relative_to(sieve_src)
|
||||
target = sieve_dst / active_relative
|
||||
plan.update({target: link})
|
||||
print(
|
||||
f"- Account has Sieve scripts and one enabled ({account=}, {sieve_src=!s})"
|
||||
)
|
||||
|
||||
print(color("\nThe following operations will be executed:", BOLD))
|
||||
moved = 0
|
||||
moves_failed = 0
|
||||
for src, dst in plan.items():
|
||||
if src.is_dir():
|
||||
if not move(src=src, dst=dst, dry_run=dry_run):
|
||||
moves_failed += 1
|
||||
else:
|
||||
moved += 1
|
||||
else:
|
||||
if not symlink(target=src, link=dst, dry_run=dry_run):
|
||||
moves_failed += 1
|
||||
else:
|
||||
moved += 1
|
||||
|
||||
print(
|
||||
color(
|
||||
"\nMigration summary",
|
||||
BOLD,
|
||||
)
|
||||
)
|
||||
|
||||
if any([skipped, lookup_failed, not accounts, moves_failed]):
|
||||
print("""
|
||||
We strongly recommend reviewing and remediating all potential issues before
|
||||
running with `--execute`. Specific details can be found further up.""")
|
||||
|
||||
if moved:
|
||||
print(f"""
|
||||
- {color(f"{moved} Sieve script directories were migrated successfully.", GREEN)} {"(dry run)" if dry_run else ""}""")
|
||||
|
||||
if skipped and accounts:
|
||||
print(f"""
|
||||
- {color(f"{skipped} paths in {(sieve_root)!s} were skipped.", YELLOW)}
|
||||
These were not a directory or did not contain a ./scripts directory.
|
||||
They should be reviewed but can most likely be deleted.""")
|
||||
|
||||
if lookup_failed:
|
||||
print(f"""
|
||||
- {color(f"{lookup_failed} account lookups failed.", YELLOW)}
|
||||
This could be a problem, because we cannot migrate the Sieve script
|
||||
directory into the home directory without finding the owner of the
|
||||
directory. In practice this can happen if an account was deleted but
|
||||
its Sieve script directory remained.""")
|
||||
|
||||
if not accounts:
|
||||
print(f"""
|
||||
- {color("No Sieve script directories were found.", RED)}
|
||||
Make sure you are passing the correct `sieve_root` argument. It must match
|
||||
your `mailserver.sieveDirectory` setting. In practise this may also happen
|
||||
if simply no account has Sieve scripts.""")
|
||||
|
||||
if moves_failed:
|
||||
print(f"""
|
||||
- {color(f"{moves_failed} Sieve script directories could not be renamed", RED)}
|
||||
No reason to panic, but the script tried to rename a Sieve script directory
|
||||
and that triggered and error. Check further up what went wrong.""")
|
||||
|
||||
if dry_run:
|
||||
print(f"\n{color('No changes were made.', YELLOW)}")
|
||||
print("Run the script with `--execute` to apply the listed changes.")
|
||||
|
||||
sys.exit(EXIT_OK if moves_failed == 0 else EXIT_ERROR)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="""
|
||||
NixOS Mailserver Migration #5: Sieve script directory migration
|
||||
(https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#sieve-script-directory-migration)
|
||||
"""
|
||||
)
|
||||
parser.add_argument(
|
||||
"sieve_root", type=Path, help="Path to the `mailserver.sieveDirectory`"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--execute", action="store_true", help="Actually perform changes"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
check_user(args.sieve_root)
|
||||
main(
|
||||
sieve_root=args.sieve_root,
|
||||
dry_run=not args.execute,
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
network.description = "mail server";
|
||||
|
||||
mailserver =
|
||||
{ config, pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
../default.nix
|
||||
];
|
||||
|
||||
mailserver = {
|
||||
enable = true;
|
||||
fqdn = "mail.example.com";
|
||||
domains = [ "example.com" "example2.com" ];
|
||||
loginAccounts = {
|
||||
"user1@example.com" = {
|
||||
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
|
||||
};
|
||||
};
|
||||
extraVirtualAliases = {
|
||||
"info@example.com" = "user1@example.com";
|
||||
"postmaster@example.com" = "user1@example.com";
|
||||
"abuse@example.com" = "user1@example.com";
|
||||
"user1@example2.com" = "user1@example.com";
|
||||
"info@example2.com" = "user1@example.com";
|
||||
"postmaster@example2.com" = "user1@example.com";
|
||||
"abuse@example2.com" = "user1@example.com";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
mailserver =
|
||||
{ config, pkgs, ... }:
|
||||
{ deployment.targetEnv = "virtualbox";
|
||||
deployment.virtualbox.memorySize = 1024; # megabytes
|
||||
deployment.virtualbox.vcpu = 2; # number of cpus
|
||||
deployment.virtualbox.headless = true;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
[tool.ruff.lint]
|
||||
extend-select = ["ISC"]
|
||||
|
||||
[tool.ruff.lint.flake8-implicit-str-concat]
|
||||
allow-multiline = false
|
||||
+86
-48
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
import sys
|
||||
from textwrap import indent
|
||||
from typing import Any, Mapping
|
||||
|
||||
header = """
|
||||
# Mailserver options
|
||||
@@ -9,74 +11,110 @@ header = """
|
||||
"""
|
||||
|
||||
template = """
|
||||
({key})=
|
||||
`````{{option}} {key}
|
||||
{description}
|
||||
|
||||
{type}
|
||||
{default}
|
||||
{example}
|
||||
|
||||
{description}
|
||||
`````
|
||||
"""
|
||||
|
||||
f = open(sys.argv[1])
|
||||
options = json.load(f)
|
||||
|
||||
groups = ["mailserver.loginAccounts",
|
||||
"mailserver.certificate",
|
||||
"mailserver.dkim",
|
||||
"mailserver.dmarcReporting",
|
||||
"mailserver.fullTextSearch",
|
||||
"mailserver.redis",
|
||||
"mailserver.ldap",
|
||||
"mailserver.monitoring",
|
||||
"mailserver.backup",
|
||||
"mailserver.borgbackup"]
|
||||
groups = [
|
||||
"mailserver.accounts",
|
||||
"mailserver.x509",
|
||||
"mailserver.storage",
|
||||
"mailserver.dkim",
|
||||
"mailserver.srs",
|
||||
"mailserver.dmarcReporting",
|
||||
"mailserver.tlsrpt",
|
||||
"mailserver.fullTextSearch",
|
||||
"mailserver.quota",
|
||||
"mailserver.redis",
|
||||
"mailserver.ldap",
|
||||
"mailserver.monitoring",
|
||||
"mailserver.backup",
|
||||
"mailserver.borgbackup",
|
||||
]
|
||||
|
||||
def render_option_value(opt, attr):
|
||||
if attr in opt:
|
||||
if isinstance(opt[attr], dict) and '_type' in opt[attr]:
|
||||
if opt[attr]['_type'] == 'literalExpression':
|
||||
if '\n' in opt[attr]['text']:
|
||||
res = '\n```nix\n' + opt[attr]['text'].rstrip('\n') + '\n```'
|
||||
else:
|
||||
res = '```{}```'.format(opt[attr]['text'])
|
||||
elif opt[attr]['_type'] == 'literalMD':
|
||||
res = opt[attr]['text']
|
||||
else:
|
||||
s = str(opt[attr])
|
||||
if s == "":
|
||||
res = '`""`'
|
||||
elif '\n' in s:
|
||||
res = '\n```\n' + s.rstrip('\n') + '\n```'
|
||||
else:
|
||||
res = '```{}```'.format(s)
|
||||
res = '- ' + attr + ': ' + res
|
||||
else:
|
||||
res = ""
|
||||
return res
|
||||
|
||||
def print_option(opt):
|
||||
if isinstance(opt['description'], dict) and '_type' in opt['description']: # mdDoc
|
||||
description = opt['description']['text']
|
||||
def md_literal(value: str) -> str:
|
||||
return f"`{value}`"
|
||||
|
||||
|
||||
def md_codefence(value: str, language: str = "nix") -> str:
|
||||
return indent(
|
||||
f"\n```{language}\n{value}\n```",
|
||||
prefix=2 * " ",
|
||||
)
|
||||
|
||||
|
||||
def render_option_value(option: Mapping[str, Any], key: str) -> str:
|
||||
if key not in option:
|
||||
return ""
|
||||
|
||||
value = None
|
||||
|
||||
if isinstance(option[key], dict) and "_type" in option[key]:
|
||||
if option[key]["_type"] == "literalExpression":
|
||||
# multi-line codeblock
|
||||
if "\n" in option[key]["text"]:
|
||||
text = option[key]["text"].rstrip("\n")
|
||||
value = md_codefence(text)
|
||||
# inline codeblock
|
||||
else:
|
||||
value = md_literal(option[key]["text"])
|
||||
# literal markdown
|
||||
elif option[key]["_type"] == "literalMD":
|
||||
value = option[key]["text"]
|
||||
else:
|
||||
assert RuntimeError(f"Unhandled option type {option[key]['_type']}")
|
||||
else:
|
||||
description = opt['description']
|
||||
print(template.format(
|
||||
key=opt['name'],
|
||||
description=description or "",
|
||||
type="- type: ```{}```".format(opt['type']),
|
||||
default=render_option_value(opt, 'default'),
|
||||
example=render_option_value(opt, 'example')))
|
||||
text = str(option[key])
|
||||
if text == "":
|
||||
value = md_literal('""')
|
||||
elif "\n" in text:
|
||||
value = md_codefence(text.rstrip("\n"))
|
||||
else:
|
||||
value = md_literal(text)
|
||||
|
||||
assert value is not None
|
||||
|
||||
return f"- {key}: {value}"
|
||||
|
||||
|
||||
def print_option(option):
|
||||
if (
|
||||
isinstance(option["description"], dict) and "_type" in option["description"]
|
||||
): # mdDoc
|
||||
description = option["description"]["text"]
|
||||
else:
|
||||
description = option["description"]
|
||||
print(
|
||||
template.format(
|
||||
key=option["name"],
|
||||
description=description or "",
|
||||
type=f"- type: {md_literal(option['type'])}",
|
||||
default=render_option_value(option, "defaultText")
|
||||
if "defaultText" in option
|
||||
else render_option_value(option, "default"),
|
||||
example=render_option_value(option, "example"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
print(header)
|
||||
for opt in options:
|
||||
if any([opt['name'].startswith(c) for c in groups]):
|
||||
if any([opt["name"].startswith(c) for c in groups]):
|
||||
continue
|
||||
print_option(opt)
|
||||
|
||||
for c in groups:
|
||||
print('## `{}`'.format(c))
|
||||
print()
|
||||
print(f"## `{c}`\n")
|
||||
for opt in options:
|
||||
if opt['name'].startswith(c):
|
||||
if opt["name"].startswith(c):
|
||||
print_option(opt)
|
||||
|
||||
+157
-87
@@ -1,31 +1,45 @@
|
||||
import smtplib, sys
|
||||
import argparse
|
||||
import os
|
||||
import uuid
|
||||
import imaplib
|
||||
from datetime import datetime, timedelta
|
||||
import email
|
||||
import email.utils
|
||||
import imaplib
|
||||
import smtplib
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import cast
|
||||
|
||||
RETRY = 100
|
||||
|
||||
def _send_mail(smtp_host, smtp_port, smtp_username, from_addr, from_pwd, to_addr, subject, starttls):
|
||||
print("Sending mail with subject '{}'".format(subject))
|
||||
message = "\n".join([
|
||||
"From: {from_addr}",
|
||||
"To: {to_addr}",
|
||||
"Subject: {subject}",
|
||||
"",
|
||||
"This validates our mail server can send to Gmail :/"]).format(
|
||||
from_addr=from_addr,
|
||||
to_addr=to_addr,
|
||||
subject=subject)
|
||||
|
||||
def _send_mail(
|
||||
smtp_host,
|
||||
smtp_port,
|
||||
smtp_username,
|
||||
from_addr,
|
||||
from_pwd,
|
||||
to_addr,
|
||||
subject,
|
||||
starttls,
|
||||
ssl,
|
||||
):
|
||||
print(f"Sending mail with subject '{subject}'")
|
||||
message = "\n".join(
|
||||
[
|
||||
f"From: {from_addr}",
|
||||
f"To: {to_addr}",
|
||||
f"Subject: {subject}",
|
||||
f"Message-ID: {uuid.uuid4()}@mail-check.py",
|
||||
f"Date: {email.utils.formatdate()}",
|
||||
"",
|
||||
"This validates our mail server can send to Gmail :/",
|
||||
]
|
||||
)
|
||||
|
||||
retry = RETRY
|
||||
smtp_class = smtplib.SMTP_SSL if ssl else smtplib.SMTP
|
||||
while True:
|
||||
try:
|
||||
with smtplib.SMTP(smtp_host, port=smtp_port) as smtp:
|
||||
with smtp_class(smtp_host, port=smtp_port) as smtp:
|
||||
try:
|
||||
if starttls:
|
||||
smtp.starttls()
|
||||
@@ -37,7 +51,9 @@ def _send_mail(smtp_host, smtp_port, smtp_username, from_addr, from_pwd, to_addr
|
||||
except smtplib.SMTPResponseException as e:
|
||||
if e.smtp_code == 451: # service unavailable error
|
||||
print(e)
|
||||
elif e.smtp_code == 454: # smtplib.SMTPResponseException: (454, b'4.3.0 Try again later')
|
||||
elif (
|
||||
e.smtp_code == 454
|
||||
): # smtplib.SMTPResponseException: (454, b'4.3.0 Try again later')
|
||||
print(e)
|
||||
else:
|
||||
raise
|
||||
@@ -55,16 +71,18 @@ def _send_mail(smtp_host, smtp_port, smtp_username, from_addr, from_pwd, to_addr
|
||||
print("Retry attempts exhausted")
|
||||
exit(5)
|
||||
|
||||
|
||||
def _read_mail(
|
||||
imap_host,
|
||||
imap_port,
|
||||
imap_username,
|
||||
to_pwd,
|
||||
subject,
|
||||
ignore_dkim_spf,
|
||||
show_body=False,
|
||||
delete=True):
|
||||
print("Reading mail from %s" % imap_username)
|
||||
imap_host,
|
||||
imap_port,
|
||||
imap_username,
|
||||
to_pwd,
|
||||
subject,
|
||||
ignore_dkim_spf,
|
||||
show_body=False,
|
||||
delete=True,
|
||||
):
|
||||
print(f"Reading mail from {imap_username}")
|
||||
|
||||
message = None
|
||||
|
||||
@@ -74,49 +92,62 @@ def _read_mail(
|
||||
|
||||
today = datetime.today()
|
||||
cutoff = today - timedelta(days=1)
|
||||
dt = cutoff.strftime('%d-%b-%Y')
|
||||
dt = cutoff.strftime("%d-%b-%Y")
|
||||
for _ in range(0, RETRY):
|
||||
print("Retrying")
|
||||
obj.select()
|
||||
typ, data = obj.search(None, '(SINCE %s) (SUBJECT "%s")'%(dt, subject))
|
||||
if data == [b'']:
|
||||
_, data = obj.search(None, f'(SINCE {dt}) (SUBJECT "{subject}")')
|
||||
if data == [b""]:
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
uids = data[0].decode("utf-8").split(" ")
|
||||
if len(uids) != 1:
|
||||
print("Warning: %d messages have been found with subject containing %s " % (len(uids), subject))
|
||||
print(
|
||||
f"Warning: {len(uids)} messages have been found with subject containing {subject}"
|
||||
)
|
||||
|
||||
# FIXME: we only consider the first matching message...
|
||||
uid = uids[0]
|
||||
_, raw = obj.fetch(uid, '(RFC822)')
|
||||
_, raw = obj.fetch(uid, "(RFC822)")
|
||||
if delete:
|
||||
obj.store(uid, '+FLAGS', '\\Deleted')
|
||||
obj.store(uid, "+FLAGS", "\\Deleted")
|
||||
obj.expunge()
|
||||
message = email.message_from_bytes(raw[0][1])
|
||||
print("Message with subject '%s' has been found" % message['subject'])
|
||||
assert raw[0] and raw[0][1]
|
||||
message = email.message_from_bytes(cast(bytes, raw[0][1]))
|
||||
print(f"Message with subject '{message['subject']}' has been found")
|
||||
if show_body:
|
||||
for m in message.get_payload():
|
||||
if m.get_content_type() == 'text/plain':
|
||||
print("Body:\n%s" % m.get_payload(decode=True).decode('utf-8'))
|
||||
if message.is_multipart():
|
||||
for part in message.walk():
|
||||
ctype = part.get_content_type()
|
||||
if ctype == "text/plain":
|
||||
body = cast(bytes, part.get_payload(decode=True)).decode()
|
||||
print(f"Body:\n{body}")
|
||||
else:
|
||||
print(f"Body with content type {ctype} not printed")
|
||||
else:
|
||||
body = cast(bytes, message.get_payload(decode=True)).decode()
|
||||
print(f"Body:\n{body}")
|
||||
break
|
||||
|
||||
if message is None:
|
||||
print("Error: no message with subject '%s' has been found in INBOX of %s" % (subject, imap_username))
|
||||
print(
|
||||
f"Error: no message with subject '{subject}' has been found in INBOX of {imap_username}"
|
||||
)
|
||||
exit(1)
|
||||
|
||||
if ignore_dkim_spf:
|
||||
return
|
||||
|
||||
# gmail set this standardized header
|
||||
if 'ARC-Authentication-Results' in message:
|
||||
if "dkim=pass" in message['ARC-Authentication-Results']:
|
||||
if "ARC-Authentication-Results" in message:
|
||||
if "dkim=pass" in message["ARC-Authentication-Results"]:
|
||||
print("DKIM ok")
|
||||
else:
|
||||
print("Error: no DKIM validation found in message:")
|
||||
print(message.as_string())
|
||||
exit(2)
|
||||
if "spf=pass" in message['ARC-Authentication-Results']:
|
||||
if "spf=pass" in message["ARC-Authentication-Results"]:
|
||||
print("SPF ok")
|
||||
else:
|
||||
print("Error: no SPF validation found in message:")
|
||||
@@ -126,71 +157,110 @@ def _read_mail(
|
||||
print("DKIM and SPF verification failed")
|
||||
exit(4)
|
||||
|
||||
|
||||
def send_and_read(args):
|
||||
src_pwd = None
|
||||
if args.src_password_file is not None:
|
||||
src_pwd = args.src_password_file.readline().rstrip()
|
||||
dst_pwd = args.dst_password_file.readline().rstrip()
|
||||
|
||||
if args.imap_username != '':
|
||||
if args.imap_username != "":
|
||||
imap_username = args.imap_username
|
||||
else:
|
||||
imap_username = args.to_addr
|
||||
|
||||
subject = "{}".format(uuid.uuid4())
|
||||
subject = f"{uuid.uuid4()}"
|
||||
|
||||
_send_mail(smtp_host=args.smtp_host,
|
||||
smtp_port=args.smtp_port,
|
||||
smtp_username=args.smtp_username,
|
||||
from_addr=args.from_addr,
|
||||
from_pwd=src_pwd,
|
||||
to_addr=args.to_addr,
|
||||
subject=subject,
|
||||
starttls=args.smtp_starttls)
|
||||
_send_mail(
|
||||
smtp_host=args.smtp_host,
|
||||
smtp_port=args.smtp_port,
|
||||
smtp_username=args.smtp_username,
|
||||
from_addr=args.from_addr,
|
||||
from_pwd=src_pwd,
|
||||
to_addr=args.to_addr,
|
||||
subject=subject,
|
||||
starttls=args.smtp_starttls,
|
||||
ssl=args.smtp_ssl,
|
||||
)
|
||||
|
||||
_read_mail(
|
||||
imap_host=args.imap_host,
|
||||
imap_port=args.imap_port,
|
||||
imap_username=imap_username,
|
||||
to_pwd=dst_pwd,
|
||||
subject=subject,
|
||||
ignore_dkim_spf=args.ignore_dkim_spf,
|
||||
)
|
||||
|
||||
_read_mail(imap_host=args.imap_host,
|
||||
imap_port=args.imap_port,
|
||||
imap_username=imap_username,
|
||||
to_pwd=dst_pwd,
|
||||
subject=subject,
|
||||
ignore_dkim_spf=args.ignore_dkim_spf)
|
||||
|
||||
def read(args):
|
||||
_read_mail(imap_host=args.imap_host,
|
||||
imap_port=args.imap_port,
|
||||
to_addr=args.imap_username,
|
||||
to_pwd=args.imap_password,
|
||||
subject=args.subject,
|
||||
ignore_dkim_spf=args.ignore_dkim_spf,
|
||||
show_body=args.show_body,
|
||||
delete=False)
|
||||
_read_mail(
|
||||
imap_host=args.imap_host,
|
||||
imap_port=args.imap_port,
|
||||
imap_username=args.imap_username,
|
||||
to_pwd=args.imap_password,
|
||||
subject=args.subject,
|
||||
ignore_dkim_spf=args.ignore_dkim_spf,
|
||||
show_body=args.show_body,
|
||||
delete=False,
|
||||
)
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers()
|
||||
|
||||
parser_send_and_read = subparsers.add_parser('send-and-read', description="Send a email with a subject containing a random UUID and then try to read this email from the recipient INBOX.")
|
||||
parser_send_and_read.add_argument('--smtp-host', type=str)
|
||||
parser_send_and_read.add_argument('--smtp-port', type=str, default=25)
|
||||
parser_send_and_read.add_argument('--smtp-starttls', action='store_true')
|
||||
parser_send_and_read.add_argument('--smtp-username', type=str, default='', help="username used for smtp login. If not specified, the from-addr value is used")
|
||||
parser_send_and_read.add_argument('--from-addr', type=str)
|
||||
parser_send_and_read.add_argument('--imap-host', required=True, type=str)
|
||||
parser_send_and_read.add_argument('--imap-port', type=str, default=993)
|
||||
parser_send_and_read.add_argument('--to-addr', type=str, required=True)
|
||||
parser_send_and_read.add_argument('--imap-username', type=str, default='', help="username used for imap login. If not specified, the to-addr value is used")
|
||||
parser_send_and_read.add_argument('--src-password-file', type=argparse.FileType('r'))
|
||||
parser_send_and_read.add_argument('--dst-password-file', required=True, type=argparse.FileType('r'))
|
||||
parser_send_and_read.add_argument('--ignore-dkim-spf', action='store_true', help="to ignore the dkim and spf verification on the read mail")
|
||||
parser_send_and_read = subparsers.add_parser(
|
||||
"send-and-read",
|
||||
description="Send a email with a subject containing a random UUID and then try to read this email from the recipient INBOX.",
|
||||
)
|
||||
parser_send_and_read.add_argument("--smtp-host", type=str)
|
||||
parser_send_and_read.add_argument("--smtp-port", type=str, default=25)
|
||||
parser_send_and_read.add_argument("--smtp-starttls", action="store_true")
|
||||
parser_send_and_read.add_argument("--smtp-ssl", action="store_true")
|
||||
parser_send_and_read.add_argument(
|
||||
"--smtp-username",
|
||||
type=str,
|
||||
default="",
|
||||
help="username used for smtp login. If not specified, the from-addr value is used",
|
||||
)
|
||||
parser_send_and_read.add_argument("--from-addr", type=str)
|
||||
parser_send_and_read.add_argument("--imap-host", required=True, type=str)
|
||||
parser_send_and_read.add_argument("--imap-port", type=str, default=993)
|
||||
parser_send_and_read.add_argument("--to-addr", type=str, required=True)
|
||||
parser_send_and_read.add_argument(
|
||||
"--imap-username",
|
||||
type=str,
|
||||
default="",
|
||||
help="username used for imap login. If not specified, the to-addr value is used",
|
||||
)
|
||||
parser_send_and_read.add_argument("--src-password-file", type=argparse.FileType("r"))
|
||||
parser_send_and_read.add_argument(
|
||||
"--dst-password-file", required=True, type=argparse.FileType("r")
|
||||
)
|
||||
parser_send_and_read.add_argument(
|
||||
"--ignore-dkim-spf",
|
||||
action="store_true",
|
||||
help="to ignore the dkim and spf verification on the read mail",
|
||||
)
|
||||
parser_send_and_read.set_defaults(func=send_and_read)
|
||||
|
||||
parser_read = subparsers.add_parser('read', description="Search for an email with a subject containing 'subject' in the INBOX.")
|
||||
parser_read.add_argument('--imap-host', type=str, default="localhost")
|
||||
parser_read.add_argument('--imap-port', type=str, default=993)
|
||||
parser_read.add_argument('--imap-username', required=True, type=str)
|
||||
parser_read.add_argument('--imap-password', required=True, type=str)
|
||||
parser_read.add_argument('--ignore-dkim-spf', action='store_true', help="to ignore the dkim and spf verification on the read mail")
|
||||
parser_read.add_argument('--show-body', action='store_true', help="print mail text/plain payload")
|
||||
parser_read.add_argument('subject', type=str)
|
||||
parser_read = subparsers.add_parser(
|
||||
"read",
|
||||
description="Search for an email with a subject containing 'subject' in the INBOX.",
|
||||
)
|
||||
parser_read.add_argument("--imap-host", type=str, default="localhost")
|
||||
parser_read.add_argument("--imap-port", type=str, default=993)
|
||||
parser_read.add_argument("--imap-username", required=True, type=str)
|
||||
parser_read.add_argument("--imap-password", required=True, type=str)
|
||||
parser_read.add_argument(
|
||||
"--ignore-dkim-spf",
|
||||
action="store_true",
|
||||
help="to ignore the dkim and spf verification on the read mail",
|
||||
)
|
||||
parser_read.add_argument(
|
||||
"--show-body", action="store_true", help="print mail text/plain payload"
|
||||
)
|
||||
parser_read.add_argument("subject", type=str)
|
||||
parser_read.set_defaults(func=read)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
(import
|
||||
(
|
||||
let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in
|
||||
fetchTarball {
|
||||
url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
|
||||
sha256 = lock.nodes.flake-compat.locked.narHash;
|
||||
}
|
||||
)
|
||||
{ src = ./.; }
|
||||
).shellNix
|
||||
(import (
|
||||
let
|
||||
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
|
||||
in
|
||||
fetchTarball {
|
||||
url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
|
||||
sha256 = lock.nodes.flake-compat.locked.narHash;
|
||||
}
|
||||
) { src = ./.; }).shellNix
|
||||
|
||||
+141
-123
@@ -14,97 +14,115 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ pkgs ? import <nixpkgs> {}, blobs}:
|
||||
{
|
||||
lib,
|
||||
blobs,
|
||||
...
|
||||
}:
|
||||
|
||||
pkgs.nixosTest {
|
||||
{
|
||||
name = "clamav";
|
||||
|
||||
nodes = {
|
||||
server = { config, pkgs, lib, ... }:
|
||||
{
|
||||
imports = [
|
||||
../default.nix
|
||||
./lib/config.nix
|
||||
];
|
||||
server =
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
../default.nix
|
||||
./lib/config.nix
|
||||
];
|
||||
|
||||
virtualisation.memorySize = 1500;
|
||||
virtualisation.memorySize = 1500;
|
||||
|
||||
services.rsyslogd = {
|
||||
enable = true;
|
||||
defaultConfig = ''
|
||||
*.* /dev/console
|
||||
'';
|
||||
};
|
||||
environment.systemPackages = with pkgs; [ netcat ];
|
||||
|
||||
services.clamav.updater.enable = lib.mkForce false;
|
||||
systemd.services.old-clam = {
|
||||
before = [ "clamav-daemon.service" ];
|
||||
requiredBy = [ "clamav-daemon.service" ];
|
||||
description = "ClamAV virus database";
|
||||
|
||||
preStart = ''
|
||||
mkdir -m 0755 -p /var/lib/clamav
|
||||
chown clamav:clamav /var/lib/clamav
|
||||
'';
|
||||
|
||||
script = ''
|
||||
cp ${blobs}/clamav/main.cvd /var/lib/clamav/
|
||||
cp ${blobs}/clamav/daily.cvd /var/lib/clamav/
|
||||
cp ${blobs}/clamav/bytecode.cvd /var/lib/clamav/
|
||||
chown clamav:clamav /var/lib/clamav/*
|
||||
'';
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
PrivateTmp = "yes";
|
||||
PrivateDevices = "yes";
|
||||
};
|
||||
};
|
||||
|
||||
mailserver = {
|
||||
enable = true;
|
||||
fqdn = "mail.example.com";
|
||||
domains = [ "example.com" "example2.com" ];
|
||||
virusScanning = true;
|
||||
|
||||
loginAccounts = {
|
||||
"user1@example.com" = {
|
||||
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
|
||||
aliases = [ "postmaster@example.com" ];
|
||||
catchAll = [ "example.com" ];
|
||||
};
|
||||
"user@example2.com" = {
|
||||
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
|
||||
};
|
||||
};
|
||||
enableImap = true;
|
||||
};
|
||||
|
||||
environment.etc = {
|
||||
"root/eicar.com.txt".text = "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
|
||||
};
|
||||
services.rsyslogd = {
|
||||
enable = true;
|
||||
defaultConfig = ''
|
||||
*.* /dev/console
|
||||
'';
|
||||
};
|
||||
client = { nodes, config, pkgs, ... }: let
|
||||
serverIP = nodes.server.config.networking.primaryIPAddress;
|
||||
clientIP = nodes.client.config.networking.primaryIPAddress;
|
||||
|
||||
services.clamav.updater.enable = lib.mkForce false;
|
||||
systemd.services.old-clam = {
|
||||
before = [ "clamav-daemon.service" ];
|
||||
requiredBy = [ "clamav-daemon.service" ];
|
||||
description = "ClamAV virus database";
|
||||
|
||||
preStart = ''
|
||||
mkdir -m 0755 -p /var/lib/clamav
|
||||
chown clamav:clamav /var/lib/clamav
|
||||
'';
|
||||
|
||||
script = ''
|
||||
cp ${blobs}/clamav/main.cvd /var/lib/clamav/
|
||||
cp ${blobs}/clamav/daily.cvd /var/lib/clamav/
|
||||
cp ${blobs}/clamav/bytecode.cvd /var/lib/clamav/
|
||||
chown clamav:clamav /var/lib/clamav/*
|
||||
'';
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
PrivateTmp = "yes";
|
||||
PrivateDevices = "yes";
|
||||
};
|
||||
};
|
||||
|
||||
mailserver = {
|
||||
enable = true;
|
||||
fqdn = "mail.example.com";
|
||||
domains = [
|
||||
"example.com"
|
||||
"example2.com"
|
||||
];
|
||||
virusScanning = true;
|
||||
|
||||
accounts = {
|
||||
"user1@example.com" = {
|
||||
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
|
||||
aliases = [ "postmaster@example.com" ];
|
||||
catchAll = [ "example.com" ];
|
||||
};
|
||||
"user@example2.com" = {
|
||||
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
|
||||
};
|
||||
};
|
||||
enableImap = true;
|
||||
};
|
||||
|
||||
environment.etc = {
|
||||
"root/eicar.com.txt".text = "X5O!P%@AP[4PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
|
||||
};
|
||||
};
|
||||
client =
|
||||
{ nodes, pkgs, ... }:
|
||||
let
|
||||
serverIP = nodes.server.networking.primaryIPAddress;
|
||||
clientIP = nodes.client.networking.primaryIPAddress;
|
||||
grep-ip = pkgs.writeScriptBin "grep-ip" ''
|
||||
#!${pkgs.stdenv.shell}
|
||||
echo grep '${clientIP}' "$@" >&2
|
||||
exec grep '${clientIP}' "$@"
|
||||
'';
|
||||
in {
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
./lib/config.nix
|
||||
./lib/config.nix
|
||||
];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
fetchmail msmtp procmail findutils grep-ip
|
||||
fetchmail
|
||||
msmtp
|
||||
procmail
|
||||
findutils
|
||||
grep-ip
|
||||
];
|
||||
environment.etc = {
|
||||
"root/.fetchmailrc" = {
|
||||
text = ''
|
||||
poll ${serverIP} with proto IMAP
|
||||
user 'user1@example.com' there with password 'user1' is 'root' here
|
||||
mda procmail
|
||||
poll ${serverIP} with proto IMAP
|
||||
user 'user1@example.com' there with password 'user1' is 'root' here
|
||||
mda procmail
|
||||
'';
|
||||
mode = "0700";
|
||||
};
|
||||
@@ -126,74 +144,74 @@ pkgs.nixosTest {
|
||||
password user2
|
||||
'';
|
||||
};
|
||||
"root/virus-email".text = ''
|
||||
From: User2 <user@example2.com>
|
||||
Content-Type: multipart/mixed;
|
||||
boundary="Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607"
|
||||
Mime-Version: 1.0 (Mac OS X Mail 11.3 \(3445.6.18\))
|
||||
Subject: Testy McTest
|
||||
Message-Id: <94550DD9-1FF1-4ED1-9F09-8812FF2E59AA@example.com>
|
||||
Date: Sat, 12 May 2018 14:15:44 +0200
|
||||
To: User1 <user1@example.com>
|
||||
X-Mailer: Apple Mail (2.3445.6.18)
|
||||
"root/virus-email".text =
|
||||
# mail
|
||||
''
|
||||
From: User2 <user@example2.com>
|
||||
Content-Type: multipart/mixed;
|
||||
boundary="Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607"
|
||||
Mime-Version: 1.0 (Mac OS X Mail 11.3 \(3445.6.18\))
|
||||
Subject: Testy McTest
|
||||
Message-Id: <94550DD9-1FF1-4ED1-9F09-8812FF2E59AA@example.com>
|
||||
Date: Sat, 12 May 2018 14:15:44 +0200
|
||||
To: User1 <user1@example.com>
|
||||
X-Mailer: Apple Mail (2.3445.6.18)
|
||||
|
||||
|
||||
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
|
||||
Content-Transfer-Encoding: 7bit
|
||||
Content-Type: text/plain;
|
||||
charset=us-ascii
|
||||
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
|
||||
Content-Transfer-Encoding: 7bit
|
||||
Content-Type: text/plain;
|
||||
charset=us-ascii
|
||||
|
||||
Hello
|
||||
Hello
|
||||
|
||||
I have attached a dangerous virus.
|
||||
I have attached a dangerous virus.
|
||||
|
||||
Mfg.
|
||||
User2
|
||||
Mfg.
|
||||
User2
|
||||
|
||||
|
||||
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
|
||||
Content-Disposition: attachment;
|
||||
filename=eicar.com.txt
|
||||
Content-Type: text/plain;
|
||||
x-unix-mode=0644;
|
||||
name="eicar.com.txt"
|
||||
Content-Transfer-Encoding: 7bit
|
||||
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
|
||||
Content-Disposition: attachment;
|
||||
filename=eicar.com.txt
|
||||
Content-Type: text/plain;
|
||||
x-unix-mode=0644;
|
||||
name="eicar.com.txt"
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*
|
||||
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607--
|
||||
'';
|
||||
"root/safe-email".text = ''
|
||||
From: User <user@example2.com>
|
||||
To: User1 <user1@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from user@example2.com to user1
|
||||
Reply-To:
|
||||
X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*
|
||||
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607--
|
||||
'';
|
||||
"root/safe-email".text =
|
||||
# mail
|
||||
''
|
||||
From: User <user@example2.com>
|
||||
To: User1 <user1@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from user@example2.com to user1
|
||||
Reply-To:
|
||||
|
||||
Hello User1,
|
||||
Hello User1,
|
||||
|
||||
how are you doing today?
|
||||
how are you doing today?
|
||||
|
||||
XOXO User1
|
||||
'';
|
||||
XOXO User1
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
testScript = { nodes, ... }:
|
||||
''
|
||||
testScript =
|
||||
# python
|
||||
''
|
||||
start_all()
|
||||
|
||||
server.wait_for_unit("multi-user.target")
|
||||
client.wait_for_unit("multi-user.target")
|
||||
|
||||
# TODO put this blocking into the systemd units? I am not sure if rspamd already waits for the clamd socket.
|
||||
server.wait_until_succeeds(
|
||||
"set +e; timeout 1 ${nodes.server.nixpkgs.pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
|
||||
)
|
||||
server.wait_until_succeeds(
|
||||
"set +e; timeout 1 ${nodes.server.nixpkgs.pkgs.netcat}/bin/nc -U /run/clamav/clamd.ctl < /dev/null; [ $? -eq 124 ]"
|
||||
)
|
||||
server.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
|
||||
server.wait_for_open_unix_socket("/run/clamav/clamd.ctl")
|
||||
|
||||
client.execute("cp -p /etc/root/.* ~/")
|
||||
client.succeed("mkdir -p ~/mail")
|
||||
@@ -222,7 +240,7 @@ pkgs.nixosTest {
|
||||
|
||||
with subtest("virus scan email"):
|
||||
client.succeed(
|
||||
'set +o pipefail; msmtp -a user2 user1\@example.com < /etc/root/virus-email 2>&1 | tee /dev/stderr | grep "server message: 554 5\\.7\\.1" >&2'
|
||||
'set +o pipefail; msmtp -a user2 user1@example.com < /etc/root/virus-email 2>&1 | tee /dev/stderr | grep "server message: 554 5\\.7\\.1" >&2'
|
||||
)
|
||||
server.succeed("journalctl -u rspamd | grep -i eicar")
|
||||
# give the mail server some time to process the mail
|
||||
@@ -231,7 +249,7 @@ pkgs.nixosTest {
|
||||
with subtest("no warnings or errors"):
|
||||
server.fail("journalctl -u postfix | grep -i error >&2")
|
||||
server.fail("journalctl -u postfix | grep -i warning >&2")
|
||||
server.fail("journalctl -u dovecot2 | grep -i error >&2")
|
||||
server.fail("journalctl -u dovecot2 | grep -i warning >&2")
|
||||
server.fail("journalctl -u dovecot | grep -i error >&2")
|
||||
server.fail("journalctl -u dovecot | grep -i warning >&2")
|
||||
'';
|
||||
}
|
||||
|
||||
+331
-258
@@ -14,81 +14,103 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ pkgs ? import <nixpkgs> {}, ...}:
|
||||
|
||||
pkgs.nixosTest {
|
||||
{
|
||||
name = "external";
|
||||
|
||||
nodes = {
|
||||
server = { config, pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
../default.nix
|
||||
./lib/config.nix
|
||||
];
|
||||
server =
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
../default.nix
|
||||
./lib/config.nix
|
||||
];
|
||||
|
||||
virtualisation.memorySize = 1024;
|
||||
environment.systemPackages = with pkgs; [
|
||||
netcat
|
||||
openssl
|
||||
];
|
||||
|
||||
services.rsyslogd = {
|
||||
enable = true;
|
||||
defaultConfig = ''
|
||||
*.* /dev/console
|
||||
'';
|
||||
};
|
||||
virtualisation.memorySize = 1024;
|
||||
|
||||
|
||||
mailserver = {
|
||||
enable = true;
|
||||
debug = true;
|
||||
fqdn = "mail.example.com";
|
||||
domains = [ "example.com" "example2.com" ];
|
||||
rewriteMessageId = true;
|
||||
dkimKeyBits = 1535;
|
||||
dmarcReporting = {
|
||||
enable = true;
|
||||
domain = "example.com";
|
||||
organizationName = "ACME Corp";
|
||||
};
|
||||
|
||||
loginAccounts = {
|
||||
"user1@example.com" = {
|
||||
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
|
||||
aliases = [ "postmaster@example.com" ];
|
||||
catchAll = [ "example.com" ];
|
||||
};
|
||||
"user2@example.com" = {
|
||||
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
|
||||
aliases = [ "chuck@example.com" ];
|
||||
};
|
||||
"user@example2.com" = {
|
||||
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
|
||||
};
|
||||
"lowquota@example.com" = {
|
||||
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
|
||||
quota = "1B";
|
||||
};
|
||||
};
|
||||
|
||||
extraVirtualAliases = {
|
||||
"single-alias@example.com" = "user1@example.com";
|
||||
"multi-alias@example.com" = [ "user1@example.com" "user2@example.com" ];
|
||||
};
|
||||
|
||||
enableImap = true;
|
||||
enableImapSsl = true;
|
||||
fullTextSearch = {
|
||||
enable = true;
|
||||
autoIndex = true;
|
||||
# special use depends on https://github.com/NixOS/nixpkgs/pull/93201
|
||||
autoIndexExclude = [ (if (pkgs.lib.versionAtLeast pkgs.lib.version "21") then "\\Junk" else "Junk") ];
|
||||
enforced = "yes";
|
||||
# fts-xapian warns when memory is low, which makes the test fail
|
||||
memoryLimit = 100000;
|
||||
};
|
||||
};
|
||||
services.rsyslogd = {
|
||||
enable = true;
|
||||
defaultConfig = ''
|
||||
*.* /dev/console
|
||||
'';
|
||||
};
|
||||
client = { nodes, config, pkgs, ... }: let
|
||||
serverIP = nodes.server.config.networking.primaryIPAddress;
|
||||
clientIP = nodes.client.config.networking.primaryIPAddress;
|
||||
|
||||
mailserver = {
|
||||
enable = true;
|
||||
debug.dovecot = true; # enabled for sieve script logging
|
||||
fqdn = "mail.example.com";
|
||||
domains = [
|
||||
"example.com"
|
||||
"example2.com"
|
||||
];
|
||||
rewriteMessageId = true;
|
||||
dkim = {
|
||||
defaults.keyLength = 1535;
|
||||
domains."example2.com".selectors = {
|
||||
"dkim-rsa" = {
|
||||
# rsa 1535 bits via defaults
|
||||
};
|
||||
"dkim-ed25519" = {
|
||||
keyType = "ed25519";
|
||||
keyLength = null;
|
||||
};
|
||||
"dkim-file" = {
|
||||
keyFile = "/run/rspamd/dkim-test.key";
|
||||
};
|
||||
};
|
||||
};
|
||||
dmarcReporting.enable = true;
|
||||
|
||||
accounts = {
|
||||
"user1@example.com" = {
|
||||
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
|
||||
aliases = [ "postmaster@example.com" ];
|
||||
catchAll = [ "example.com" ];
|
||||
};
|
||||
"user2@example.com" = {
|
||||
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
|
||||
aliases = [ "chuck@example.com" ];
|
||||
};
|
||||
"user@example2.com" = {
|
||||
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
|
||||
};
|
||||
"lowquota@example.com" = {
|
||||
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
|
||||
quota = "1K";
|
||||
};
|
||||
};
|
||||
|
||||
aliases = {
|
||||
"single-alias@example.com" = "user1@example.com";
|
||||
"multi-alias@example.com" = [
|
||||
"user1@example.com"
|
||||
"user2@example.com"
|
||||
];
|
||||
};
|
||||
|
||||
enableImap = true;
|
||||
enableImapSsl = true;
|
||||
fullTextSearch = {
|
||||
enable = true;
|
||||
autoIndex = true;
|
||||
fallback = false;
|
||||
};
|
||||
};
|
||||
|
||||
# by default quota can be exceeded once with this amount (default: 10M)
|
||||
# this is required to make the quota subtest hard fail on the first attempt.
|
||||
services.dovecot2.settings.quota_storage_grace = "0";
|
||||
};
|
||||
client =
|
||||
{ nodes, pkgs, ... }:
|
||||
let
|
||||
serverIP = nodes.server.networking.primaryIPAddress;
|
||||
clientIP = nodes.client.networking.primaryIPAddress;
|
||||
grep-ip = pkgs.writeScriptBin "grep-ip" ''
|
||||
#!${pkgs.stdenv.shell}
|
||||
echo grep '${clientIP}' "$@" >&2
|
||||
@@ -99,101 +121,119 @@ pkgs.nixosTest {
|
||||
echo grep '^Message-ID:.*@mail.example.com>$' "$@" >&2
|
||||
exec grep '^Message-ID:.*@mail.example.com>$' "$@"
|
||||
'';
|
||||
test-imap-spam = pkgs.writeScriptBin "imap-mark-spam" ''
|
||||
#!${pkgs.python3.interpreter}
|
||||
import imaplib
|
||||
test-imap-spam =
|
||||
pkgs.writeScriptBin "imap-mark-spam"
|
||||
# python
|
||||
''
|
||||
#!${pkgs.python3.interpreter}
|
||||
import imaplib
|
||||
|
||||
with imaplib.IMAP4_SSL('${serverIP}') as imap:
|
||||
imap.login('user1@example.com', 'user1')
|
||||
imap.select()
|
||||
status, [response] = imap.search(None, 'ALL')
|
||||
msg_ids = response.decode("utf-8").split(' ')
|
||||
print(msg_ids)
|
||||
assert status == 'OK'
|
||||
assert len(msg_ids) == 1
|
||||
with imaplib.IMAP4_SSL('${serverIP}') as imap:
|
||||
imap.login('user1@example.com', 'user1')
|
||||
imap.select()
|
||||
status, [response] = imap.search(None, 'ALL')
|
||||
msg_ids = response.decode("utf-8").split(' ')
|
||||
print(msg_ids)
|
||||
assert status == 'OK'
|
||||
assert len(msg_ids) == 1
|
||||
|
||||
imap.copy(','.join(msg_ids), 'Junk')
|
||||
for num in msg_ids:
|
||||
imap.store(num, '+FLAGS', '\\Deleted')
|
||||
imap.expunge()
|
||||
imap.copy(','.join(msg_ids), 'Junk')
|
||||
for num in msg_ids:
|
||||
imap.store(num, '+FLAGS', '\\Deleted')
|
||||
imap.expunge()
|
||||
|
||||
imap.select('Junk')
|
||||
status, [response] = imap.search(None, 'ALL')
|
||||
msg_ids = response.decode("utf-8").split(' ')
|
||||
print(msg_ids)
|
||||
assert status == 'OK'
|
||||
assert len(msg_ids) == 1
|
||||
imap.select('Junk')
|
||||
status, [response] = imap.search(None, 'ALL')
|
||||
msg_ids = response.decode("utf-8").split(' ')
|
||||
print(msg_ids)
|
||||
assert status == 'OK'
|
||||
assert len(msg_ids) == 1
|
||||
|
||||
imap.close()
|
||||
'';
|
||||
test-imap-ham = pkgs.writeScriptBin "imap-mark-ham" ''
|
||||
#!${pkgs.python3.interpreter}
|
||||
import imaplib
|
||||
imap.close()
|
||||
'';
|
||||
test-imap-ham =
|
||||
pkgs.writeScriptBin "imap-mark-ham"
|
||||
# python
|
||||
''
|
||||
#!${pkgs.python3.interpreter}
|
||||
import imaplib
|
||||
|
||||
with imaplib.IMAP4_SSL('${serverIP}') as imap:
|
||||
imap.login('user1@example.com', 'user1')
|
||||
imap.select('Junk')
|
||||
status, [response] = imap.search(None, 'ALL')
|
||||
msg_ids = response.decode("utf-8").split(' ')
|
||||
print(msg_ids)
|
||||
assert status == 'OK'
|
||||
assert len(msg_ids) == 1
|
||||
with imaplib.IMAP4_SSL('${serverIP}') as imap:
|
||||
imap.login('user1@example.com', 'user1')
|
||||
imap.select('Junk')
|
||||
status, [response] = imap.search(None, 'ALL')
|
||||
msg_ids = response.decode("utf-8").split(' ')
|
||||
print(msg_ids)
|
||||
assert status == 'OK'
|
||||
assert len(msg_ids) == 1
|
||||
|
||||
imap.copy(','.join(msg_ids), 'INBOX')
|
||||
for num in msg_ids:
|
||||
imap.store(num, '+FLAGS', '\\Deleted')
|
||||
imap.expunge()
|
||||
imap.copy(','.join(msg_ids), 'INBOX')
|
||||
for num in msg_ids:
|
||||
imap.store(num, '+FLAGS', '\\Deleted')
|
||||
imap.expunge()
|
||||
|
||||
imap.select('INBOX')
|
||||
status, [response] = imap.search(None, 'ALL')
|
||||
msg_ids = response.decode("utf-8").split(' ')
|
||||
print(msg_ids)
|
||||
assert status == 'OK'
|
||||
assert len(msg_ids) == 1
|
||||
imap.select('INBOX')
|
||||
status, [response] = imap.search(None, 'ALL')
|
||||
msg_ids = response.decode("utf-8").split(' ')
|
||||
print(msg_ids)
|
||||
assert status == 'OK'
|
||||
assert len(msg_ids) == 1
|
||||
|
||||
imap.close()
|
||||
'';
|
||||
search = pkgs.writeScriptBin "search" ''
|
||||
#!${pkgs.python3.interpreter}
|
||||
import imaplib
|
||||
import sys
|
||||
imap.close()
|
||||
'';
|
||||
search =
|
||||
pkgs.writeScriptBin "search"
|
||||
# python
|
||||
''
|
||||
#!${pkgs.python3.interpreter}
|
||||
import imaplib
|
||||
import sys
|
||||
|
||||
[_, mailbox, needle] = sys.argv
|
||||
[_, mailbox, needle] = sys.argv
|
||||
|
||||
with imaplib.IMAP4_SSL('${serverIP}') as imap:
|
||||
imap.login('user1@example.com', 'user1')
|
||||
imap.select(mailbox)
|
||||
status, [response] = imap.search(None, 'BODY', repr(needle))
|
||||
msg_ids = [ i for i in response.decode("utf-8").split(' ') if i ]
|
||||
print(msg_ids)
|
||||
assert status == 'OK'
|
||||
assert len(msg_ids) == 1
|
||||
status, response = imap.fetch(msg_ids[0], '(RFC822)')
|
||||
assert status == "OK"
|
||||
assert needle in repr(response)
|
||||
imap.close()
|
||||
'';
|
||||
in {
|
||||
with imaplib.IMAP4_SSL('${serverIP}') as imap:
|
||||
imap.login('user1@example.com', 'user1')
|
||||
imap.select(mailbox)
|
||||
status, [response] = imap.search(None, 'BODY', repr(needle))
|
||||
msg_ids = [ i for i in response.decode("utf-8").split(' ') if i ]
|
||||
print(msg_ids)
|
||||
assert status == 'OK'
|
||||
assert len(msg_ids) == 1
|
||||
status, response = imap.fetch(msg_ids[0], '(RFC822)')
|
||||
assert status == "OK"
|
||||
assert needle in repr(response)
|
||||
imap.close()
|
||||
'';
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
./lib/config.nix
|
||||
./lib/config.nix
|
||||
];
|
||||
environment.systemPackages = with pkgs; [
|
||||
fetchmail msmtp procmail findutils grep-ip check-mail-id test-imap-spam test-imap-ham search
|
||||
fetchmail
|
||||
msmtp
|
||||
procmail
|
||||
findutils
|
||||
grep-ip
|
||||
check-mail-id
|
||||
test-imap-spam
|
||||
test-imap-ham
|
||||
search
|
||||
];
|
||||
environment.etc = {
|
||||
"root/.fetchmailrc" = {
|
||||
text = ''
|
||||
poll ${serverIP} with proto IMAP
|
||||
user 'user1@example.com' there with password 'user1' is 'root' here
|
||||
mda procmail
|
||||
poll ${serverIP} with proto IMAP
|
||||
user 'user1@example.com' there with password 'user1' is 'root' here
|
||||
mda procmail
|
||||
'';
|
||||
mode = "0700";
|
||||
};
|
||||
"root/.fetchmailRcLowQuota" = {
|
||||
text = ''
|
||||
poll ${serverIP} with proto IMAP
|
||||
user 'lowquota@example.com' there with password 'user2' is 'root' here
|
||||
mda procmail
|
||||
poll ${serverIP} with proto IMAP
|
||||
user 'lowquota@example.com' there with password 'user2' is 'root' here
|
||||
mda procmail
|
||||
'';
|
||||
mode = "0700";
|
||||
};
|
||||
@@ -238,120 +278,149 @@ pkgs.nixosTest {
|
||||
password user1
|
||||
'';
|
||||
};
|
||||
"root/email1".text = ''
|
||||
Message-ID: <12345qwerty@host.local.network>
|
||||
From: User2 <user2@example.com>
|
||||
To: User1 <user1@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from user2 to user1
|
||||
Reply-To:
|
||||
"root/email1".text =
|
||||
# mail
|
||||
''
|
||||
Message-ID: <12345qwerty@host.local.network>
|
||||
From: User2 <user2@example.com>
|
||||
To: User1 <user1@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from user2 to user1
|
||||
Reply-To:
|
||||
|
||||
Hello User1,
|
||||
Hello User1,
|
||||
|
||||
how are you doing today?
|
||||
'';
|
||||
"root/email2".text = ''
|
||||
Message-ID: <232323abc@host.local.network>
|
||||
From: User <user@example2.com>
|
||||
To: User1 <user1@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from user@example2.com to user1
|
||||
Reply-To:
|
||||
how are you doing today?
|
||||
'';
|
||||
"root/email2".text =
|
||||
# mail
|
||||
''
|
||||
Message-ID: <232323abc@host.local.network>
|
||||
From: User <user@example2.com>
|
||||
To: User1 <user1@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from user@example2.com to user1
|
||||
Reply-To:
|
||||
|
||||
Hello User1,
|
||||
Hello User1,
|
||||
|
||||
how are you doing today?
|
||||
how are you doing today? I have this exciting text for you, that helps fill
|
||||
your quota.
|
||||
|
||||
XOXO User1
|
||||
'';
|
||||
"root/email3".text = ''
|
||||
Message-ID: <asdfghjkl42@host.local.network>
|
||||
From: Postmaster <postmaster@example.com>
|
||||
To: Chuck <chuck@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from postmaster\@example.com to chuck
|
||||
Reply-To:
|
||||
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod
|
||||
tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At
|
||||
vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren,
|
||||
no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit
|
||||
amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut
|
||||
labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam
|
||||
et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata
|
||||
sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur
|
||||
sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore
|
||||
magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo
|
||||
dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est
|
||||
Lorem ipsum dolor sit amet.
|
||||
|
||||
Hello Chuck,
|
||||
XOXO User1
|
||||
'';
|
||||
"root/email3".text =
|
||||
# mail
|
||||
''
|
||||
Message-ID: <asdfghjkl42@host.local.network>
|
||||
From: Postmaster <postmaster@example.com>
|
||||
To: Chuck <chuck@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from postmaster@example.com to chuck
|
||||
Reply-To:
|
||||
|
||||
I think I may have misconfigured the mail server
|
||||
XOXO Postmaster
|
||||
'';
|
||||
"root/email4".text = ''
|
||||
Message-ID: <sdfsdf@host.local.network>
|
||||
From: Single Alias <single-alias@example.com>
|
||||
To: User1 <user1@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from single-alias\@example.com to user1
|
||||
Reply-To:
|
||||
Hello Chuck,
|
||||
|
||||
Hello User1,
|
||||
I think I may have misconfigured the mail server
|
||||
XOXO Postmaster
|
||||
'';
|
||||
"root/email4".text =
|
||||
# mail
|
||||
''
|
||||
Message-ID: <sdfsdf@host.local.network>
|
||||
From: Single Alias <single-alias@example.com>
|
||||
To: User1 <user1@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from single-alias@example.com to user1
|
||||
Reply-To:
|
||||
|
||||
how are you doing today?
|
||||
Hello User1,
|
||||
|
||||
XOXO User1 aka Single Alias
|
||||
'';
|
||||
"root/email5".text = ''
|
||||
Message-ID: <789asdf@host.local.network>
|
||||
From: User2 <user2@example.com>
|
||||
To: Multi Alias <multi-alias@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from user2\@example.com to multi-alias
|
||||
Reply-To:
|
||||
how are you doing today?
|
||||
|
||||
Hello Multi Alias,
|
||||
XOXO User1 aka Single Alias
|
||||
'';
|
||||
"root/email5".text =
|
||||
# mail
|
||||
''
|
||||
Message-ID: <789asdf@host.local.network>
|
||||
From: User2 <user2@example.com>
|
||||
To: Multi Alias <multi-alias@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from user2@example.com to multi-alias
|
||||
Reply-To:
|
||||
|
||||
how are we doing today?
|
||||
Hello Multi Alias,
|
||||
|
||||
XOXO User1
|
||||
'';
|
||||
"root/email6".text = ''
|
||||
Message-ID: <123457qwerty@host.local.network>
|
||||
From: User2 <user2@example.com>
|
||||
To: User1 <user1@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from user2 to user1
|
||||
Reply-To:
|
||||
how are we doing today?
|
||||
|
||||
Hello User1,
|
||||
XOXO User1
|
||||
'';
|
||||
"root/email6".text =
|
||||
# mail
|
||||
''
|
||||
Message-ID: <123457qwerty@host.local.network>
|
||||
From: User2 <user2@example.com>
|
||||
To: User1 <user1@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from user2 to user1
|
||||
Reply-To:
|
||||
|
||||
this email contains the needle:
|
||||
576a4565b70f5a4c1a0925cabdb587a6
|
||||
'';
|
||||
"root/email7".text = ''
|
||||
Message-ID: <1234578qwerty@host.local.network>
|
||||
From: User2 <user2@example.com>
|
||||
To: User1 <user1@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from user2 to user1
|
||||
Reply-To:
|
||||
Hello User1,
|
||||
|
||||
Hello User1,
|
||||
this email contains the needle:
|
||||
576a4565b70f5a4c1a0925cabdb587a6
|
||||
'';
|
||||
"root/email7".text =
|
||||
# mail
|
||||
''
|
||||
Message-ID: <1234578qwerty@host.local.network>
|
||||
From: User2 <user2@example.com>
|
||||
To: User1 <user1@example.com>
|
||||
Cc:
|
||||
Bcc:
|
||||
Subject: This is a test Email from user2 to user1
|
||||
Reply-To:
|
||||
|
||||
this email does not contain the needle :(
|
||||
'';
|
||||
Hello User1,
|
||||
|
||||
this email does not contain the needle :(
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
testScript = { nodes, ... }:
|
||||
''
|
||||
testScript =
|
||||
# python
|
||||
''
|
||||
start_all()
|
||||
|
||||
server.wait_for_unit("multi-user.target")
|
||||
client.wait_for_unit("multi-user.target")
|
||||
|
||||
# TODO put this blocking into the systemd units?
|
||||
server.wait_until_succeeds(
|
||||
"set +e; timeout 1 ${nodes.server.nixpkgs.pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
|
||||
)
|
||||
server.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
|
||||
|
||||
server.succeed("rspamadm dkim_keygen > /run/rspamd/dkim-test.key")
|
||||
server.succeed("chown rspamd: /run/rspamd/dkim-test.key")
|
||||
|
||||
client.execute("cp -p /etc/root/.* ~/")
|
||||
client.succeed("mkdir -p ~/mail")
|
||||
@@ -367,7 +436,7 @@ pkgs.nixosTest {
|
||||
with subtest("submission port send mail"):
|
||||
# send email from user2 to user1
|
||||
client.succeed(
|
||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email1 >&2"
|
||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email1 >&2"
|
||||
)
|
||||
# give the mail server some time to process the mail
|
||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||
@@ -388,37 +457,39 @@ pkgs.nixosTest {
|
||||
|
||||
with subtest("dkim has user-specified size"):
|
||||
server.succeed(
|
||||
"openssl rsa -in /var/dkim/example.com.mail.key -text -noout | grep 'Private-Key: (1535 bit'"
|
||||
"openssl rsa -in /var/dkim/example2.com.dkim-rsa.key -text -noout | grep 'Private-Key: (1535 bit'"
|
||||
)
|
||||
|
||||
with subtest("dkim singing, multiple domains"):
|
||||
with subtest("dkim signing, multiple domains"):
|
||||
client.execute("rm ~/mail/*")
|
||||
# send email from user2 to user1
|
||||
client.succeed(
|
||||
"msmtp -a test2 --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email2 >&2"
|
||||
"msmtp -a test2 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email2 >&2"
|
||||
)
|
||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
||||
client.succeed("fetchmail --nosslcertck -v")
|
||||
client.succeed("cat ~/mail/* >&2")
|
||||
# make sure it is dkim signed
|
||||
client.succeed("grep DKIM ~/mail/*")
|
||||
client.succeed("grep 's=dkim-rsa' ~/mail/*")
|
||||
client.succeed("grep 's=dkim-ed25519' ~/mail/*")
|
||||
client.succeed("grep 's=dkim-file' ~/mail/*")
|
||||
|
||||
with subtest("aliases"):
|
||||
client.execute("rm ~/mail/*")
|
||||
# send email from chuck to postmaster
|
||||
client.succeed(
|
||||
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on postmaster\@example.com < /etc/root/email2 >&2"
|
||||
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on postmaster@example.com < /etc/root/email2 >&2"
|
||||
)
|
||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
||||
client.succeed("fetchmail --nosslcertck -v")
|
||||
|
||||
with subtest("catchAlls"):
|
||||
with subtest("domain catch-all"):
|
||||
client.execute("rm ~/mail/*")
|
||||
# send email from chuck to non exsitent account
|
||||
# send email from chuck to non-existent account
|
||||
client.succeed(
|
||||
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lol\@example.com < /etc/root/email2 >&2"
|
||||
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lol@example.com < /etc/root/email2 >&2"
|
||||
)
|
||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
||||
@@ -427,18 +498,18 @@ pkgs.nixosTest {
|
||||
client.execute("rm ~/mail/*")
|
||||
# send email from user1 to chuck
|
||||
client.succeed(
|
||||
"msmtp -a test4 --tls=on --tls-certcheck=off --auth=on chuck\@example.com < /etc/root/email2 >&2"
|
||||
"msmtp -a test4 --tls=on --tls-certcheck=off --auth=on chuck@example.com < /etc/root/email2 >&2"
|
||||
)
|
||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||
# fetchmail returns EXIT_CODE 1 when no new mail
|
||||
# if this succeeds, it means that user1 recieved the mail that was intended for chuck.
|
||||
# if this succeeds, it means that user1 received the mail that was intended for chuck.
|
||||
client.fail("fetchmail --nosslcertck -v")
|
||||
|
||||
with subtest("extraVirtualAliases"):
|
||||
with subtest("Test sending from alias address (mailserver.aliases)"):
|
||||
client.execute("rm ~/mail/*")
|
||||
# send email from single-alias to user1
|
||||
client.succeed(
|
||||
"msmtp -a test5 --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email4 >&2"
|
||||
"msmtp -a test5 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email4 >&2"
|
||||
)
|
||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
||||
@@ -447,7 +518,7 @@ pkgs.nixosTest {
|
||||
client.execute("rm ~/mail/*")
|
||||
# send email from user1 to multi-alias (user{1,2}@example.com)
|
||||
client.succeed(
|
||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on multi-alias\@example.com < /etc/root/email5 >&2"
|
||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on multi-alias@example.com < /etc/root/email5 >&2"
|
||||
)
|
||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
||||
@@ -457,8 +528,10 @@ pkgs.nixosTest {
|
||||
client.execute("rm ~/mail/*")
|
||||
client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc")
|
||||
|
||||
server.log(server.succeed("doveadm quota get -u lowquota@example.com"))
|
||||
|
||||
client.succeed(
|
||||
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lowquota\@example.com < /etc/root/email2 >&2"
|
||||
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lowquota@example.com < /etc/root/email2 >&2"
|
||||
)
|
||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||
# fetchmail returns EXIT_CODE 0 when it retrieves mail
|
||||
@@ -467,23 +540,23 @@ pkgs.nixosTest {
|
||||
with subtest("imap sieve junk trainer"):
|
||||
# send email from user2 to user1
|
||||
client.succeed(
|
||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email1 >&2"
|
||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email1 >&2"
|
||||
)
|
||||
# give the mail server some time to process the mail
|
||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||
|
||||
client.succeed("imap-mark-spam >&2")
|
||||
server.wait_until_succeeds("journalctl -u dovecot2 | grep -i sa-learn-spam.sh >&2")
|
||||
server.wait_until_succeeds("journalctl -u dovecot | grep -i rspamd-learn-spam.sh >&2")
|
||||
client.succeed("imap-mark-ham >&2")
|
||||
server.wait_until_succeeds("journalctl -u dovecot2 | grep -i sa-learn-ham.sh >&2")
|
||||
server.wait_until_succeeds("journalctl -u dovecot | grep -i rspamd-learn-ham.sh >&2")
|
||||
|
||||
with subtest("full text search and indexation"):
|
||||
# send 2 email from user2 to user1
|
||||
client.succeed(
|
||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email6 >&2"
|
||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email6 >&2"
|
||||
)
|
||||
client.succeed(
|
||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email7 >&2"
|
||||
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email7 >&2"
|
||||
)
|
||||
# give the mail server some time to process the mail
|
||||
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
|
||||
@@ -493,23 +566,23 @@ pkgs.nixosTest {
|
||||
# should fail because this folder is not indexed
|
||||
client.fail("search Junk a >&2")
|
||||
# check that search really goes through the indexer
|
||||
server.succeed(
|
||||
"journalctl -u dovecot2 | grep -E 'indexer-worker.* Done indexing .INBOX.' >&2"
|
||||
)
|
||||
server.succeed("journalctl -u dovecot | grep 'fts-flatcurve(INBOX): Query ' >&2")
|
||||
# check that Junk is not indexed
|
||||
server.fail("journalctl -u dovecot2 | grep 'indexer-worker' | grep -i 'JUNK' >&2")
|
||||
server.fail("journalctl -u dovecot | grep 'fts-flatcurve(JUNK): Indexing ' >&2")
|
||||
|
||||
with subtest("dmarc reporting"):
|
||||
server.systemctl("start rspamd-dmarc-reporter.service")
|
||||
server.wait_until_succeeds("journalctl -eu rspamd-dmarc-reporter.service -o cat | grep -q 'No reports for '")
|
||||
|
||||
with subtest("no warnings or errors"):
|
||||
server.fail("journalctl -u postfix | grep -i error >&2")
|
||||
server.fail("journalctl -u postfix | grep -i warning >&2")
|
||||
server.fail("journalctl -u dovecot2 | grep -i error >&2")
|
||||
server.fail("journalctl -u dovecot | grep -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2")
|
||||
# harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html
|
||||
server.fail(
|
||||
"journalctl -u dovecot2 |grep -v 'Expunged message reappeared, giving a new UID'| grep -i warning >&2"
|
||||
"journalctl -u dovecot | \
|
||||
grep -v 'Expunged message reappeared, giving a new UID' | \
|
||||
grep -v 'Time moved forwards' | \
|
||||
grep -i warning >&2"
|
||||
)
|
||||
'';
|
||||
}
|
||||
|
||||
+274
-145
@@ -14,7 +14,10 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
{ pkgs ? import <nixpkgs> {}, ...}:
|
||||
{
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
sendMail = pkgs.writeTextFile {
|
||||
@@ -27,169 +30,295 @@ let
|
||||
'';
|
||||
};
|
||||
|
||||
hashPassword = password: pkgs.runCommand
|
||||
"password-${password}-hashed"
|
||||
{ buildInputs = [ pkgs.mkpasswd ]; inherit password; } ''
|
||||
mkpasswd -sm bcrypt <<<"$password" > $out
|
||||
'';
|
||||
hashPassword =
|
||||
password:
|
||||
pkgs.runCommand "password-${password}-hashed"
|
||||
{
|
||||
buildInputs = [ pkgs.mkpasswd ];
|
||||
inherit password;
|
||||
}
|
||||
''
|
||||
mkpasswd -s <<<"$password" > $out
|
||||
'';
|
||||
|
||||
hashPasswordWithScheme =
|
||||
password:
|
||||
pkgs.runCommand "password-${password}-hashed-with-scheme"
|
||||
{
|
||||
buildInputs = [ pkgs.dovecot ];
|
||||
inherit password;
|
||||
}
|
||||
''
|
||||
printf "$password\n$password\n" | doveadm -O pw -s SSHA256 > $out
|
||||
'';
|
||||
|
||||
hashedPasswordFile = hashPassword "my-password";
|
||||
hashedPasswordFileWithScheme = hashPasswordWithScheme "my-password";
|
||||
passwordFile = pkgs.writeText "password" "my-password";
|
||||
in
|
||||
pkgs.nixosTest {
|
||||
{
|
||||
name = "internal";
|
||||
|
||||
nodes = {
|
||||
machine = { config, pkgs, ... }: {
|
||||
imports = [
|
||||
./../default.nix
|
||||
./lib/config.nix
|
||||
];
|
||||
machine =
|
||||
{ pkgs, lib, ... }:
|
||||
{
|
||||
imports = [
|
||||
./../default.nix
|
||||
./lib/config.nix
|
||||
];
|
||||
|
||||
virtualisation.memorySize = 1024;
|
||||
virtualisation.memorySize = 1024;
|
||||
|
||||
environment.systemPackages = [
|
||||
(pkgs.writeScriptBin "mail-check" ''
|
||||
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
|
||||
'')];
|
||||
environment.systemPackages = [
|
||||
(pkgs.writeScriptBin "mail-check" ''
|
||||
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
|
||||
'')
|
||||
]
|
||||
++ (with pkgs; [
|
||||
curl
|
||||
openssl
|
||||
netcat
|
||||
]);
|
||||
|
||||
mailserver = {
|
||||
enable = true;
|
||||
fqdn = "mail.example.com";
|
||||
domains = [ "example.com" "domain.com" ];
|
||||
localDnsResolver = false;
|
||||
|
||||
loginAccounts = {
|
||||
"user1@example.com" = {
|
||||
hashedPasswordFile = hashedPasswordFile;
|
||||
};
|
||||
"user2@example.com" = {
|
||||
hashedPasswordFile = hashedPasswordFile;
|
||||
aliasesRegexp = [''/^user2.*@domain\.com$/''];
|
||||
};
|
||||
"send-only@example.com" = {
|
||||
hashedPasswordFile = hashPassword "send-only";
|
||||
sendOnly = true;
|
||||
systemd.tmpfiles.settings."mailserver-test-passwords" = {
|
||||
"/run/passwords/user3" = {
|
||||
f = {
|
||||
argument = "my-password";
|
||||
mode = "0600";
|
||||
};
|
||||
};
|
||||
};
|
||||
forwards = {
|
||||
# user2@example.com is a local account and its mails are
|
||||
# also forwarded to user1@example.com
|
||||
"user2@example.com" = "user1@example.com";
|
||||
|
||||
systemd.services.dovecot.serviceConfig.CacheDirectory = "dovecot";
|
||||
|
||||
mailserver = {
|
||||
enable = true;
|
||||
fqdn = "mail.example.com";
|
||||
domains = [
|
||||
"example.com"
|
||||
"domain.com"
|
||||
];
|
||||
localDnsResolver = false;
|
||||
|
||||
accounts = {
|
||||
"user1@example.com" = {
|
||||
hashedPasswordFile = hashedPasswordFile;
|
||||
};
|
||||
"user2@example.com" = {
|
||||
hashedPasswordFile = hashedPasswordFile;
|
||||
aliasesRegexp = [ ''/^user2.*@domain\.com$/'' ];
|
||||
};
|
||||
"user3@example.com" = {
|
||||
passwordFile = "/run/passwords/user3";
|
||||
sieveScript = lib.readFile ./lib/redirect.sieve;
|
||||
};
|
||||
"user4@example.com" = {
|
||||
hashedPasswordFile = hashedPasswordFileWithScheme;
|
||||
};
|
||||
"send-only@example.com" = {
|
||||
hashedPasswordFile = hashPassword "send-only";
|
||||
sendOnly = true;
|
||||
};
|
||||
};
|
||||
forwards = {
|
||||
# user2@example.com is a local account and its mails are
|
||||
# also forwarded to user1@example.com
|
||||
"user2@example.com" = "user1@example.com";
|
||||
};
|
||||
|
||||
storage = {
|
||||
gid = 5000;
|
||||
group = "vmail";
|
||||
};
|
||||
|
||||
indexDir = "/var/cache/dovecot/fts";
|
||||
|
||||
enableImap = false;
|
||||
};
|
||||
|
||||
vmailGroupName = "vmail";
|
||||
vmailUID = 5000;
|
||||
|
||||
enableImap = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
testScript = ''
|
||||
machine.start()
|
||||
machine.wait_for_unit("multi-user.target")
|
||||
testScript =
|
||||
{
|
||||
nodes,
|
||||
...
|
||||
}:
|
||||
# python
|
||||
''
|
||||
machine.start()
|
||||
machine.wait_for_unit("multi-user.target")
|
||||
machine.wait_for_unit("dovecot.service")
|
||||
|
||||
# Regression test for https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues/205
|
||||
with subtest("mail forwarded can are locally kept"):
|
||||
# A mail sent to user2@example.com is in the user1@example.com mailbox
|
||||
machine.succeed(
|
||||
" ".join(
|
||||
[
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 587",
|
||||
"--smtp-starttls",
|
||||
"--smtp-host localhost",
|
||||
"--imap-host localhost",
|
||||
"--imap-username user1@example.com",
|
||||
"--from-addr user1@example.com",
|
||||
"--to-addr user2@example.com",
|
||||
"--src-password-file ${passwordFile}",
|
||||
"--dst-password-file ${passwordFile}",
|
||||
"--ignore-dkim-spf",
|
||||
]
|
||||
)
|
||||
)
|
||||
# A mail sent to user2@example.com is in the user2@example.com mailbox
|
||||
machine.succeed(
|
||||
" ".join(
|
||||
[
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 587",
|
||||
"--smtp-starttls",
|
||||
"--smtp-host localhost",
|
||||
"--imap-host localhost",
|
||||
"--imap-username user2@example.com",
|
||||
"--from-addr user1@example.com",
|
||||
"--to-addr user2@example.com",
|
||||
"--src-password-file ${passwordFile}",
|
||||
"--dst-password-file ${passwordFile}",
|
||||
"--ignore-dkim-spf",
|
||||
]
|
||||
)
|
||||
)
|
||||
machine.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
|
||||
|
||||
with subtest("regex email alias are received"):
|
||||
# A mail sent to user2-regex-alias@domain.com is in the user2@example.com mailbox
|
||||
machine.succeed(
|
||||
" ".join(
|
||||
[
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 587",
|
||||
"--smtp-starttls",
|
||||
"--smtp-host localhost",
|
||||
"--imap-host localhost",
|
||||
"--imap-username user2@example.com",
|
||||
"--from-addr user1@example.com",
|
||||
"--to-addr user2-regex-alias@domain.com",
|
||||
"--src-password-file ${passwordFile}",
|
||||
"--dst-password-file ${passwordFile}",
|
||||
"--ignore-dkim-spf",
|
||||
]
|
||||
)
|
||||
)
|
||||
# Regression test for https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues/205
|
||||
with subtest("mail forwarded can are locally kept"):
|
||||
# A mail sent to user2@example.com via explicit TLS is in the user1@example.com mailbox
|
||||
machine.succeed(
|
||||
" ".join(
|
||||
[
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 587",
|
||||
"--smtp-starttls",
|
||||
"--smtp-host localhost",
|
||||
"--imap-host localhost",
|
||||
"--imap-username user1@example.com",
|
||||
"--from-addr user1@example.com",
|
||||
"--to-addr user2@example.com",
|
||||
"--src-password-file ${passwordFile}",
|
||||
"--dst-password-file ${passwordFile}",
|
||||
"--ignore-dkim-spf",
|
||||
]
|
||||
)
|
||||
)
|
||||
# A mail sent to user2@example.com via implicit TLS is in the user2@example.com mailbox
|
||||
machine.succeed(
|
||||
" ".join(
|
||||
[
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 465",
|
||||
"--smtp-ssl",
|
||||
"--smtp-host localhost",
|
||||
"--imap-host localhost",
|
||||
"--imap-username user2@example.com",
|
||||
"--from-addr user1@example.com",
|
||||
"--to-addr user2@example.com",
|
||||
"--src-password-file ${passwordFile}",
|
||||
"--dst-password-file ${passwordFile}",
|
||||
"--ignore-dkim-spf",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
with subtest("user can send from regex email alias"):
|
||||
# A mail sent from user2-regex-alias@domain.com, using user2@example.com credentials is received
|
||||
machine.succeed(
|
||||
" ".join(
|
||||
[
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 587",
|
||||
"--smtp-starttls",
|
||||
"--smtp-host localhost",
|
||||
"--imap-host localhost",
|
||||
"--smtp-username user2@example.com",
|
||||
"--from-addr user2-regex-alias@domain.com",
|
||||
"--to-addr user1@example.com",
|
||||
"--src-password-file ${passwordFile}",
|
||||
"--dst-password-file ${passwordFile}",
|
||||
"--ignore-dkim-spf",
|
||||
]
|
||||
)
|
||||
)
|
||||
with subtest("regex email alias are received"):
|
||||
# A mail sent to user2-regex-alias@domain.com via explicit TLS is in the user2@example.com mailbox
|
||||
machine.succeed(
|
||||
" ".join(
|
||||
[
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 587",
|
||||
"--smtp-starttls",
|
||||
"--smtp-host localhost",
|
||||
"--imap-host localhost",
|
||||
"--imap-username user2@example.com",
|
||||
"--from-addr user1@example.com",
|
||||
"--to-addr user2-regex-alias@domain.com",
|
||||
"--src-password-file ${passwordFile}",
|
||||
"--dst-password-file ${passwordFile}",
|
||||
"--ignore-dkim-spf",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
with subtest("vmail gid is set correctly"):
|
||||
machine.succeed("getent group vmail | grep 5000")
|
||||
with subtest("user can send from regex email alias"):
|
||||
# A mail sent to user1@example.com from user2-regex-alias@domain.com by
|
||||
# user2@example.com via implicit TLS is in the user1@example.com mailbox
|
||||
machine.succeed(
|
||||
" ".join(
|
||||
[
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 465",
|
||||
"--smtp-ssl",
|
||||
"--smtp-host localhost",
|
||||
"--imap-host localhost",
|
||||
"--smtp-username user2@example.com",
|
||||
"--from-addr user2-regex-alias@domain.com",
|
||||
"--to-addr user1@example.com",
|
||||
"--src-password-file ${passwordFile}",
|
||||
"--dst-password-file ${passwordFile}",
|
||||
"--ignore-dkim-spf",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
with subtest("mail to send only accounts is rejected"):
|
||||
machine.wait_for_open_port(25)
|
||||
# TODO put this blocking into the systemd units
|
||||
machine.wait_until_succeeds(
|
||||
"set +e; timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
|
||||
)
|
||||
machine.succeed(
|
||||
"cat ${sendMail} | ${pkgs.netcat-gnu}/bin/nc localhost 25 | grep -q 'This account cannot receive emails'"
|
||||
)
|
||||
with subtest("vmail gid is set correctly"):
|
||||
machine.succeed("getent group vmail | grep 5000")
|
||||
|
||||
with subtest("rspamd controller serves web ui"):
|
||||
machine.succeed(
|
||||
"set +o pipefail; ${pkgs.curl}/bin/curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q '<body>'"
|
||||
)
|
||||
with subtest("Check dovecot maildir and index locations"):
|
||||
# If these paths change we need a migration
|
||||
machine.succeed("doveadm user -f home user1@example.com | grep ${nodes.machine.mailserver.storage.path}/example.com/user1")
|
||||
machine.succeed("doveadm user -f mail_path user1@example.com | grep ${nodes.machine.mailserver.storage.path}/example.com/user1/mail")
|
||||
machine.succeed("doveadm user -f mail_index_path user1@example.com | grep ${nodes.machine.mailserver.indexDir}/example.com/user1")
|
||||
|
||||
with subtest("imap port 143 is closed and imaps is serving SSL"):
|
||||
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("mail to send only accounts is rejected"):
|
||||
machine.wait_for_open_port(25)
|
||||
# TODO put this blocking into the systemd units
|
||||
machine.wait_until_succeeds(
|
||||
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
|
||||
)
|
||||
machine.succeed(
|
||||
"cat ${sendMail} | nc localhost 25 | grep -q '554 5.5.0 Error'"
|
||||
)
|
||||
|
||||
with subtest("rspamd controller serves web ui"):
|
||||
machine.succeed(
|
||||
"set +o pipefail; curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q '<body>'"
|
||||
)
|
||||
|
||||
with subtest("user with plaintext password file can send and receive"):
|
||||
machine.succeed(
|
||||
" ".join(
|
||||
[
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 587",
|
||||
"--smtp-starttls",
|
||||
"--smtp-host localhost",
|
||||
"--imap-host localhost",
|
||||
"--imap-username user3@example.com",
|
||||
"--from-addr user3@example.com",
|
||||
"--to-addr user3@example.com",
|
||||
"--src-password-file ${passwordFile}",
|
||||
"--dst-password-file ${passwordFile}",
|
||||
"--ignore-dkim-spf",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
with subtest("user with scheme-prefixed hashedPasswordFile can send and receive"):
|
||||
machine.succeed(
|
||||
" ".join(
|
||||
[
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 587",
|
||||
"--smtp-starttls",
|
||||
"--smtp-host localhost",
|
||||
"--imap-host localhost",
|
||||
"--imap-username user4@example.com",
|
||||
"--from-addr user4@example.com",
|
||||
"--to-addr user4@example.com",
|
||||
"--src-password-file ${passwordFile}",
|
||||
"--dst-password-file ${passwordFile}",
|
||||
"--ignore-dkim-spf",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
with subtest("user's static Sieve script is being executed"):
|
||||
# user3@example.com has a cfg.sieveScript that forwards every
|
||||
# mail sent from user1@example.com back to user1@example.com
|
||||
machine.succeed(
|
||||
" ".join(
|
||||
[
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 587",
|
||||
"--smtp-starttls",
|
||||
"--smtp-host localhost",
|
||||
"--imap-host localhost",
|
||||
"--imap-username user1@example.com",
|
||||
"--from-addr user1@example.com",
|
||||
"--to-addr user3@example.com",
|
||||
"--src-password-file ${passwordFile}",
|
||||
"--dst-password-file ${passwordFile}",
|
||||
"--ignore-dkim-spf",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
with subtest("imap port 143 is closed and imaps is serving SSL"):
|
||||
machine.wait_for_closed_port(143)
|
||||
machine.wait_for_open_port(993)
|
||||
machine.succeed(
|
||||
"echo | openssl s_client -connect localhost:993 | grep 'New, TLS'"
|
||||
)
|
||||
'';
|
||||
}
|
||||
|
||||
+350
-154
@@ -1,183 +1,379 @@
|
||||
{ pkgs ? import <nixpkgs> {}
|
||||
, ...
|
||||
{
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
hashPassword =
|
||||
password:
|
||||
pkgs.runCommand "password-${password}-hashed"
|
||||
{
|
||||
buildInputs = [ pkgs.mkpasswd ];
|
||||
inherit password;
|
||||
}
|
||||
''
|
||||
mkpasswd -s <<<"$password" > $out
|
||||
'';
|
||||
|
||||
bindPassword = "unsafegibberish";
|
||||
alicePassword = "testalice";
|
||||
bobPassword = "testbob";
|
||||
carolPassword = "testcarol";
|
||||
malloryPassword = "testmallory";
|
||||
in
|
||||
pkgs.nixosTest {
|
||||
{
|
||||
name = "ldap";
|
||||
|
||||
nodes = {
|
||||
machine = { config, pkgs, ... }: {
|
||||
imports = [
|
||||
./../default.nix
|
||||
./lib/config.nix
|
||||
];
|
||||
machine =
|
||||
{ pkgs, lib, ... }:
|
||||
{
|
||||
imports = [
|
||||
../default.nix
|
||||
./lib/config.nix
|
||||
];
|
||||
|
||||
virtualisation.memorySize = 1024;
|
||||
environment.systemPackages = [
|
||||
(pkgs.writeScriptBin "mail-check" ''
|
||||
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
|
||||
'')
|
||||
];
|
||||
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
permitRootLogin = "yes";
|
||||
};
|
||||
environment.etc.bind-password.text = bindPassword;
|
||||
|
||||
environment.systemPackages = [
|
||||
(pkgs.writeScriptBin "mail-check" ''
|
||||
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
|
||||
'')];
|
||||
services.openldap = {
|
||||
enable = true;
|
||||
settings = {
|
||||
children = {
|
||||
"cn=schema".includes = [
|
||||
"${pkgs.openldap}/etc/schema/core.ldif"
|
||||
"${pkgs.openldap}/etc/schema/cosine.ldif"
|
||||
"${pkgs.openldap}/etc/schema/inetorgperson.ldif"
|
||||
"${pkgs.openldap}/etc/schema/nis.ldif"
|
||||
];
|
||||
"olcDatabase={1}mdb" = {
|
||||
attrs = {
|
||||
objectClass = [
|
||||
"olcDatabaseConfig"
|
||||
"olcMdbConfig"
|
||||
];
|
||||
olcDatabase = "{1}mdb";
|
||||
olcDbDirectory = "/var/lib/openldap/example";
|
||||
olcSuffix = "dc=example";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
declarativeContents."dc=example" =
|
||||
#ldif
|
||||
''
|
||||
dn: dc=example
|
||||
objectClass: domain
|
||||
dc: example
|
||||
|
||||
environment.etc.bind-password.text = bindPassword;
|
||||
dn: cn=mail,dc=example
|
||||
objectClass: organizationalRole
|
||||
objectClass: simpleSecurityObject
|
||||
objectClass: top
|
||||
cn: mail
|
||||
# unsafegibberish
|
||||
userPassword: {SSHA}JNr6l3s/RHo1LKRXqFsJg8sXznyRid8L
|
||||
|
||||
services.openldap = {
|
||||
enable = true;
|
||||
settings = {
|
||||
children = {
|
||||
"cn=schema".includes = [
|
||||
"${pkgs.openldap}/etc/schema/core.ldif"
|
||||
"${pkgs.openldap}/etc/schema/cosine.ldif"
|
||||
"${pkgs.openldap}/etc/schema/inetorgperson.ldif"
|
||||
"${pkgs.openldap}/etc/schema/nis.ldif"
|
||||
dn: ou=users,dc=example
|
||||
objectClass: organizationalUnit
|
||||
ou: users
|
||||
|
||||
dn: cn=alice,ou=users,dc=example
|
||||
entryUUID: c52f777b-a6e8-4507-80f9-c4de47e8520d
|
||||
objectClass: inetOrgPerson
|
||||
uid: alice
|
||||
sn: Foo
|
||||
mail: alice@example.com
|
||||
# testalice
|
||||
userPassword: {SSHA}gkJq4Dm4jfIKjxviR0WD63wMt0Ti6zMB
|
||||
|
||||
dn: cn=bob,ou=users,dc=example
|
||||
entryUUID: f3b4e8ea-087f-42cc-95f0-cbfd99386092
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: posixAccount
|
||||
uid: bob
|
||||
uidNumber: 9999
|
||||
gidNumber: 9999
|
||||
sn: Bar
|
||||
mail: bob@example.com
|
||||
homeDirectory: /home/bob
|
||||
# testbob
|
||||
userPassword: {SSHA}qqUveZGZrDrjYFnREXLDZc//y89RppVN
|
||||
|
||||
dn: cn=carol,ou=users,dc=example
|
||||
entryUUID: 41240499-27e2-4fa2-be4f-4113a77661b1
|
||||
objectClass: inetOrgPerson
|
||||
uid: carol
|
||||
sn: Baz
|
||||
mail: carol@example.com
|
||||
# testcarol
|
||||
userPassword: {SSHA}69HOuP+OPWE+3+tDucFZxzXDC7p4e3ML
|
||||
|
||||
dn: cn=frank,ou=users,dc=example
|
||||
entryUUID: ca16f594-f6b2-418f-87d3-0d02d746461f
|
||||
objectClass: inetOrgPerson
|
||||
uid: frank
|
||||
sn: Moo
|
||||
mail: frank@example.com
|
||||
# testfrank
|
||||
userPassword: {SSHA}xqtMl8/uJ6HEFWDzLYpAE+Wq7FvKrtkm
|
||||
'';
|
||||
};
|
||||
|
||||
systemd.services.dovecot.serviceConfig = {
|
||||
CacheDirectory = "dovecot";
|
||||
StateDirectory = "dovecot";
|
||||
};
|
||||
|
||||
mailserver = {
|
||||
enable = true;
|
||||
fqdn = "mail.example.com";
|
||||
domains = [ "example.com" ];
|
||||
localDnsResolver = false;
|
||||
storage.path = "/var/lib/dovecot/vmail";
|
||||
indexDir = "/var/cache/dovecot/indices";
|
||||
|
||||
aliases = {
|
||||
# Steal frank@example.com from LDAP user frank
|
||||
"frank@example.com" = "mallory@example.com";
|
||||
};
|
||||
|
||||
accounts = {
|
||||
# Colliding local account takes precedence over LDAP account with
|
||||
# same address.
|
||||
"carol@example.com" = {
|
||||
hashedPasswordFile = hashPassword carolPassword;
|
||||
};
|
||||
# Another account used as a virtual alias target to steal
|
||||
# frank@example.com from the LDAP user frank
|
||||
"mallory@example.com" = {
|
||||
hashedPasswordFile = hashPassword malloryPassword;
|
||||
};
|
||||
};
|
||||
|
||||
ldap = {
|
||||
enable = true;
|
||||
uris = [
|
||||
"ldap://"
|
||||
];
|
||||
"olcDatabase={1}mdb" = {
|
||||
attrs = {
|
||||
objectClass = [
|
||||
"olcDatabaseConfig"
|
||||
"olcMdbConfig"
|
||||
];
|
||||
olcDatabase = "{1}mdb";
|
||||
olcDbDirectory = "/var/lib/openldap/example";
|
||||
olcSuffix = "dc=example";
|
||||
bind = {
|
||||
dn = "cn=mail,dc=example";
|
||||
passwordFile = "/etc/bind-password";
|
||||
};
|
||||
base = "ou=users,dc=example";
|
||||
scope = "sub";
|
||||
attributes = {
|
||||
# disable auth bind
|
||||
password = "userPassword";
|
||||
};
|
||||
};
|
||||
|
||||
forwards = {
|
||||
"bob_fw@example.com" = "bob@example.com";
|
||||
};
|
||||
};
|
||||
|
||||
specialisation.auth_bind = {
|
||||
inheritParentConfig = true;
|
||||
configuration = {
|
||||
mailserver = {
|
||||
ldap = {
|
||||
attributes = {
|
||||
# enable auth bind
|
||||
password = lib.mkForce null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
services.openldap.settings.children = {
|
||||
"olcDatabase={1}mdb" = {
|
||||
attrs = {
|
||||
olcAccess = [
|
||||
# disallow access to userPassword
|
||||
''
|
||||
to * attrs=userPassword
|
||||
by anonymous auth
|
||||
by * none
|
||||
''
|
||||
|
||||
# default policy (same as if we would specify none as all)
|
||||
''
|
||||
to *
|
||||
by * read
|
||||
''
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
declarativeContents."dc=example" = ''
|
||||
dn: dc=example
|
||||
objectClass: domain
|
||||
dc: example
|
||||
|
||||
dn: cn=mail,dc=example
|
||||
objectClass: organizationalRole
|
||||
objectClass: simpleSecurityObject
|
||||
objectClass: top
|
||||
cn: mail
|
||||
userPassword: ${bindPassword}
|
||||
|
||||
dn: ou=users,dc=example
|
||||
objectClass: organizationalUnit
|
||||
ou: users
|
||||
|
||||
dn: cn=alice,ou=users,dc=example
|
||||
objectClass: inetOrgPerson
|
||||
cn: alice
|
||||
sn: Foo
|
||||
mail: alice@example.com
|
||||
userPassword: ${alicePassword}
|
||||
|
||||
dn: cn=bob,ou=users,dc=example
|
||||
objectClass: inetOrgPerson
|
||||
cn: bob
|
||||
sn: Bar
|
||||
mail: bob@example.com
|
||||
userPassword: ${bobPassword}
|
||||
'';
|
||||
};
|
||||
|
||||
mailserver = {
|
||||
enable = true;
|
||||
fqdn = "mail.example.com";
|
||||
domains = [ "example.com" ];
|
||||
localDnsResolver = false;
|
||||
|
||||
ldap = {
|
||||
enable = true;
|
||||
uris = [
|
||||
"ldap://"
|
||||
];
|
||||
bind = {
|
||||
dn = "cn=mail,dc=example";
|
||||
passwordFile = "/etc/bind-password";
|
||||
};
|
||||
searchBase = "ou=users,dc=example";
|
||||
searchScope = "sub";
|
||||
};
|
||||
|
||||
vmailGroupName = "vmail";
|
||||
vmailUID = 5000;
|
||||
|
||||
enableImap = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
testScript = ''
|
||||
import sys
|
||||
import re
|
||||
testScript =
|
||||
{
|
||||
nodes,
|
||||
...
|
||||
}:
|
||||
# python
|
||||
''
|
||||
import sys
|
||||
import re
|
||||
|
||||
machine.start()
|
||||
machine.wait_for_unit("multi-user.target")
|
||||
machine.start()
|
||||
machine.wait_for_unit("multi-user.target")
|
||||
|
||||
# This function retrieves the ldap table file from a postconf
|
||||
# command.
|
||||
# A key lookup is achived and the returned value is compared
|
||||
# to the expected value.
|
||||
def test_lookup(postconf_cmdline, key, expected):
|
||||
conf = machine.succeed(postconf_cmdline).rstrip()
|
||||
ldap_table_path = re.match('.* =.*ldap:(.*)', conf).group(1)
|
||||
value = machine.succeed(f"postmap -q {key} ldap:{ldap_table_path}").rstrip()
|
||||
try:
|
||||
assert value == expected
|
||||
except AssertionError:
|
||||
print(f"Expected {conf} lookup for key '{key}' to return '{expected}, but got '{value}'", file=sys.stderr)
|
||||
raise
|
||||
# if the schema is broken, fail fast. helps during development.
|
||||
machine.wait_for_unit("openldap.service")
|
||||
|
||||
with subtest("Test postmap lookups"):
|
||||
test_lookup("postconf virtual_mailbox_maps", "alice@example.com", "alice@example.com")
|
||||
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "alice@example.com", "alice@example.com")
|
||||
machine.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
|
||||
|
||||
test_lookup("postconf virtual_mailbox_maps", "bob@example.com", "bob@example.com")
|
||||
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "bob@example.com", "bob@example.com")
|
||||
# This function retrieves the ldap table file from a postconf
|
||||
# command.
|
||||
# A key lookup is achieved and the returned value is compared
|
||||
# to the expected value.
|
||||
def test_lookup(postconf_cmdline, key, expected):
|
||||
conf = machine.succeed(postconf_cmdline).rstrip()
|
||||
ldap_table_path_match = re.match('.* =.*ldap:(.*)', conf)
|
||||
if not ldap_table_path_match:
|
||||
raise RuntimeError(f"Failed to match LDAP table in '{postconf_cmdline}' response")
|
||||
ldap_table_path = ldap_table_path_match.group(1)
|
||||
value = machine.succeed(f"postmap -q {key} ldap:{ldap_table_path}").rstrip()
|
||||
try:
|
||||
assert value == expected
|
||||
except AssertionError:
|
||||
print(f"Expected {conf} lookup for key '{key}' to return '{expected}, but got '{value}'", file=sys.stderr)
|
||||
raise
|
||||
|
||||
with subtest("Test doveadm lookups"):
|
||||
machine.succeed("doveadm user -u alice@example.com")
|
||||
machine.succeed("doveadm user -u bob@example.com")
|
||||
with subtest("Test postmap lookups"):
|
||||
test_lookup("postconf virtual_mailbox_maps", "alice@example.com", "alice")
|
||||
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "alice@example.com", "alice")
|
||||
|
||||
with subtest("Files containing secrets are only readable by root"):
|
||||
machine.succeed("ls -l /run/postfix/*.cf | grep -e '-rw------- 1 root root'")
|
||||
machine.succeed("ls -l /run/dovecot2/dovecot-ldap.conf.ext | grep -e '-rw------- 1 root root'")
|
||||
test_lookup("postconf virtual_mailbox_maps", "bob@example.com", "bob")
|
||||
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "bob@example.com", "bob")
|
||||
|
||||
with subtest("Test account/mail address binding"):
|
||||
machine.fail(" ".join([
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 587",
|
||||
"--smtp-starttls",
|
||||
"--smtp-host localhost",
|
||||
"--smtp-username alice@example.com",
|
||||
"--imap-host localhost",
|
||||
"--imap-username bob@example.com",
|
||||
"--from-addr bob@example.com",
|
||||
"--to-addr aliceb@example.com",
|
||||
"--src-password-file <(echo '${alicePassword}')",
|
||||
"--dst-password-file <(echo '${bobPassword}')",
|
||||
"--ignore-dkim-spf"
|
||||
]))
|
||||
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user alice@example.com'")
|
||||
with subtest("Test doveadm lookups"):
|
||||
machine.succeed("doveadm user -u alice@example.com")
|
||||
machine.succeed("doveadm user -u bob@example.com")
|
||||
|
||||
with subtest("Test mail delivery"):
|
||||
machine.succeed(" ".join([
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 587",
|
||||
"--smtp-starttls",
|
||||
"--smtp-host localhost",
|
||||
"--smtp-username alice@example.com",
|
||||
"--imap-host localhost",
|
||||
"--imap-username bob@example.com",
|
||||
"--from-addr alice@example.com",
|
||||
"--to-addr bob@example.com",
|
||||
"--src-password-file <(echo '${alicePassword}')",
|
||||
"--dst-password-file <(echo '${bobPassword}')",
|
||||
"--ignore-dkim-spf"
|
||||
]))
|
||||
'';
|
||||
machine.succeed("doveadm user -u alice")
|
||||
machine.log(machine.succeed("doveadm user -u bob"))
|
||||
|
||||
machine.succeed("doveadm user -f uid bob@example.com | grep ${toString nodes.machine.mailserver.storage.uid}")
|
||||
machine.succeed("doveadm user -f gid bob@example.com | grep ${toString nodes.machine.mailserver.storage.uid}")
|
||||
|
||||
machine.succeed("doveadm user -f home bob@example.com | grep ${nodes.machine.mailserver.storage.path}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092")
|
||||
machine.succeed("doveadm user -f mail_path bob@example.com | grep ${nodes.machine.mailserver.storage.path}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092")
|
||||
machine.succeed("doveadm user -f mail_index_path bob@example.com | grep ${nodes.machine.mailserver.indexDir}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092")
|
||||
|
||||
with subtest("Files containing secrets are only readable by root"):
|
||||
machine.succeed("ls -l /run/postfix/*.cf | grep -e '-rw------- 1 root root'")
|
||||
|
||||
with subtest("Test account/mail address binding via explicit TLS"):
|
||||
machine.fail(" ".join([
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 587",
|
||||
"--smtp-starttls",
|
||||
"--smtp-host localhost",
|
||||
"--smtp-username alice",
|
||||
"--imap-host localhost",
|
||||
"--imap-username bob",
|
||||
"--from-addr bob@example.com",
|
||||
"--to-addr aliceb@example.com",
|
||||
"--src-password-file <(echo '${alicePassword}')",
|
||||
"--dst-password-file <(echo '${bobPassword}')",
|
||||
"--ignore-dkim-spf"
|
||||
]))
|
||||
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user alice'")
|
||||
|
||||
with subtest("Test mail delivery via implicit TLS"):
|
||||
machine.succeed(" ".join([
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 465",
|
||||
"--smtp-ssl",
|
||||
"--smtp-host localhost",
|
||||
"--smtp-username alice",
|
||||
"--imap-host localhost",
|
||||
"--imap-username bob",
|
||||
"--from-addr alice@example.com",
|
||||
"--to-addr bob@example.com",
|
||||
"--src-password-file <(echo '${alicePassword}')",
|
||||
"--dst-password-file <(echo '${bobPassword}')",
|
||||
"--ignore-dkim-spf"
|
||||
]))
|
||||
|
||||
with subtest("Test mail forwarding via explicit TLS works"):
|
||||
machine.succeed(" ".join([
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 587",
|
||||
"--smtp-starttls",
|
||||
"--smtp-host localhost",
|
||||
"--smtp-username alice",
|
||||
"--imap-host localhost",
|
||||
"--imap-username bob",
|
||||
"--from-addr alice@example.com",
|
||||
"--to-addr bob_fw@example.com",
|
||||
"--src-password-file <(echo '${alicePassword}')",
|
||||
"--dst-password-file <(echo '${bobPassword}')",
|
||||
"--ignore-dkim-spf"
|
||||
]))
|
||||
|
||||
with subtest("Test cannot send mail via implicit TLS from forwarded address"):
|
||||
machine.fail(" ".join([
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 465",
|
||||
"--smtp-ssl",
|
||||
"--smtp-host localhost",
|
||||
"--smtp-username bob",
|
||||
"--imap-host localhost",
|
||||
"--imap-username alice@example.com",
|
||||
"--from-addr bob_fw@example.com",
|
||||
"--to-addr alice@example.com",
|
||||
"--src-password-file <(echo '${bobPassword}')",
|
||||
"--dst-password-file <(echo '${alicePassword}')",
|
||||
"--ignore-dkim-spf"
|
||||
]))
|
||||
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user bob'")
|
||||
|
||||
with subtest("Local addresses take priority over those learnt from LDAP"):
|
||||
# carol@example.com is routed to the local user account
|
||||
machine.succeed(" ".join([
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 465",
|
||||
"--smtp-ssl",
|
||||
"--smtp-host localhost",
|
||||
"--smtp-username alice", # LDAP user
|
||||
"--imap-host localhost",
|
||||
"--imap-username carol@example.com", # Local user
|
||||
"--from-addr alice@example.com",
|
||||
"--to-addr carol@example.com",
|
||||
"--src-password-file <(echo '${alicePassword}')",
|
||||
"--dst-password-file <(echo '${carolPassword}')",
|
||||
"--ignore-dkim-spf"
|
||||
]))
|
||||
|
||||
# frank@example.com gets routed to mallory@example.com due to a virtual alias
|
||||
machine.succeed(" ".join([
|
||||
"mail-check send-and-read",
|
||||
"--smtp-port 465",
|
||||
"--smtp-ssl",
|
||||
"--smtp-host localhost",
|
||||
"--smtp-username alice", # LDAP user
|
||||
"--imap-host localhost",
|
||||
"--imap-username mallory@example.com", # Local user
|
||||
"--from-addr alice@example.com",
|
||||
"--to-addr frank@example.com",
|
||||
"--src-password-file <(echo '${alicePassword}')",
|
||||
"--dst-password-file <(echo '${malloryPassword}')",
|
||||
"--ignore-dkim-spf"
|
||||
]))
|
||||
|
||||
with subtest("LDAP Authentication Binds"):
|
||||
machine.succeed("/run/booted-system/specialisation/auth_bind/bin/switch-to-configuration test")
|
||||
machine.wait_for_unit("openldap.service")
|
||||
machine.succeed("doveadm auth test alice '${alicePassword}'")
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBizCCATGgAwIBAgIUN4ncJfMVIQSSurMkdE73x4aefTMwCgYIKoZIzj0EAwIw
|
||||
GzEZMBcGA1UEAwwQdGVzdC5sb2NhbGRvbWFpbjAeFw0yNTEwMTgyMTQ4MTNaFw0z
|
||||
NTEwMTYyMTQ4MTNaMBsxGTAXBgNVBAMMEHRlc3QubG9jYWxkb21haW4wWTATBgcq
|
||||
hkjOPQIBBggqhkjOPQMBBwNCAARCJUj4j7eC/7Xso3REUscqHlWPvW9zvl5I6TIy
|
||||
zEXFsWxM0QxMuNW4oXE56UiCyJklcpk0JfQUGat+kKQqSUJyo1MwUTAdBgNVHQ4E
|
||||
FgQUW3CnmBf3n/Y30vfj3ERsIQnXu9QwHwYDVR0jBBgwFoAUW3CnmBf3n/Y30vfj
|
||||
3ERsIQnXu9QwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAhwAi
|
||||
K4xdr8KxD5xRvvzShheh48i8X7NtBIQ3bd01Jx4CIG/kYTDK5nDZri7UYOMsgz2l
|
||||
iWss56p2dGWTL7LrBHgM
|
||||
-----END CERTIFICATE-----
|
||||
+37
-1
@@ -1,3 +1,39 @@
|
||||
{
|
||||
security.dhparams.defaultBitSize = 1024; # minimum size required by dovecot
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
|
||||
{
|
||||
# Testing eval failures that result from stateVersion assertion is out of scope
|
||||
mailserver.stateVersion = 999;
|
||||
|
||||
# Keep testing submission with explicit TLS
|
||||
mailserver.enableSubmission = true;
|
||||
|
||||
# Certificate created for testing purposes from RFC9500 private key
|
||||
# https://datatracker.ietf.org/doc/rfc9500/
|
||||
# openssl req -x509 -new -key key.pem \
|
||||
# -subj "/CN=test.localdomain" \
|
||||
# -sha256 -days 3650 \
|
||||
# -out cert.pem
|
||||
mailserver.x509 = {
|
||||
certificateFile = "${./cert.pem}";
|
||||
privateKeyFile = "${./key.pem}";
|
||||
};
|
||||
|
||||
# Enable second CPU core
|
||||
virtualisation.cores = lib.mkDefault 2;
|
||||
|
||||
services.rspamd = {
|
||||
# Don't make tests block on DNS requests that will never succeed
|
||||
locals."options.inc".text = ''
|
||||
dns {
|
||||
nameservers = ["127.0.0.1"];
|
||||
timeout = 0.0s;
|
||||
retransmits = 0;
|
||||
}
|
||||
'';
|
||||
# Relax `local_addrs` definition to default for tests, so mail doesn't get flagged as spam
|
||||
overrides."options.inc".enable = false;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIObLW92AqkWunJXowVR2Z5/+yVPBaFHnEedDk5WJxk/BoAoGCCqGSM49
|
||||
AwEHoUQDQgAEQiVI+I+3gv+17KN0RFLHKh5Vj71vc75eSOkyMsxFxbFsTNEMTLjV
|
||||
uKFxOelIgsiZJXKZNCX0FBmrfpCkKklCcg==
|
||||
-----END EC PRIVATE KEY-----
|
||||
@@ -0,0 +1,3 @@
|
||||
if address :is "from" "user1@example.com" {
|
||||
redirect "user1@example.com";
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
# nixos-mailserver: a simple mail server
|
||||
# Copyright (C) 2016-2018 Robin Raymond
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
|
||||
import <nixpkgs/nixos/tests/make-test-python.nix> {
|
||||
|
||||
nodes.machine =
|
||||
{ config, pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
./../default.nix
|
||||
];
|
||||
};
|
||||
|
||||
testScript =
|
||||
''
|
||||
machine.wait_for_unit("multi-user.target");
|
||||
'';
|
||||
}
|
||||
+77
-51
@@ -1,26 +1,41 @@
|
||||
# This tests is used to test features requiring several mail domains.
|
||||
|
||||
{ pkgs ? import <nixpkgs> {}, ...}:
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
hashPassword = password: pkgs.runCommand
|
||||
"password-${password}-hashed"
|
||||
{ buildInputs = [ pkgs.mkpasswd ]; inherit password; }
|
||||
hashPassword =
|
||||
password:
|
||||
pkgs.runCommand "password-${password}-hashed"
|
||||
{
|
||||
buildInputs = [ pkgs.mkpasswd ];
|
||||
inherit password;
|
||||
}
|
||||
''
|
||||
mkpasswd -sm bcrypt <<<"$password" > $out
|
||||
mkpasswd -s <<<"$password" > $out
|
||||
'';
|
||||
|
||||
password = pkgs.writeText "password" "password";
|
||||
password = pkgs.writeText "password" "password";
|
||||
|
||||
domainGenerator = domain: { config, pkgs, ... }: {
|
||||
imports = [../default.nix];
|
||||
domainGenerator =
|
||||
domain:
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
../default.nix
|
||||
./lib/config.nix
|
||||
];
|
||||
environment.systemPackages = with pkgs; [ netcat ];
|
||||
virtualisation.memorySize = 1024;
|
||||
mailserver = {
|
||||
enable = true;
|
||||
fqdn = "mail.${domain}";
|
||||
domains = [ domain ];
|
||||
localDnsResolver = false;
|
||||
loginAccounts = {
|
||||
accounts = {
|
||||
"user@${domain}" = {
|
||||
hashedPasswordFile = hashPassword "password";
|
||||
};
|
||||
@@ -30,60 +45,71 @@ let
|
||||
};
|
||||
services.dnsmasq = {
|
||||
enable = true;
|
||||
# Fixme: once nixos-22.11 has been removed, could be replaced by
|
||||
# settings.mx-host = [ "domain1.com,domain1,10" "domain2.com,domain2,10" ];
|
||||
extraConfig = ''
|
||||
mx-host=domain1.com,domain1,10
|
||||
mx-host=domain2.com,domain2,10
|
||||
'';
|
||||
settings.mx-host = [
|
||||
"domain1.com,domain1,10"
|
||||
"domain2.com,domain2,10"
|
||||
];
|
||||
};
|
||||
|
||||
# breaks the test, due to running into DNS timeouts
|
||||
services.postfix-tlspol.configurePostfix = lib.mkForce false;
|
||||
};
|
||||
|
||||
in
|
||||
|
||||
pkgs.nixosTest {
|
||||
{
|
||||
name = "multiple";
|
||||
|
||||
nodes = {
|
||||
domain1 = {...}: {
|
||||
imports = [
|
||||
../default.nix
|
||||
(domainGenerator "domain1.com")
|
||||
];
|
||||
mailserver.forwards = {
|
||||
"non-local@domain1.com" = ["user@domain2.com" "user@domain1.com"];
|
||||
"non@domain1.com" = ["user@domain2.com" "user@domain1.com"];
|
||||
domain1 =
|
||||
{ ... }:
|
||||
{
|
||||
imports = [
|
||||
../default.nix
|
||||
(domainGenerator "domain1.com")
|
||||
];
|
||||
mailserver.forwards = {
|
||||
"non-local@domain1.com" = [
|
||||
"user@domain2.com"
|
||||
"user@domain1.com"
|
||||
];
|
||||
"non@domain1.com" = [
|
||||
"user@domain2.com"
|
||||
"user@domain1.com"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
domain2 = domainGenerator "domain2.com";
|
||||
client = { config, pkgs, ... }: {
|
||||
environment.systemPackages = [
|
||||
(pkgs.writeScriptBin "mail-check" ''
|
||||
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
|
||||
'')];
|
||||
};
|
||||
client =
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
environment.systemPackages = [
|
||||
(pkgs.writeScriptBin "mail-check" ''
|
||||
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
|
||||
'')
|
||||
];
|
||||
};
|
||||
};
|
||||
testScript = ''
|
||||
start_all()
|
||||
testScript =
|
||||
# python
|
||||
''
|
||||
start_all()
|
||||
|
||||
domain1.wait_for_unit("multi-user.target")
|
||||
domain2.wait_for_unit("multi-user.target")
|
||||
for domain in [domain1, domain2]:
|
||||
domain.wait_for_unit("multi-user.target")
|
||||
domain.wait_for_unit("dovecot.service")
|
||||
|
||||
# TODO put this blocking into the systemd units?
|
||||
domain1.wait_until_succeeds(
|
||||
"set +e; timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
|
||||
)
|
||||
domain2.wait_until_succeeds(
|
||||
"set +e; timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
|
||||
)
|
||||
for host in [domain1, domain2]:
|
||||
host.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
|
||||
|
||||
# user@domain1.com sends a mail to user@domain2.com
|
||||
client.succeed(
|
||||
"mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
|
||||
)
|
||||
# user@domain1.com sends a mail to user@domain2.com via explicit TLS
|
||||
client.succeed(
|
||||
"mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
|
||||
)
|
||||
|
||||
# Send a mail to the address forwarded and check it is in the recipient mailbox
|
||||
client.succeed(
|
||||
"mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr non-local@domain1.com --imap-username user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
|
||||
)
|
||||
'';
|
||||
# Send a mail to the address forwarded via implicit TLS and check it is in the recipient mailbox
|
||||
client.succeed(
|
||||
"mail-check send-and-read --smtp-port 465 --smtp-ssl --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr non-local@domain1.com --imap-username user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
|
||||
)
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
sed -i -e "s/v[0-9]\+\.[0-9]\+\.[0-9]\+/$1/g" README.md
|
||||
|
||||
HASH=$(nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/v2.3.0/nixos-mailserver-$1.tar.gz" --unpack)
|
||||
|
||||
sed -i -e "s/sha256 = \"[0-9a-z]\{52\}\"/sha256 = \"$HASH\"/g" README.md
|
||||
Reference in New Issue
Block a user