Merge branch 'cleanup' into 'master'

Format with nixfmt, drop redundant parentheses

See merge request simple-nixos-mailserver/nixos-mailserver!415
This commit is contained in:
Martin Weinelt 2025-06-15 02:45:24 +00:00
commit b555b3e8dc
21 changed files with 2090 additions and 1681 deletions

View file

@ -1,22 +1,21 @@
{ nixpkgs, pulls, ... }: { nixpkgs, pulls, ... }:
let let
pkgs = import nixpkgs {}; pkgs = import nixpkgs { };
prs = builtins.fromJSON (builtins.readFile pulls); prs = builtins.fromJSON (builtins.readFile pulls);
prJobsets = pkgs.lib.mapAttrs (num: info: prJobsets = pkgs.lib.mapAttrs (num: info: {
{ enabled = 1; enabled = 1;
hidden = false; hidden = false;
description = "PR ${num}: ${info.title}"; description = "PR ${num}: ${info.title}";
checkinterval = 300; checkinterval = 300;
schedulingshares = 20; schedulingshares = 20;
enableemail = false; enableemail = false;
emailoverride = ""; emailoverride = "";
keepnr = 1; keepnr = 1;
type = 1; type = 1;
flake = "gitlab:simple-nixos-mailserver/nixos-mailserver/merge-requests/${info.iid}/head"; flake = "gitlab:simple-nixos-mailserver/nixos-mailserver/merge-requests/${info.iid}/head";
} }) prs;
) prs;
mkFlakeJobset = branch: { mkFlakeJobset = branch: {
description = "Build ${branch} branch of Simple NixOS MailServer"; description = "Build ${branch} branch of Simple NixOS MailServer";
checkinterval = 300; checkinterval = 300;
@ -41,8 +40,9 @@ let
jobsets = desc; jobsets = desc;
}; };
in { in
jobsets = pkgs.runCommand "spec-jobsets.json" {} '' {
jobsets = pkgs.runCommand "spec-jobsets.json" { } ''
cat >$out <<EOF cat >$out <<EOF
${builtins.toJSON desc} ${builtins.toJSON desc}
EOF EOF

View file

@ -14,7 +14,12 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, lib, pkgs, ... }: {
config,
lib,
pkgs,
...
}:
with lib; with lib;
@ -56,14 +61,17 @@ in
domains = mkOption { domains = mkOption {
type = types.listOf types.str; type = types.listOf types.str;
example = [ "example.com" ]; example = [ "example.com" ];
default = []; default = [ ];
description = "The domains that this mail server serves."; description = "The domains that this mail server serves.";
}; };
certificateDomains = mkOption { certificateDomains = mkOption {
type = types.listOf types.str; type = types.listOf types.str;
example = [ "imap.example.com" "pop3.example.com" ]; example = [
default = []; "imap.example.com"
"pop3.example.com"
];
default = [ ];
description = '' description = ''
({option}`mailserver.certificateScheme` == `acme-nginx`) ({option}`mailserver.certificateScheme` == `acme-nginx`)
@ -79,130 +87,141 @@ in
}; };
loginAccounts = mkOption { loginAccounts = mkOption {
type = types.attrsOf (types.submodule ({ name, ... }: { type = types.attrsOf (
options = { types.submodule (
name = mkOption { { name, ... }:
type = types.str; {
example = "user1@example.com"; options = {
description = "Username"; name = mkOption {
}; type = types.str;
example = "user1@example.com";
description = "Username";
};
hashedPassword = mkOption { hashedPassword = mkOption {
type = with types; nullOr str; type = with types; nullOr str;
default = null; default = null;
example = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/"; example = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/";
description = '' description = ''
The user's hashed password. Use `mkpasswd` as follows The user's hashed password. Use `mkpasswd` as follows
``` ```
nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt'
``` ```
Warning: this is stored in plaintext in the Nix store! Warning: this is stored in plaintext in the Nix store!
Use {option}`mailserver.loginAccounts.<name>.hashedPasswordFile` instead. Use {option}`mailserver.loginAccounts.<name>.hashedPasswordFile` instead.
''; '';
}; };
hashedPasswordFile = mkOption { hashedPasswordFile = mkOption {
type = with types; nullOr path; type = with types; nullOr path;
default = null; default = null;
example = "/run/keys/user1-passwordhash"; example = "/run/keys/user1-passwordhash";
description = '' description = ''
A file containing the user's hashed password. Use `mkpasswd` as follows A file containing the user's hashed password. Use `mkpasswd` as follows
``` ```
nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt'
``` ```
''; '';
}; };
aliases = mkOption { aliases = mkOption {
type = with types; listOf types.str; type = with types; listOf types.str;
example = ["abuse@example.com" "postmaster@example.com"]; example = [
default = []; "abuse@example.com"
description = '' "postmaster@example.com"
A list of aliases of this login account. ];
Note: Use list entries like "@example.com" to create a catchAll default = [ ];
that allows sending from all email addresses in these domain. description = ''
''; A list of aliases of this login account.
}; Note: Use list entries like "@example.com" to create a catchAll
that allows sending from all email addresses in these domain.
'';
};
aliasesRegexp = mkOption { aliasesRegexp = mkOption {
type = with types; listOf types.str; type = with types; listOf types.str;
example = [''/^tom\..*@domain\.com$/'']; example = [ ''/^tom\..*@domain\.com$/'' ];
default = []; default = [ ];
description = '' description = ''
Same as {option}`mailserver.aliases` but using PCRE (Perl compatible regex). Same as {option}`mailserver.aliases` but using PCRE (Perl compatible regex).
''; '';
}; };
catchAll = mkOption { catchAll = mkOption {
type = with types; listOf (enum cfg.domains); type = with types; listOf (enum cfg.domains);
example = ["example.com" "example2.com"]; example = [
default = []; "example.com"
description = '' "example2.com"
For which domains should this account act as a catch all? ];
Note: Does not allow sending from all addresses of these domains. default = [ ];
''; description = ''
}; For which domains should this account act as a catch all?
Note: Does not allow sending from all addresses of these domains.
'';
};
quota = mkOption { quota = mkOption {
type = with types; nullOr types.str; type = with types; nullOr types.str;
default = null; default = null;
example = "2G"; example = "2G";
description = '' description = ''
Per user quota rules. Accepted sizes are `xx k/M/G/T` with the Per user quota rules. Accepted sizes are `xx k/M/G/T` with the
obvious meaning. Leave blank for the standard quota `100G`. obvious meaning. Leave blank for the standard quota `100G`.
''; '';
}; };
sieveScript = mkOption { sieveScript = mkOption {
type = with types; nullOr lines; type = with types; nullOr lines;
default = null; default = null;
example = '' example = ''
require ["fileinto", "mailbox"]; require ["fileinto", "mailbox"];
if address :is "from" "gitlab@mg.gitlab.com" { if address :is "from" "gitlab@mg.gitlab.com" {
fileinto :create "GitLab"; fileinto :create "GitLab";
stop; stop;
} }
# This must be the last rule, it will check if list-id is set, and # This must be the last rule, it will check if list-id is set, and
# file the message into the Lists folder for further investigation # file the message into the Lists folder for further investigation
elsif header :matches "list-id" "<?*>" { elsif header :matches "list-id" "<?*>" {
fileinto :create "Lists"; fileinto :create "Lists";
stop; stop;
} }
''; '';
description = '' description = ''
Per-user sieve script. Per-user sieve script.
''; '';
}; };
sendOnly = mkOption { sendOnly = mkOption {
type = types.bool; type = types.bool;
default = false; default = false;
description = '' description = ''
Specifies if the account should be a send-only account. Specifies if the account should be a send-only account.
Emails sent to send-only accounts will be rejected from Emails sent to send-only accounts will be rejected from
unauthorized senders with the `sendOnlyRejectMessage` unauthorized senders with the `sendOnlyRejectMessage`
stating the reason. stating the reason.
''; '';
}; };
sendOnlyRejectMessage = mkOption { sendOnlyRejectMessage = mkOption {
type = types.str; type = types.str;
default = "This account cannot receive emails."; default = "This account cannot receive emails.";
description = '' description = ''
The message that will be returned to the sender when an email is The message that will be returned to the sender when an email is
sent to a send-only account. Only used if the account is marked sent to a send-only account. Only used if the account is marked
as send-only. as send-only.
''; '';
}; };
}; };
config.name = mkDefault name; config.name = mkDefault name;
})); }
)
);
example = { example = {
user1 = { user1 = {
hashedPassword = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/"; hashedPassword = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/";
@ -220,13 +239,13 @@ in
nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt'
``` ```
''; '';
default = {}; default = { };
}; };
ldap = { ldap = {
enable = mkEnableOption "LDAP support"; enable = mkEnableOption "LDAP support";
uris = mkOption { uris = mkOption {
type = types.listOf types.str; type = types.listOf types.str;
example = literalExpression '' example = literalExpression ''
[ [
@ -284,7 +303,11 @@ in
}; };
searchScope = mkOption { searchScope = mkOption {
type = types.enum [ "sub" "base" "one" ]; type = types.enum [
"sub"
"base"
"one"
];
default = "sub"; default = "sub";
description = '' description = ''
Search scope below which users accounts are looked for. Search scope below which users accounts are looked for.
@ -419,14 +442,22 @@ in
autoIndexExclude = mkOption { autoIndexExclude = mkOption {
type = types.listOf types.str; type = types.listOf types.str;
default = [ ]; default = [ ];
example = [ "\\Trash" "SomeFolder" "Other/*" ]; example = [
"\\Trash"
"SomeFolder"
"Other/*"
];
description = '' description = ''
Mailboxes to exclude from automatic indexing. Mailboxes to exclude from automatic indexing.
''; '';
}; };
enforced = mkOption { enforced = mkOption {
type = types.enum [ "yes" "no" "body" ]; type = types.enum [
"yes"
"no"
"body"
];
default = "no"; default = "no";
description = '' description = ''
Fail searches when no index is available. If set to Fail searches when no index is available. If set to
@ -439,7 +470,10 @@ in
languages = mkOption { languages = mkOption {
type = types.nonEmptyListOf types.str; type = types.nonEmptyListOf types.str;
default = [ "en" ]; default = [ "en" ];
example = [ "en" "de" ]; example = [
"en"
"de"
];
description = '' description = ''
A list of languages that the full text search should detect. A list of languages that the full text search should detect.
At least one language must be specified. At least one language must be specified.
@ -488,7 +522,10 @@ in
}; };
lmtpSaveToDetailMailbox = mkOption { lmtpSaveToDetailMailbox = mkOption {
type = types.enum ["yes" "no"]; type = types.enum [
"yes"
"no"
];
default = "yes"; default = "yes";
description = '' description = ''
If an email address is delimited by a "+", should it be filed into a If an email address is delimited by a "+", should it be filed into a
@ -514,17 +551,23 @@ in
}; };
extraVirtualAliases = mkOption { extraVirtualAliases = mkOption {
type = let type =
loginAccount = mkOptionType { let
name = "Login Account"; loginAccount = mkOptionType {
check = (account: builtins.elem account (builtins.attrNames cfg.loginAccounts)); name = "Login Account";
}; check = account: builtins.elem account (builtins.attrNames cfg.loginAccounts);
in with types; attrsOf (either loginAccount (nonEmptyListOf loginAccount)); };
in
with types;
attrsOf (either loginAccount (nonEmptyListOf loginAccount));
example = { example = {
"info@example.com" = "user1@example.com"; "info@example.com" = "user1@example.com";
"postmaster@example.com" = "user1@example.com"; "postmaster@example.com" = "user1@example.com";
"abuse@example.com" = "user1@example.com"; "abuse@example.com" = "user1@example.com";
"multi@example.com" = [ "user1@example.com" "user2@example.com" ]; "multi@example.com" = [
"user1@example.com"
"user2@example.com"
];
}; };
description = '' description = ''
Virtual Aliases. A virtual alias `"info@example.com" = "user1@example.com"` means that Virtual Aliases. A virtual alias `"info@example.com" = "user1@example.com"` means that
@ -537,7 +580,7 @@ in
example all mails for `multi@example.com` will be forwarded to both example all mails for `multi@example.com` will be forwarded to both
`user1@example.com` and `user2@example.com`. `user1@example.com` and `user2@example.com`.
''; '';
default = {}; default = { };
}; };
forwards = mkOption { forwards = mkOption {
@ -554,28 +597,34 @@ in
can't send mail as `user@example.com`. Also, this option can't send mail as `user@example.com`. Also, this option
allows to forward mails to external addresses. allows to forward mails to external addresses.
''; '';
default = {}; default = { };
}; };
rejectSender = mkOption { rejectSender = mkOption {
type = types.listOf types.str; type = types.listOf types.str;
example = [ "example.com" "spammer@example.net" ]; example = [
"example.com"
"spammer@example.net"
];
description = '' description = ''
Reject emails from these addresses from unauthorized senders. Reject emails from these addresses from unauthorized senders.
Use if a spammer is using the same domain or the same sender over and over. Use if a spammer is using the same domain or the same sender over and over.
''; '';
default = []; default = [ ];
}; };
rejectRecipients = mkOption { rejectRecipients = mkOption {
type = types.listOf types.str; type = types.listOf types.str;
example = [ "sales@example.com" "info@example.com" ]; example = [
"sales@example.com"
"info@example.com"
];
description = '' description = ''
Reject emails addressed to these local addresses from unauthorized senders. Reject emails addressed to these local addresses from unauthorized senders.
Use if a spammer has found email addresses in a catchall domain but you do Use if a spammer has found email addresses in a catchall domain but you do
not want to disable the catchall. not want to disable the catchall.
''; '';
default = []; default = [ ];
}; };
vmailUID = mkOption { vmailUID = mkOption {
@ -673,28 +722,46 @@ in
}; };
}; };
certificateScheme = let certificateScheme =
schemes = [ "manual" "selfsigned" "acme-nginx" "acme" ]; let
translate = i: warn "Setting mailserver.certificateScheme by number is deprecated, please use names instead: 'mailserver.certificateScheme = ${builtins.toString i}' can be replaced by 'mailserver.certificateScheme = \"${(builtins.elemAt schemes (i - 1))}\"'." schemes = [
(builtins.elemAt schemes (i - 1)); "manual"
in mkOption { "selfsigned"
type = with types; coercedTo (enum [ 1 2 3 ]) translate (enum schemes); "acme-nginx"
default = "selfsigned"; "acme"
description = '' ];
The scheme to use for managing TLS certificates: translate =
i:
warn
"Setting mailserver.certificateScheme by number is deprecated, please use names instead: 'mailserver.certificateScheme = ${builtins.toString i}' can be replaced by 'mailserver.certificateScheme = \"${
(builtins.elemAt schemes (i - 1))
}\"'."
(builtins.elemAt schemes (i - 1));
in
mkOption {
type =
with types;
coercedTo (enum [
1
2
3
]) translate (enum schemes);
default = "selfsigned";
description = ''
The scheme to use for managing TLS certificates:
1. `manual`: you specify locations via {option}`mailserver.certificateFile` and 1. `manual`: you specify locations via {option}`mailserver.certificateFile` and
{option}`mailserver.keyFile` and manually copy certificates there. {option}`mailserver.keyFile` and manually copy certificates there.
2. `selfsigned`: you let the server create new (self-signed) certificates on the fly. 2. `selfsigned`: you let the server create new (self-signed) certificates on the fly.
3. `acme-nginx`: you let the server request certificates from [Let's Encrypt](https://letsencrypt.org) 3. `acme-nginx`: you let the server request certificates from [Let's Encrypt](https://letsencrypt.org)
via NixOS' ACME module. By default, this will set up a stripped-down Nginx server for via NixOS' ACME module. By default, this will set up a stripped-down Nginx server for
{option}`mailserver.fqdn` and open port 80. For this to work, the FQDN must be properly {option}`mailserver.fqdn` and open port 80. For this to work, the FQDN must be properly
configured to point to your server (see the [setup guide](setup-guide.rst) for more information). configured to point to your server (see the [setup guide](setup-guide.rst) for more information).
4. `acme`: you already have an ACME certificate set up (for example, you're already running a TLS-enabled 4. `acme`: you already have an ACME certificate set up (for example, you're already running a TLS-enabled
Nginx server on the FQDN). This is better than `manual` because the appropriate services will be reloaded Nginx server on the FQDN). This is better than `manual` because the appropriate services will be reloaded
when the certificate is renewed. when the certificate is renewed.
''; '';
}; };
certificateFile = mkOption { certificateFile = mkOption {
type = types.path; type = types.path;
@ -851,7 +918,10 @@ in
}; };
dkimKeyType = mkOption { dkimKeyType = mkOption {
type = types.enum [ "rsa" "ed25519" ]; type = types.enum [
"rsa"
"ed25519"
];
default = "rsa"; default = "rsa";
description = '' description = ''
The key type used for generating DKIM keys. ED25519 was introduced in RFC6376 (2018). The key type used for generating DKIM keys. ED25519 was introduced in RFC6376 (2018).
@ -864,16 +934,16 @@ in
}; };
dkimKeyBits = mkOption { dkimKeyBits = mkOption {
type = types.int; type = types.int;
default = 1024; default = 1024;
description = '' description = ''
How many bits in generated DKIM keys. RFC6376 advises minimum 1024-bit keys. How many bits in generated DKIM keys. RFC6376 advises minimum 1024-bit keys.
If you have already deployed a key with a different number of bits than specified If you have already deployed a key with a different number of bits than specified
here, then you should use a different selector ({option}`mailserver.dkimSelector`). In order to get here, then you should use a different selector ({option}`mailserver.dkimSelector`). In order to get
this package to generate a key with the new number of bits, you will either have to this package to generate a key with the new number of bits, you will either have to
change the selector or delete the old key file. change the selector or delete the old key file.
''; '';
}; };
dmarcReporting = { dmarcReporting = {
@ -901,7 +971,7 @@ in
}; };
domain = mkOption { domain = mkOption {
type = types.enum (cfg.domains); type = types.enum cfg.domains;
example = "example.com"; example = "example.com";
description = '' description = ''
The domain from which outgoing DMARC reports are served. The domain from which outgoing DMARC reports are served.
@ -938,7 +1008,7 @@ in
excludeDomains = mkOption { excludeDomains = mkOption {
type = types.listOf types.str; type = types.listOf types.str;
default = []; default = [ ];
description = '' description = ''
List of domains or eSLDs to be excluded from DMARC reports. List of domains or eSLDs to be excluded from DMARC reports.
''; '';
@ -1150,7 +1220,15 @@ in
compression = { compression = {
method = mkOption { method = mkOption {
type = types.nullOr (types.enum ["none" "lz4" "zstd" "zlib" "lzma"]); type = types.nullOr (
types.enum [
"none"
"lz4"
"zstd"
"zlib"
"lzma"
]
);
default = null; default = null;
description = "Leaving this unset allows borg to choose. The default for borg 1.1.4 is lz4."; description = "Leaving this unset allows borg to choose. The default for borg 1.1.4 is lz4.";
}; };
@ -1208,14 +1286,14 @@ in
locations = mkOption { locations = mkOption {
type = types.listOf types.path; type = types.listOf types.path;
default = [cfg.mailDirectory]; default = [ cfg.mailDirectory ];
defaultText = lib.literalExpression "[ config.mailserver.mailDirectory ]"; defaultText = lib.literalExpression "[ config.mailserver.mailDirectory ]";
description = "The locations that are to be backed up by borg."; description = "The locations that are to be backed up by borg.";
}; };
extraArgumentsForInit = mkOption { extraArgumentsForInit = mkOption {
type = types.listOf types.str; type = types.listOf types.str;
default = ["--critical"]; default = [ "--critical" ];
description = "Additional arguments to add to the borg init command line."; description = "Additional arguments to add to the borg init command line.";
}; };
@ -1295,9 +1373,9 @@ in
cronIntervals = mkOption { cronIntervals = mkOption {
type = types.attrsOf types.str; type = types.attrsOf types.str;
default = { default = {
# minute, hour, day-in-month, month, weekday (0 = sunday) # minute, hour, day-in-month, month, weekday (0 = sunday)
hourly = " 0 * * * *"; # Every full hour hourly = " 0 * * * *"; # Every full hour
daily = "30 3 * * *"; # Every day at 3:30 daily = "30 3 * * *"; # Every day at 3:30
weekly = " 0 5 * * 0"; # Every sunday at 5:00 AM weekly = " 0 5 * * 0"; # Every sunday at 5:00 AM
}; };
description = '' description = ''
@ -1311,29 +1389,29 @@ in
imports = [ imports = [
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "enable" ] '' (lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "enable" ] ''
This option is not needed for fts-flatcurve This option is not needed for fts-flatcurve
'') '')
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "onCalendar" ] '' (lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "onCalendar" ] ''
This option is not needed for fts-flatcurve This option is not needed for fts-flatcurve
'') '')
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "randomizedDelaySec" ] '' (lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "randomizedDelaySec" ] ''
This option is not needed for fts-flatcurve This option is not needed for fts-flatcurve
'') '')
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "minSize" ] '' (lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "minSize" ] ''
This option is not supported by fts-flatcurve This option is not supported by fts-flatcurve
'') '')
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maxSize" ] '' (lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maxSize" ] ''
This option is not needed since fts-xapian 1.8.3 This option is not needed since fts-xapian 1.8.3
'') '')
(lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "indexAttachments" ] '' (lib.mkRemovedOptionModule [ "mailserver" "fullTextSearch" "indexAttachments" ] ''
Text attachments are always indexed since fts-xapian 1.4.8 Text attachments are always indexed since fts-xapian 1.4.8
'') '')
(lib.mkRenamedOptionModule (lib.mkRenamedOptionModule
[ "mailserver" "rebootAfterKernelUpgrade" "enable" ] [ "mailserver" "rebootAfterKernelUpgrade" "enable" ]
[ "system" "autoUpgrade" "allowReboot" ] [ "system" "autoUpgrade" "allowReboot" ]
) )
(lib.mkRemovedOptionModule [ "mailserver" "rebootAfterKernelUpgrade" "method" ] '' (lib.mkRemovedOptionModule [ "mailserver" "rebootAfterKernelUpgrade" "method" ] ''
Use `system.autoUpgrade` instead. Use `system.autoUpgrade` instead.
'') '')
./mail-server/assertions.nix ./mail-server/assertions.nix
./mail-server/borgbackup.nix ./mail-server/borgbackup.nix

345
flake.nix
View file

@ -20,174 +20,205 @@
}; };
}; };
outputs = { self, blobs, git-hooks, nixpkgs, nixpkgs-25_05, ... }: let outputs =
lib = nixpkgs.lib; {
system = "x86_64-linux"; self,
pkgs = nixpkgs.legacyPackages.${system}; blobs,
releases = [ git-hooks,
{ nixpkgs,
name = "unstable"; nixpkgs-25_05,
nixpkgs = nixpkgs; ...
pkgs = nixpkgs.legacyPackages.${system}; }:
} let
{ lib = nixpkgs.lib;
name = "25.05"; system = "x86_64-linux";
nixpkgs = nixpkgs-25_05; pkgs = nixpkgs.legacyPackages.${system};
pkgs = nixpkgs-25_05.legacyPackages.${system}; releases = [
} {
]; name = "unstable";
testNames = [ nixpkgs = nixpkgs;
"clamav" pkgs = nixpkgs.legacyPackages.${system};
"external" }
"internal" {
"ldap" name = "25.05";
"multiple" nixpkgs = nixpkgs-25_05;
]; pkgs = nixpkgs-25_05.legacyPackages.${system};
}
];
testNames = [
"clamav"
"external"
"internal"
"ldap"
"multiple"
];
genTest = testName: release: let genTest =
pkgs = release.pkgs; testName: release:
nixos-lib = import (release.nixpkgs + "/nixos/lib") { let
inherit (pkgs) lib; pkgs = release.pkgs;
}; nixos-lib = import (release.nixpkgs + "/nixos/lib") {
in { inherit (pkgs) lib;
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";
domains = [
"example.com"
];
dmarcReporting = {
organizationName = "Example Corp";
domain = "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" ".*\\.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
'';
};
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 = ./.;
hooks = {
# docs
markdownlint = {
enable = true;
settings.configuration = {
# Max line length, doesn't seem to correclty account for lines containing links
# https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md
MD013 = false;
};
}; };
rstcheck = { in
enable = true; {
package = pkgs.rstcheckWithSphinx; name = "${testName}-${builtins.replaceStrings [ "." ] [ "_" ] release.name}";
entry = lib.getExe pkgs.rstcheckWithSphinx; value = nixos-lib.runTest {
files = "\\.rst$"; hostPkgs = pkgs;
imports = [ ./tests/${testName}.nix ];
_module.args = { inherit blobs; };
extraBaseModules.imports = [ ./default.nix ];
}; };
};
# nix # Generate an attribute set such as
deadnix.enable = true; # {
# external-unstable = <derivation>;
# external-21_05 = <derivation>;
# ...
# }
allTests = lib.listToAttrs (lib.flatten (map (t: map (r: genTest t r) releases) testNames));
# python mailserverModule = import ./.;
pyright.enable = true;
ruff = { # Generate a MarkDown file describing the options of the NixOS mailserver module
enable = true; optionsDoc =
args = [ let
"--extend-select" eval = lib.evalModules {
"I" modules = [
mailserverModule
{
_module.check = false;
mailserver = {
fqdn = "mx.example.com";
domains = [
"example.com"
];
dmarcReporting = {
organizationName = "Example Corp";
domain = "example.com";
};
};
}
]; ];
}; };
ruff-format.enable = true; 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
'';
# scripts documentation = pkgs.stdenv.mkDerivation {
shellcheck.enable = true; 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
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
'';
};
# sieve in
check-sieve = { {
enable = true; nixosModules = rec {
package = pkgs.check-sieve; mailserver = mailserverModule;
entry = lib.getExe pkgs.check-sieve; default = mailserver;
files = "\\.sieve$"; };
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 = ./.;
hooks = {
# docs
markdownlint = {
enable = true;
settings.configuration = {
# Max line length, doesn't seem to correclty 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$";
};
# nix
deadnix.enable = true;
nixfmt-rfc-style.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$";
};
}; };
}; };
}; };
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
formatter.${system} = pkgs.nixfmt-tree;
}; };
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
};
} }

View file

@ -14,28 +14,44 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.mailserver.borgbackup; cfg = config.mailserver.borgbackup;
methodFragment = lib.optional (cfg.compression.method != null) cfg.compression.method; methodFragment = lib.optional (cfg.compression.method != null) cfg.compression.method;
autoFragment = autoFragment =
if cfg.compression.auto && cfg.compression.method == null if cfg.compression.auto && cfg.compression.method == null then
then throw "compression.method must be set when using auto." throw "compression.method must be set when using auto."
else lib.optional cfg.compression.auto "auto"; else
lib.optional cfg.compression.auto "auto";
levelFragment = levelFragment =
if cfg.compression.level != null && cfg.compression.method == null if cfg.compression.level != null && cfg.compression.method == null then
then throw "compression.method must be set when using compression.level." throw "compression.method must be set when using compression.level."
else lib.optional (cfg.compression.level != null) (toString cfg.compression.level); else
compressionFragment = lib.concatStringsSep "," (lib.flatten [autoFragment methodFragment levelFragment]); lib.optional (cfg.compression.level != null) (toString cfg.compression.level);
compressionFragment = lib.concatStringsSep "," (
lib.flatten [
autoFragment
methodFragment
levelFragment
]
);
compression = lib.optionalString (compressionFragment != "") "--compression ${compressionFragment}"; compression = lib.optionalString (compressionFragment != "") "--compression ${compressionFragment}";
encryptionFragment = cfg.encryption.method; encryptionFragment = cfg.encryption.method;
passphraseFile = lib.escapeShellArg cfg.encryption.passphraseFile; passphraseFile = lib.escapeShellArg cfg.encryption.passphraseFile;
passphraseFragment = lib.optionalString (cfg.encryption.method != "none") passphraseFragment = lib.optionalString (cfg.encryption.method != "none") (
(if cfg.encryption.passphraseFile != null then ''env BORG_PASSPHRASE="$(cat ${passphraseFile})"'' if cfg.encryption.passphraseFile != null then
else throw "passphraseFile must be set when using encryption."); ''env BORG_PASSPHRASE="$(cat ${passphraseFile})"''
else
throw "passphraseFile must be set when using encryption."
);
locations = lib.escapeShellArgs cfg.locations; locations = lib.escapeShellArgs cfg.locations;
name = lib.escapeShellArg cfg.name; name = lib.escapeShellArg cfg.name;
@ -55,7 +71,8 @@ let
${passphraseFragment} ${pkgs.borgbackup}/bin/borg create ${extraCreateArgs} ${compression} ::${name} ${locations} ${passphraseFragment} ${pkgs.borgbackup}/bin/borg create ${extraCreateArgs} ${compression} ::${name} ${locations}
${cmdPostexec} ${cmdPostexec}
''; '';
in { in
{
config = lib.mkIf (config.mailserver.enable && cfg.enable) { config = lib.mkIf (config.mailserver.enable && cfg.enable) {
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
borgbackup borgbackup

View file

@ -14,57 +14,76 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, pkgs, lib }: {
config,
pkgs,
lib,
}:
let let
cfg = config.mailserver; cfg = config.mailserver;
in in
{ {
# cert :: PATH # cert :: PATH
certificatePath = if cfg.certificateScheme == "manual" certificatePath =
then cfg.certificateFile if cfg.certificateScheme == "manual" then
else if cfg.certificateScheme == "selfsigned" cfg.certificateFile
then "${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem" else if cfg.certificateScheme == "selfsigned" then
else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" "${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem"
then "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/fullchain.pem" else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" then
else throw "unknown certificate scheme"; "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/fullchain.pem"
else
throw "unknown certificate scheme";
# key :: PATH # key :: PATH
keyPath = if cfg.certificateScheme == "manual" keyPath =
then cfg.keyFile if cfg.certificateScheme == "manual" then
else if cfg.certificateScheme == "selfsigned" cfg.keyFile
then "${cfg.certificateDirectory}/key-${cfg.fqdn}.pem" else if cfg.certificateScheme == "selfsigned" then
else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" "${cfg.certificateDirectory}/key-${cfg.fqdn}.pem"
then "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/key.pem" else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" then
else throw "unknown certificate scheme"; "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/key.pem"
else
throw "unknown certificate scheme";
passwordFiles = let passwordFiles =
mkHashFile = name: hash: pkgs.writeText "${builtins.hashString "sha256" name}-password-hash" hash; let
in mkHashFile = name: hash: pkgs.writeText "${builtins.hashString "sha256" name}-password-hash" hash;
lib.mapAttrs (name: value: in
if value.hashedPasswordFile == null then lib.mapAttrs (
builtins.toString (mkHashFile name value.hashedPassword) name: value:
else value.hashedPasswordFile) cfg.loginAccounts; if value.hashedPasswordFile == null then
builtins.toString (mkHashFile name value.hashedPassword)
else
value.hashedPasswordFile
) cfg.loginAccounts;
# Appends the LDAP bind password to files to avoid writing this # Appends the LDAP bind password to files to avoid writing this
# password into the Nix store. # password into the Nix store.
appendLdapBindPwd = { appendLdapBindPwd =
name, file, prefix, suffix ? "", passwordFile, destination {
}: pkgs.writeScript "append-ldap-bind-pwd-in-${name}" '' name,
#!${pkgs.stdenv.shell} file,
set -euo pipefail prefix,
suffix ? "",
passwordFile,
destination,
}:
pkgs.writeScript "append-ldap-bind-pwd-in-${name}" ''
#!${pkgs.stdenv.shell}
set -euo pipefail
baseDir=$(dirname ${destination}) baseDir=$(dirname ${destination})
if (! test -d "$baseDir"); then if (! test -d "$baseDir"); then
mkdir -p $baseDir mkdir -p $baseDir
chmod 755 $baseDir chmod 755 $baseDir
fi fi
cat ${file} > ${destination} cat ${file} > ${destination}
echo -n '${prefix}' >> ${destination} echo -n '${prefix}' >> ${destination}
cat ${passwordFile} | tr -d '\n' >> ${destination} cat ${passwordFile} | tr -d '\n' >> ${destination}
echo -n '${suffix}' >> ${destination} echo -n '${suffix}' >> ${destination}
chmod 600 ${destination} chmod 600 ${destination}
''; '';
} }

View file

@ -14,7 +14,12 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
with (import ./common.nix { inherit config pkgs lib; }); with (import ./common.nix { inherit config pkgs lib; });
@ -28,10 +33,14 @@ let
ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext"; ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext";
boolToYesNo = x: if x then "yes" else "no"; boolToYesNo = x: if x then "yes" else "no";
listToLine = lib.concatStringsSep " "; listToLine = lib.concatStringsSep " ";
listToMultiAttrs = keyPrefix: attrs: lib.listToAttrs (lib.imap1 (n: x: { listToMultiAttrs =
name = "${keyPrefix}${if n==1 then "" else toString n}"; keyPrefix: attrs:
value = x; lib.listToAttrs (
}) attrs); lib.imap1 (n: x: {
name = "${keyPrefix}${if n == 1 then "" else toString n}";
value = x;
}) attrs
);
maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs"; maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs";
maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8"; maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8";
@ -39,9 +48,7 @@ let
# maildir in format "/${domain}/${user}" # maildir in format "/${domain}/${user}"
dovecotMaildir = dovecotMaildir =
"maildir:${cfg.mailDirectory}/%{domain}/%{username}${maildirLayoutAppendix}${maildirUTF8FolderNames}" "maildir:${cfg.mailDirectory}/%{domain}/%{username}${maildirLayoutAppendix}${maildirUTF8FolderNames}"
+ (lib.optionalString (cfg.indexDir != null) + (lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/%{domain}/%{username}");
":INDEX=${cfg.indexDir}/%{domain}/%{username}"
);
postfixCfg = config.services.postfix; postfixCfg = config.services.postfix;
@ -51,7 +58,7 @@ let
ldap_version = 3 ldap_version = 3
uris = ${lib.concatStringsSep " " cfg.ldap.uris} uris = ${lib.concatStringsSep " " cfg.ldap.uris}
${lib.optionalString cfg.ldap.startTls '' ${lib.optionalString cfg.ldap.startTls ''
tls = yes tls = yes
''} ''}
tls_require_cert = hard tls_require_cert = hard
tls_ca_cert_file = ${cfg.ldap.tlsCAFile} tls_ca_cert_file = ${cfg.ldap.tlsCAFile}
@ -61,11 +68,11 @@ let
base = ${cfg.ldap.searchBase} base = ${cfg.ldap.searchBase}
scope = ${mkLdapSearchScope cfg.ldap.searchScope} scope = ${mkLdapSearchScope cfg.ldap.searchScope}
${lib.optionalString (cfg.ldap.dovecot.userAttrs != null) '' ${lib.optionalString (cfg.ldap.dovecot.userAttrs != null) ''
user_attrs = ${cfg.ldap.dovecot.userAttrs} user_attrs = ${cfg.ldap.dovecot.userAttrs}
''} ''}
user_filter = ${cfg.ldap.dovecot.userFilter} user_filter = ${cfg.ldap.dovecot.userFilter}
${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") '' ${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") ''
pass_attrs = ${cfg.ldap.dovecot.passAttrs} pass_attrs = ${cfg.ldap.dovecot.passAttrs}
''} ''}
pass_filter = ${cfg.ldap.dovecot.passFilter} pass_filter = ${cfg.ldap.dovecot.passFilter}
''; '';
@ -93,7 +100,9 @@ let
# Prevent world-readable password files, even temporarily. # Prevent world-readable password files, even temporarily.
umask 077 umask 077
for f in ${builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.loginAccounts)}; do for f in ${
builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.loginAccounts)
}; do
if [ ! -f "$f" ]; then if [ ! -f "$f" ]; then
echo "Expected password hash file $f does not exist!" echo "Expected password hash file $f does not exist!"
exit 1 exit 1
@ -101,34 +110,49 @@ let
done done
cat <<EOF > ${passwdFile} cat <<EOF > ${passwdFile}
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: _: ${lib.concatStringsSep "\n" (
"${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::" lib.mapAttrsToList (
) cfg.loginAccounts)} name: _: "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::"
) cfg.loginAccounts
)}
EOF EOF
cat <<EOF > ${userdbFile} cat <<EOF > ${userdbFile}
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: ${lib.concatStringsSep "\n" (
"${name}:::::::" lib.mapAttrsToList (
name: value:
"${name}:::::::"
+ lib.optionalString (value.quota != null) "userdb_quota_rule=*:storage=${value.quota}" + lib.optionalString (value.quota != null) "userdb_quota_rule=*:storage=${value.quota}"
) cfg.loginAccounts)} ) cfg.loginAccounts
)}
EOF EOF
''; '';
junkMailboxes = builtins.attrNames (lib.filterAttrs (_: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes); junkMailboxes = builtins.attrNames (
lib.filterAttrs (_: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes
);
junkMailboxNumber = builtins.length junkMailboxes; junkMailboxNumber = builtins.length junkMailboxes;
# The assertion garantees there is exactly one Junk mailbox. # The assertion garantees there is exactly one Junk mailbox.
junkMailboxName = if junkMailboxNumber == 1 then builtins.elemAt junkMailboxes 0 else ""; junkMailboxName = if junkMailboxNumber == 1 then builtins.elemAt junkMailboxes 0 else "";
mkLdapSearchScope = scope: ( mkLdapSearchScope =
if scope == "sub" then "subtree" scope:
else if scope == "one" then "onelevel" (
else scope if scope == "sub" then
); "subtree"
else if scope == "one" then
"onelevel"
else
scope
);
ftsPluginSettings = { ftsPluginSettings = {
fts = "flatcurve"; fts = "flatcurve";
fts_languages = listToLine cfg.fullTextSearch.languages; fts_languages = listToLine cfg.fullTextSearch.languages;
fts_tokenizers = listToLine [ "generic" "email-address" ]; fts_tokenizers = listToLine [
"generic"
"email-address"
];
fts_tokenizer_email_address = "maxlen=100"; # default 254 too large for Xapian fts_tokenizer_email_address = "maxlen=100"; # default 254 too large for Xapian
fts_flatcurve_substring_search = boolToYesNo cfg.fullTextSearch.substringSearch; fts_flatcurve_substring_search = boolToYesNo cfg.fullTextSearch.substringSearch;
fts_filters = listToLine cfg.fullTextSearch.filters; fts_filters = listToLine cfg.fullTextSearch.filters;
@ -139,255 +163,283 @@ let
in in
{ {
config = with cfg; lib.mkIf enable { config =
assertions = [ with cfg;
{ lib.mkIf enable {
assertion = junkMailboxNumber == 1; assertions = [
message = "nixos-mailserver requires exactly one dovecot mailbox with the 'special use' flag set to 'Junk' (${builtins.toString junkMailboxNumber} have been found)"; {
} 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)";
}
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.
'')
;
# 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.
environment.systemPackages = [
pkgs.dovecot_pigeonhole
] ++ lib.optional cfg.fullTextSearch.enable pkgs.dovecot-fts-flatcurve;
# 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;
enableDHE = lib.mkDefault false;
enableLmtp = true;
mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [
"fts"
"fts_flatcurve"
]; ];
protocols = lib.optional cfg.enableManageSieve "sieve";
pluginSettings = { warnings =
sieve = "file:${cfg.sieveDirectory}/%{user}/scripts;active=${cfg.sieveDirectory}/%{user}/active.sieve"; lib.optional
sieve_default = "file:${cfg.sieveDirectory}/%{user}/default.sieve"; (
sieve_default_name = "default"; (builtins.length cfg.fullTextSearch.languages > 1)
} // (lib.optionalAttrs cfg.fullTextSearch.enable ftsPluginSettings); && (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.
sieve = { The recommended solution is to NOT use the stopword filter when
extensions = [ multiple languages are present in the configuration.
"fileinto" '';
# 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.
environment.systemPackages = [
pkgs.dovecot_pigeonhole
] ++ lib.optional cfg.fullTextSearch.enable pkgs.dovecot-fts-flatcurve;
# 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;
enableDHE = lib.mkDefault false;
enableLmtp = true;
mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [
"fts"
"fts_flatcurve"
]; ];
protocols = lib.optional cfg.enableManageSieve "sieve";
scripts.after = builtins.toFile "spam.sieve" '' pluginSettings = {
require "fileinto"; sieve = "file:${cfg.sieveDirectory}/%{user}/scripts;active=${cfg.sieveDirectory}/%{user}/active.sieve";
sieve_default = "file:${cfg.sieveDirectory}/%{user}/default.sieve";
sieve_default_name = "default";
} // (lib.optionalAttrs cfg.fullTextSearch.enable ftsPluginSettings);
if header :is "X-Spam" "Yes" { sieve = {
fileinto "${junkMailboxName}"; extensions = [
stop; "fileinto"
];
scripts.after = builtins.toFile "spam.sieve" ''
require "fileinto";
if header :is "X-Spam" "Yes" {
fileinto "${junkMailboxName}";
stop;
}
'';
pipeBins = map lib.getExe [
(pkgs.writeShellScriptBin "rspamd-learn-ham.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_ham")
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh" "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_spam")
];
};
imapsieve.mailbox = [
{
name = junkMailboxName;
causes = [
"COPY"
"APPEND"
];
before = ./dovecot/imap_sieve/report-spam.sieve;
}
{
name = "*";
from = junkMailboxName;
causes = [ "COPY" ];
before = ./dovecot/imap_sieve/report-ham.sieve;
} }
'';
pipeBins = map lib.getExe [
(pkgs.writeShellScriptBin "rspamd-learn-ham.sh"
"exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_ham")
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh"
"exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_spam")
]; ];
mailboxes = cfg.mailboxes;
extraConfig = ''
#Extra Config
${lib.optionalString debug ''
mail_debug = yes
auth_debug = yes
verbose_ssl = yes
''}
${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
''
}
}
}
''}
${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
}
service imap {
vsz_limit = ${builtins.toString cfg.imapMemoryLimit} MB
}
protocol pop3 {
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
}
mail_access_groups = ${vmailGroupName}
# https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7
ssl = required
ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = no
ssl_curve_list = X25519:prime256v1:secp384r1
service lmtp {
unix_listener dovecot-lmtp {
group = ${postfixCfg.group}
mode = 0600
user = ${postfixCfg.user}
}
vsz_limit = ${builtins.toString cfg.lmtpMemoryLimit} MB
}
service quota-status {
inet_listener {
port = 0
}
unix_listener quota-status {
user = postfix
}
vsz_limit = ${builtins.toString cfg.quotaStatusMemoryLimit} MB
}
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=${cfg.mailDirectory}/ldap/%{user} 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
}
service indexer-worker {
${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) ''
vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit * 1024 * 1024)}
''}
}
lda_mailbox_autosubscribe = yes
lda_mailbox_autocreate = yes
'';
}; };
imapsieve.mailbox = [ systemd.services.dovecot2 = {
{ preStart =
name = junkMailboxName; ''
causes = [ "COPY" "APPEND" ]; ${genPasswdScript}
before = ./dovecot/imap_sieve/report-spam.sieve; ''
} + (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile);
{ };
name = "*";
from = junkMailboxName;
causes = [ "COPY" ];
before = ./dovecot/imap_sieve/report-ham.sieve;
}
];
mailboxes = cfg.mailboxes; systemd.services.postfix.restartTriggers = [
genPasswdScript
extraConfig = '' ] ++ (lib.optional cfg.ldap.enable [ setPwdInLdapConfFile ]);
#Extra Config
${lib.optionalString debug ''
mail_debug = yes
auth_debug = yes
verbose_ssl = yes
''}
${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
''}
}
}
''}
${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
}
service imap {
vsz_limit = ${builtins.toString cfg.imapMemoryLimit} MB
}
protocol pop3 {
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
}
mail_access_groups = ${vmailGroupName}
# https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7
ssl = required
ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = no
ssl_curve_list = X25519:prime256v1:secp384r1
service lmtp {
unix_listener dovecot-lmtp {
group = ${postfixCfg.group}
mode = 0600
user = ${postfixCfg.user}
}
vsz_limit = ${builtins.toString cfg.lmtpMemoryLimit} MB
}
service quota-status {
inet_listener {
port = 0
}
unix_listener quota-status {
user = postfix
}
vsz_limit = ${builtins.toString cfg.quotaStatusMemoryLimit} MB
}
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=${cfg.mailDirectory}/ldap/%{user} 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
}
service indexer-worker {
${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) ''
vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit*1024*1024)}
''}
}
lda_mailbox_autosubscribe = yes
lda_mailbox_autocreate = yes
'';
}; };
systemd.services.dovecot2 = {
preStart = ''
${genPasswdScript}
'' + (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile);
};
systemd.services.postfix.restartTriggers = [ genPasswdScript ] ++ (lib.optional cfg.ldap.enable [setPwdInLdapConfFile]);
};
} }

View file

@ -14,15 +14,28 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.mailserver; cfg = config.mailserver;
in in
{ {
config = with cfg; lib.mkIf enable { config =
environment.systemPackages = with pkgs; [ with cfg;
dovecot openssh postfix rspamd lib.mkIf enable {
] ++ (if certificateScheme == "selfsigned" then [ openssl ] else []); environment.systemPackages =
}; with pkgs;
[
dovecot
openssh
postfix
rspamd
]
++ (if certificateScheme == "selfsigned" then [ openssl ] else [ ]);
};
} }

View file

@ -24,4 +24,3 @@ in
services.kresd.enable = true; services.kresd.enable = true;
}; };
} }

View file

@ -20,18 +20,21 @@ let
cfg = config.mailserver; cfg = config.mailserver;
in in
{ {
config = with cfg; lib.mkIf (enable && openFirewall) { config =
with cfg;
lib.mkIf (enable && openFirewall) {
networking.firewall = { networking.firewall = {
allowedTCPPorts = [ 25 ] allowedTCPPorts =
++ lib.optional enableSubmission 587 [ 25 ]
++ lib.optional enableSubmissionSsl 465 ++ lib.optional enableSubmission 587
++ lib.optional enableImap 143 ++ lib.optional enableSubmissionSsl 465
++ lib.optional enableImapSsl 993 ++ lib.optional enableImap 143
++ lib.optional enablePop3 110 ++ lib.optional enableImapSsl 993
++ lib.optional enablePop3Ssl 995 ++ lib.optional enablePop3 110
++ lib.optional enableManageSieve 4190 ++ lib.optional enablePop3Ssl 995
++ lib.optional (certificateScheme == "acme-nginx") 80; ++ lib.optional enableManageSieve 4190
++ lib.optional (certificateScheme == "acme-nginx") 80;
};
}; };
};
} }

View file

@ -14,8 +14,12 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{
{ config, pkgs, lib, ... }: config,
pkgs,
lib,
...
}:
with (import ./common.nix { inherit config lib pkgs; }); with (import ./common.nix { inherit config lib pkgs; });
@ -23,20 +27,22 @@ let
cfg = config.mailserver; cfg = config.mailserver;
in in
{ {
config = lib.mkIf (cfg.enable && (cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx")) { config =
services.nginx = lib.mkIf (cfg.certificateScheme == "acme-nginx") { lib.mkIf (cfg.enable && (cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx"))
enable = true; {
virtualHosts."${cfg.fqdn}" = { services.nginx = lib.mkIf (cfg.certificateScheme == "acme-nginx") {
serverName = cfg.fqdn; enable = true;
serverAliases = cfg.certificateDomains; virtualHosts."${cfg.fqdn}" = {
forceSSL = true; serverName = cfg.fqdn;
enableACME = true; serverAliases = cfg.certificateDomains;
}; forceSSL = true;
}; enableACME = true;
};
};
security.acme.certs."${cfg.acmeCertificateName}".reloadServices = [ security.acme.certs."${cfg.acmeCertificateName}".reloadServices = [
"postfix.service" "postfix.service"
"dovecot2.service" "dovecot2.service"
]; ];
}; };
} }

View file

@ -14,7 +14,12 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
with (import ./common.nix { inherit config pkgs lib; }); with (import ./common.nix { inherit config pkgs lib; });
@ -28,31 +33,55 @@ let
mergeLookupTables = tables: lib.zipAttrsWith (_: v: lib.flatten v) tables; mergeLookupTables = tables: lib.zipAttrsWith (_: v: lib.flatten v) tables;
# valiases_postfix :: Map String [String] # valiases_postfix :: Map String [String]
valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList valiases_postfix = mergeLookupTables (
(name: value: lib.flatten (
let to = name; lib.mapAttrsToList (
in map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name)) name: value:
cfg.loginAccounts)); let
regex_valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList to = name;
(name: value: in
let to = name; map (from: { "${from}" = to; }) (value.aliases ++ lib.singleton name)
in map (from: {"${from}" = to;}) value.aliasesRegexp) ) cfg.loginAccounts
cfg.loginAccounts)); )
);
regex_valiases_postfix = mergeLookupTables (
lib.flatten (
lib.mapAttrsToList (
name: value:
let
to = name;
in
map (from: { "${from}" = to; }) value.aliasesRegexp
) cfg.loginAccounts
)
);
# catchAllPostfix :: Map String [String] # catchAllPostfix :: Map String [String]
catchAllPostfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList catchAllPostfix = mergeLookupTables (
(name: value: lib.flatten (
let to = name; lib.mapAttrsToList (
in map (from: {"@${from}" = to;}) value.catchAll) name: value:
cfg.loginAccounts)); let
to = name;
in
map (from: { "@${from}" = to; }) value.catchAll
) cfg.loginAccounts
)
);
# all_valiases_postfix :: Map String [String] # 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 :: Map String (Either String [ String ]) -> Map String [String]
attrsToLookupTable = aliases: let attrsToLookupTable =
lookupTables = lib.mapAttrsToList (from: to: {"${from}" = to;}) aliases; aliases:
in mergeLookupTables lookupTables; let
lookupTables = lib.mapAttrsToList (from: to: { "${from}" = to; }) aliases;
in
mergeLookupTables lookupTables;
# extra_valiases_postfix :: Map String [String] # extra_valiases_postfix :: Map String [String]
extra_valiases_postfix = attrsToLookupTable cfg.extraVirtualAliases; extra_valiases_postfix = attrsToLookupTable cfg.extraVirtualAliases;
@ -61,37 +90,49 @@ let
forwards = attrsToLookupTable cfg.forwards; forwards = attrsToLookupTable cfg.forwards;
# lookupTableToString :: Map String [String] -> String # lookupTableToString :: Map String [String] -> String
lookupTableToString = attrs: let lookupTableToString =
valueToString = value: lib.concatStringsSep ", " value; attrs:
in lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs); let
valueToString = value: lib.concatStringsSep ", " value;
in
lib.concatStringsSep "\n" (
lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs
);
# valiases_file :: Path # valiases_file :: Path
valiases_file = let valiases_file =
content = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix]); let
in builtins.toFile "valias" content; content = lookupTableToString (mergeLookupTables [
all_valiases_postfix
catchAllPostfix
]);
in
builtins.toFile "valias" content;
regex_valiases_file = let regex_valiases_file =
content = lookupTableToString regex_valiases_postfix; let
in builtins.toFile "regex_valias" content; content = lookupTableToString regex_valiases_postfix;
in
builtins.toFile "regex_valias" content;
# denied_recipients_postfix :: [ String ] # denied_recipients_postfix :: [ String ]
denied_recipients_postfix = (map denied_recipients_postfix = map (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") (
(acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts)
(lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts))); );
denied_recipients_file = builtins.toFile "denied_recipients" (lib.concatStringsSep "\n" denied_recipients_postfix); denied_recipients_file = builtins.toFile "denied_recipients" (
lib.concatStringsSep "\n" denied_recipients_postfix
);
reject_senders_postfix = (map reject_senders_postfix = map (sender: "${sender} REJECT") cfg.rejectSender;
(sender: reject_senders_file = builtins.toFile "reject_senders" (
"${sender} REJECT") lib.concatStringsSep "\n" reject_senders_postfix
(cfg.rejectSender)); );
reject_senders_file = builtins.toFile "reject_senders" (lib.concatStringsSep "\n" (reject_senders_postfix)) ;
reject_recipients_postfix = (map reject_recipients_postfix = map (recipient: "${recipient} REJECT") cfg.rejectRecipients;
(recipient:
"${recipient} REJECT")
(cfg.rejectRecipients));
# rejectRecipients :: [ Path ] # 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 :: Path
vhosts_file = builtins.toFile "vhosts" (concatStringsSep "\n" cfg.domains); vhosts_file = builtins.toFile "vhosts" (concatStringsSep "\n" cfg.domains);
@ -103,45 +144,51 @@ let
# every alias is owned (uniquely) by its user. # every alias is owned (uniquely) by its user.
# The user's own address is already in all_valiases_postfix. # The user's own address is already in all_valiases_postfix.
vaccounts_file = builtins.toFile "vaccounts" (lookupTableToString 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" ('' 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/ # Removes sensitive headers from mails handed in via the submission port.
# Uses "pcre" style regex. # See https://thomas-leister.de/mailserver-debian-stretch/
# Uses "pcre" style regex.
/^Received:/ IGNORE /^Received:/ IGNORE
/^X-Originating-IP:/ IGNORE /^X-Originating-IP:/ IGNORE
/^X-Mailer:/ IGNORE /^X-Mailer:/ IGNORE
/^User-Agent:/ IGNORE /^User-Agent:/ IGNORE
/^X-Enigmail:/ IGNORE /^X-Enigmail:/ IGNORE
'' + lib.optionalString cfg.rewriteMessageId '' ''
+ lib.optionalString cfg.rewriteMessageId ''
# Replaces the user submitted hostname with the server's FQDN to hide the # Replaces the user submitted hostname with the server's FQDN to hide the
# user's host or network. # user's host or network.
/^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}> /^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}>
''); ''
);
smtpdMilters = [ "unix:/run/rspamd/rspamd-milter.sock" ]; smtpdMilters = [ "unix:/run/rspamd/rspamd-milter.sock" ];
mappedFile = name: "hash:/var/lib/postfix/conf/${name}"; mappedFile = name: "hash:/var/lib/postfix/conf/${name}";
mappedRegexFile = name: "pcre:/var/lib/postfix/conf/${name}"; mappedRegexFile = name: "pcre:/var/lib/postfix/conf/${name}";
submissionOptions = submissionOptions = {
{ smtpd_tls_security_level = "encrypt";
smtpd_tls_security_level = "encrypt"; smtpd_sasl_auth_enable = "yes";
smtpd_sasl_auth_enable = "yes"; smtpd_sasl_type = "dovecot";
smtpd_sasl_type = "dovecot"; smtpd_sasl_path = "/run/dovecot2/auth";
smtpd_sasl_path = "/run/dovecot2/auth"; smtpd_sasl_security_options = "noanonymous";
smtpd_sasl_security_options = "noanonymous"; smtpd_sasl_local_domain = "$myhostname";
smtpd_sasl_local_domain = "$myhostname"; smtpd_client_restrictions = "permit_sasl_authenticated,reject";
smtpd_client_restrictions = "permit_sasl_authenticated,reject"; smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}${
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"}"; 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"; smtpd_sender_restrictions = "reject_sender_login_mismatch";
cleanup_service_name = "submission-header-cleanup"; smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject";
}; cleanup_service_name = "submission-header-cleanup";
};
commonLdapConfig = '' commonLdapConfig = ''
server_host = ${lib.concatStringsSep " " cfg.ldap.uris} server_host = ${lib.concatStringsSep " " cfg.ldap.uris}
@ -186,164 +233,183 @@ let
}; };
in in
{ {
config = with cfg; lib.mkIf enable { config =
with cfg;
lib.mkIf enable {
systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable { systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable {
preStart = '' preStart = ''
${appendPwdInVirtualMailboxMap} ${appendPwdInVirtualMailboxMap}
${appendPwdInSenderLoginMap} ${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;
mapFiles."regex_vaccounts" = regex_vaccounts_file;
mapFiles."denied_recipients" = denied_recipients_file;
mapFiles."reject_senders" = reject_senders_file;
mapFiles."reject_recipients" = reject_recipients_file;
enableSubmission = cfg.enableSubmission;
enableSubmissions = cfg.enableSubmissionSsl;
virtual = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix forwards]);
config = {
smtpd_tls_chain_files = [
"${keyPath}"
"${certificatePath}"
]; ];
};
# Extra Config services.postfix = {
mydestination = ""; enable = true;
recipient_delimiter = cfg.recipientDelimiter; hostname = "${sendingFqdn}";
smtpd_banner = "${fqdn} ESMTP NO UCE"; networksStyle = "host";
disable_vrfy_command = true; mapFiles."valias" = valiases_file;
message_size_limit = toString cfg.messageSizeLimit; mapFiles."regex_valias" = regex_valiases_file;
mapFiles."vaccounts" = vaccounts_file;
# virtual mail system mapFiles."regex_vaccounts" = regex_vaccounts_file;
virtual_uid_maps = "static:5000"; mapFiles."denied_recipients" = denied_recipients_file;
virtual_gid_maps = "static:5000"; mapFiles."reject_senders" = reject_senders_file;
virtual_mailbox_base = mailDirectory; mapFiles."reject_recipients" = reject_recipients_file;
virtual_mailbox_domains = vhosts_file; enableSubmission = cfg.enableSubmission;
virtual_mailbox_maps = [ enableSubmissions = cfg.enableSubmissionSsl;
(mappedFile "valias") virtual = lookupTableToString (mergeLookupTables [
] ++ lib.optionals (cfg.ldap.enable) [ all_valiases_postfix
"ldap:${ldapVirtualMailboxMapFile}" catchAllPostfix
] ++ lib.optionals (regex_valiases_postfix != {}) [ forwards
(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";
# sasl with dovecot config = {
smtpd_sasl_type = "dovecot"; smtpd_tls_chain_files = [
smtpd_sasl_path = "/run/dovecot2/auth"; "${keyPath}"
smtpd_sasl_auth_enable = true; "${certificatePath}"
smtpd_relay_restrictions = [ ];
"permit_mynetworks" "permit_sasl_authenticated" "reject_unauth_destination"
];
# reject selected senders # Extra Config
smtpd_sender_restrictions = [ mydestination = "";
"check_sender_access ${mappedFile "reject_senders"}" recipient_delimiter = cfg.recipientDelimiter;
]; smtpd_banner = "${fqdn} ESMTP NO UCE";
disable_vrfy_command = true;
message_size_limit = toString cfg.messageSizeLimit;
smtpd_recipient_restrictions = [ # virtual mail system
# reject selected recipients virtual_uid_maps = "static:5000";
"check_recipient_access ${mappedFile "denied_recipients"}" virtual_gid_maps = "static:5000";
"check_recipient_access ${mappedFile "reject_recipients"}" virtual_mailbox_base = mailDirectory;
# quota checking virtual_mailbox_domains = vhosts_file;
"check_policy_service unix:/run/dovecot2/quota-status" virtual_mailbox_maps =
]; [
(mappedFile "valias")
]
++ lib.optionals cfg.ldap.enable [
"ldap:${ldapVirtualMailboxMapFile}"
]
++ 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";
# TLS for incoming mail is optional # sasl with dovecot
smtpd_tls_security_level = "may"; smtpd_sasl_type = "dovecot";
smtpd_sasl_path = "/run/dovecot2/auth";
smtpd_sasl_auth_enable = true;
smtpd_relay_restrictions = [
"permit_mynetworks"
"permit_sasl_authenticated"
"reject_unauth_destination"
];
# But required for authentication attempts # reject selected senders
smtpd_tls_auth_only = true; smtpd_sender_restrictions = [
"check_sender_access ${mappedFile "reject_senders"}"
];
# TLS versions supported for the SMTP server smtpd_recipient_restrictions = [
smtpd_tls_protocols = ">=TLSv1.2"; # reject selected recipients
smtpd_tls_mandatory_protocols = ">=TLSv1.2"; "check_recipient_access ${mappedFile "denied_recipients"}"
"check_recipient_access ${mappedFile "reject_recipients"}"
# quota checking
"check_policy_service unix:/run/dovecot2/quota-status"
];
# Require ciphersuites that OpenSSL classifies as "High" # TLS for incoming mail is optional
smtpd_tls_ciphers = "high"; smtpd_tls_security_level = "may";
smtpd_tls_mandatory_ciphers = "high";
# Exclude cipher suites with undesirable properties # But required for authentication attempts
smtpd_tls_exclude_ciphers = "eNULL, aNULL"; smtpd_tls_auth_only = true;
smtpd_tls_mandatory_exclude_ciphers = "eNULL, aNULL";
# Opportunistic DANE support when delivering mail to other servers # TLS versions supported for the SMTP server
# https://www.postfix.org/postconf.5.html#smtp_tls_security_level smtpd_tls_protocols = ">=TLSv1.2";
smtp_dns_support_level = "dnssec"; smtpd_tls_mandatory_protocols = ">=TLSv1.2";
smtp_tls_security_level = "dane";
# TLS versions supported for the SMTP client # Require ciphersuites that OpenSSL classifies as "High"
smtp_tls_protocols = ">=TLSv1.2"; smtpd_tls_ciphers = "high";
smtp_tls_mandatory_protocols = ">=TLSv1.2"; smtpd_tls_mandatory_ciphers = "high";
# Require ciphersuites that OpenSSL classifies as "High" # Exclude cipher suites with undesirable properties
smtp_tls_ciphers = "high"; smtpd_tls_exclude_ciphers = "eNULL, aNULL";
smtp_tls_mandatory_ciphers = "high"; smtpd_tls_mandatory_exclude_ciphers = "eNULL, aNULL";
# Exclude ciphersuites with undesirable properties # Opportunistic DANE support when delivering mail to other servers
smtp_tls_exclude_ciphers = "eNULL, aNULL"; # https://www.postfix.org/postconf.5.html#smtp_tls_security_level
smtp_tls_mandatory_exclude_ciphers = "eNULL, aNULL"; smtp_dns_support_level = "dnssec";
smtp_tls_security_level = "dane";
# Restrict and prioritize the following curves in the given order # TLS versions supported for the SMTP client
# Excludes curves that have no widespread support, so we don't bloat the handshake needlessly. smtp_tls_protocols = ">=TLSv1.2";
# https://www.postfix.org/postconf.5.html#tls_eecdh_auto_curves smtp_tls_mandatory_protocols = ">=TLSv1.2";
# https://ssl-config.mozilla.org/#server=postfix&version=3.10&config=intermediate&openssl=3.4.1&guideline=5.7
tls_eecdh_auto_curves = [
"X25519"
"prime256v1"
"secp384r1"
];
# Disable FFDHE on TLSv1.3 because it is slower than elliptic curves # Require ciphersuites that OpenSSL classifies as "High"
# https://www.postfix.org/postconf.5.html#tls_ffdhe_auto_groups smtp_tls_ciphers = "high";
tls_ffdhe_auto_groups = [ ]; smtp_tls_mandatory_ciphers = "high";
# As long as all cipher suites are considered safe, let the client use its preferred cipher # Exclude ciphersuites with undesirable properties
tls_preempt_cipherlist = false; smtp_tls_exclude_ciphers = "eNULL, aNULL";
smtp_tls_mandatory_exclude_ciphers = "eNULL, aNULL";
# Log only a summary message on TLS handshake completion # Restrict and prioritize the following curves in the given order
smtp_tls_loglevel = "1"; # Excludes curves that have no widespread support, so we don't bloat the handshake needlessly.
smtpd_tls_loglevel = "1"; # https://www.postfix.org/postconf.5.html#tls_eecdh_auto_curves
# https://ssl-config.mozilla.org/#server=postfix&version=3.10&config=intermediate&openssl=3.4.1&guideline=5.7
tls_eecdh_auto_curves = [
"X25519"
"prime256v1"
"secp384r1"
];
smtpd_milters = smtpdMilters; # Disable FFDHE on TLSv1.3 because it is slower than elliptic curves
non_smtpd_milters = lib.mkIf cfg.dkimSigning [ "unix:/run/rspamd/rspamd-milter.sock" ]; # https://www.postfix.org/postconf.5.html#tls_ffdhe_auto_groups
milter_protocol = "6"; tls_ffdhe_auto_groups = [ ];
milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_authen}";
};
submissionOptions = submissionOptions; # As long as all cipher suites are considered safe, let the client use its preferred cipher
submissionsOptions = submissionOptions; tls_preempt_cipherlist = false;
masterConfig = { # Log only a summary message on TLS handshake completion
"lmtp" = { smtp_tls_loglevel = "1";
# Add headers when delivering, see http://www.postfix.org/smtp.8.html smtpd_tls_loglevel = "1";
# D => Delivered-To, O => X-Original-To, R => Return-Path
args = [ "flags=O" ]; smtpd_milters = smtpdMilters;
non_smtpd_milters = lib.mkIf cfg.dkimSigning [ "unix:/run/rspamd/rspamd-milter.sock" ];
milter_protocol = "6";
milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_authen}";
}; };
"submission-header-cleanup" = {
type = "unix"; submissionOptions = submissionOptions;
private = false; submissionsOptions = submissionOptions;
chroot = false;
maxproc = 0; masterConfig = {
command = "cleanup"; "lmtp" = {
args = ["-o" "header_checks=pcre:${submissionHeaderCleanupRules}"]; # Add headers when delivering, see http://www.postfix.org/smtp.8.html
# D => Delivered-To, O => X-Original-To, R => Return-Path
args = [ "flags=O" ];
};
"submission-header-cleanup" = {
type = "unix";
private = false;
chroot = false;
maxproc = 0;
command = "cleanup";
args = [
"-o"
"header_checks=pcre:${submissionHeaderCleanupRules}"
];
};
}; };
}; };
}; };
};
} }

View file

@ -14,7 +14,12 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
with lib; with lib;
@ -38,7 +43,8 @@ let
${cfg.backup.cmdPostexec} ${cfg.backup.cmdPostexec}
''; '';
postexecString = optionalString postexecDefined "cmd_postexec ${postexecWrapped}"; postexecString = optionalString postexecDefined "cmd_postexec ${postexecWrapped}";
in { in
{
config = mkIf (cfg.enable && cfg.backup.enable) { config = mkIf (cfg.enable && cfg.backup.enable) {
services.rsnapshot = { services.rsnapshot = {
enable = true; enable = true;

View file

@ -14,7 +14,12 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.mailserver; cfg = config.mailserver;
@ -26,56 +31,74 @@ let
rspamdUser = config.services.rspamd.user; rspamdUser = config.services.rspamd.user;
rspamdGroup = config.services.rspamd.group; rspamdGroup = config.services.rspamd.group;
createDkimKeypair = domain: let createDkimKeypair =
privateKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.key"; domain:
publicKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.txt"; let
in pkgs.writeShellScript "dkim-keygen-${domain}" '' privateKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.key";
if [ ! -f "${privateKey}" ] publicKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.txt";
then in
${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen \ pkgs.writeShellScript "dkim-keygen-${domain}" ''
--domain "${domain}" \ if [ ! -f "${privateKey}" ]
--selector "${cfg.dkimSelector}" \ then
--type "${cfg.dkimKeyType}" \ ${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen \
--bits ${toString cfg.dkimKeyBits} \ --domain "${domain}" \
--privkey "${privateKey}" > "${publicKey}" --selector "${cfg.dkimSelector}" \
chmod 0644 "${publicKey}" --type "${cfg.dkimKeyType}" \
echo "Generated key for domain ${domain} and selector ${cfg.dkimSelector}" --bits ${toString cfg.dkimKeyBits} \
fi --privkey "${privateKey}" > "${publicKey}"
''; chmod 0644 "${publicKey}"
echo "Generated key for domain ${domain} and selector ${cfg.dkimSelector}"
fi
'';
in in
{ {
config = with cfg; lib.mkIf enable { config =
environment.systemPackages = lib.mkBefore [ with cfg;
(pkgs.runCommand "rspamc-wrapped" { lib.mkIf enable {
nativeBuildInputs = with pkgs; [ makeWrapper ]; environment.systemPackages = lib.mkBefore [
}'' (pkgs.runCommand "rspamc-wrapped"
makeWrapper ${pkgs.rspamd}/bin/rspamc $out/bin/rspamc \ {
--add-flags "-h /run/rspamd/worker-controller.sock" nativeBuildInputs = with pkgs; [ makeWrapper ];
'') }
]; ''
makeWrapper ${pkgs.rspamd}/bin/rspamc $out/bin/rspamc \
--add-flags "-h /run/rspamd/worker-controller.sock"
''
)
];
services.rspamd = { services.rspamd = {
enable = true; enable = true;
inherit debug; inherit debug;
locals = { locals = {
"milter_headers.conf" = { text = '' "milter_headers.conf" = {
text = ''
extended_spam_headers = true; extended_spam_headers = true;
''; }; '';
"redis.conf" = { text = '' };
servers = "${if cfg.redis.port == null "redis.conf" = {
then text =
cfg.redis.address ''
else servers = "${
"${cfg.redis.address}:${toString cfg.redis.port}"}"; if cfg.redis.port == null then
'' + (lib.optionalString (cfg.redis.password != null) '' cfg.redis.address
password = "${cfg.redis.password}"; else
''); }; "${cfg.redis.address}:${toString cfg.redis.port}"
"classifier-bayes.conf" = { text = '' }";
''
+ (lib.optionalString (cfg.redis.password != null) ''
password = "${cfg.redis.password}";
'');
};
"classifier-bayes.conf" = {
text = ''
cache { cache {
backend = "redis"; backend = "redis";
} }
''; }; '';
"antivirus.conf" = lib.mkIf cfg.virusScanning { text = '' };
"antivirus.conf" = lib.mkIf cfg.virusScanning {
text = ''
clamav { clamav {
action = "reject"; action = "reject";
symbol = "CLAM_VIRUS"; symbol = "CLAM_VIRUS";
@ -84,157 +107,168 @@ in
servers = "/run/clamav/clamd.ctl"; 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 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.dkimSigning}; "dkim_signing.conf" = {
path = "${cfg.dkimKeyDirectory}/$domain.$selector.key"; text = ''
selector = "${cfg.dkimSelector}"; enabled = ${lib.boolToString cfg.dkimSigning};
# Allow for usernames w/o domain part path = "${cfg.dkimKeyDirectory}/$domain.$selector.key";
allow_username_mismatch = true selector = "${cfg.dkimSelector}";
''; }; # Allow for usernames w/o domain part
"dmarc.conf" = { text = '' allow_username_mismatch = true
'';
};
"dmarc.conf" = {
text = ''
${lib.optionalString cfg.dmarcReporting.enable '' ${lib.optionalString cfg.dmarcReporting.enable ''
reporting { reporting {
enabled = true; enabled = true;
email = "${cfg.dmarcReporting.email}"; email = "${cfg.dmarcReporting.email}";
domain = "${cfg.dmarcReporting.domain}"; domain = "${cfg.dmarcReporting.domain}";
org_name = "${cfg.dmarcReporting.organizationName}"; org_name = "${cfg.dmarcReporting.organizationName}";
from_name = "${cfg.dmarcReporting.fromName}"; from_name = "${cfg.dmarcReporting.fromName}";
msgid_from = "${cfg.dmarcReporting.domain}"; msgid_from = "${cfg.dmarcReporting.domain}";
${lib.optionalString (cfg.dmarcReporting.excludeDomains != []) '' ${lib.optionalString (cfg.dmarcReporting.excludeDomains != [ ]) ''
exclude_domains = ${builtins.toJSON cfg.dmarcReporting.excludeDomains}; exclude_domains = ${builtins.toJSON cfg.dmarcReporting.excludeDomains};
''} ''}
}''} }''}
''; }; '';
};
};
workers.rspamd_proxy = {
type = "rspamd_proxy";
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
timeout = 120s; # Needed for Milter usually
upstream "local" {
default = yes; # Self-scan upstreams are always default
self_scan = yes; # Enable self-scan
}
'';
};
workers.controller = {
type = "controller";
count = 1;
bindSockets = [
{
socket = "/run/rspamd/worker-controller.sock";
mode = "0666";
}
];
includes = [ ];
extraConfig = ''
static_dir = "''${WWWDIR}"; # Serve the web UI static assets
'';
};
}; };
workers.rspamd_proxy = { services.redis.servers.rspamd.enable = lib.mkDefault true;
type = "rspamd_proxy";
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
timeout = 120s; # Needed for Milter usually
upstream "local" { systemd.tmpfiles.settings."10-rspamd.conf" = {
default = yes; # Self-scan upstreams are always default "${cfg.dkimKeyDirectory}" = {
self_scan = yes; # Enable self-scan 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.dkimSigning {
}; ExecStartPre = map createDkimKeypair cfg.domains;
workers.controller = { ReadWritePaths = [ cfg.dkimKeyDirectory ];
type = "controller"; })
count = 1;
bindSockets = [{
socket = "/run/rspamd/worker-controller.sock";
mode = "0666";
}];
includes = [];
extraConfig = ''
static_dir = "''${WWWDIR}"; # Serve the web UI static assets
'';
};
};
services.redis.servers.rspamd.enable = lib.mkDefault true;
systemd.tmpfiles.settings."10-rspamd.conf" = {
"${cfg.dkimKeyDirectory}" = {
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.dkimSigning {
ExecStartPre = map createDkimKeypair cfg.domains;
ReadWritePaths = [ cfg.dkimKeyDirectory ];
})
];
};
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")
'';
serviceConfig = {
User = "${config.services.rspamd.user}";
Group = "${config.services.rspamd.group}";
AmbientCapabilities = [];
CapabilityBoundingSet = "";
DevicePolicy = "closed";
IPAddressAllow = "localhost";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProcSubset = "pid";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
]; ];
UMask = "0077";
}; };
};
systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs (cfg.dmarcReporting.enable) { systemd.services.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable {
description = "Daily delivery of aggregated DMARC reports"; # Explicitly select yesterday's date to work around broken
wantedBy = [ # default behaviour when called without a date.
"timers.target" # https://github.com/rspamd/rspamd/issues/4062
]; script = ''
timerConfig = { ${pkgs.rspamd}/bin/rspamadm dmarc_report $(date -d "yesterday" "+%Y%m%d")
OnCalendar = "daily"; '';
Persistent = true; serviceConfig = {
RandomizedDelaySec = 86400; User = "${config.services.rspamd.user}";
FixedRandomDelay = true; Group = "${config.services.rspamd.group}";
AmbientCapabilities = [ ];
CapabilityBoundingSet = "";
DevicePolicy = "closed";
IPAddressAllow = "localhost";
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateMounts = true;
PrivateTmp = true;
PrivateUsers = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProcSubset = "pid";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
];
UMask = "0077";
};
}; };
};
systemd.services.postfix = { systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable {
after = [ rspamdSocket ]; description = "Daily delivery of aggregated DMARC reports";
requires = [ rspamdSocket ]; wantedBy = [
}; "timers.target"
];
timerConfig = {
OnCalendar = "daily";
Persistent = true;
RandomizedDelaySec = 86400;
FixedRandomDelay = true;
};
};
users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ]; systemd.services.postfix = {
}; after = [ rspamdSocket ];
requires = [ rspamdSocket ];
};
users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ];
};
} }

View file

@ -14,72 +14,79 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
let let
cfg = config.mailserver; cfg = config.mailserver;
certificatesDeps = certificatesDeps =
if cfg.certificateScheme == "manual" then if cfg.certificateScheme == "manual" then
[] [ ]
else if cfg.certificateScheme == "selfsigned" then else if cfg.certificateScheme == "selfsigned" then
[ "mailserver-selfsigned-certificate.service" ] [ "mailserver-selfsigned-certificate.service" ]
else else
[ "acme-finished-${cfg.fqdn}.target" ]; [ "acme-finished-${cfg.fqdn}.target" ];
in in
{ {
config = with cfg; lib.mkIf enable { config =
# Create self signed certificate with cfg;
systemd.services.mailserver-selfsigned-certificate = lib.mkIf (cfg.certificateScheme == "selfsigned") { lib.mkIf enable {
after = [ "local-fs.target" ]; # Create self signed certificate
script = '' systemd.services.mailserver-selfsigned-certificate =
# Create certificates if they do not exist yet lib.mkIf (cfg.certificateScheme == "selfsigned")
dir="${cfg.certificateDirectory}" {
fqdn="${cfg.fqdn}" after = [ "local-fs.target" ];
[[ $fqdn == /* ]] && fqdn=$(< "$fqdn") script = ''
key="$dir/key-${cfg.fqdn}.pem"; # Create certificates if they do not exist yet
cert="$dir/cert-${cfg.fqdn}.pem"; dir="${cfg.certificateDirectory}"
fqdn="${cfg.fqdn}"
[[ $fqdn == /* ]] && fqdn=$(< "$fqdn")
key="$dir/key-${cfg.fqdn}.pem";
cert="$dir/cert-${cfg.fqdn}.pem";
if [[ ! -f $key || ! -f $cert ]]; then if [[ ! -f $key || ! -f $cert ]]; then
mkdir -p "${cfg.certificateDirectory}" mkdir -p "${cfg.certificateDirectory}"
(umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) && (umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) &&
"${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \ "${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \
-days 3650 -out "$cert" -days 3650 -out "$cert"
fi fi
''; '';
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
PrivateTmp = true; 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
# <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 "${vmailGroupName}" ${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 "rspamd.service" ++ certificatesDeps;
requires = [ "dovecot2.service" ] ++ lib.optional cfg.dkimSigning "rspamd.service";
}; };
}; };
# 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
# <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 "${vmailGroupName}" ${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 "rspamd.service"
++ certificatesDeps;
requires = [ "dovecot2.service" ]
++ lib.optional cfg.dkimSigning "rspamd.service";
};
};
} }

View file

@ -14,7 +14,12 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, pkgs, lib, ... }: {
config,
pkgs,
lib,
...
}:
with config.mailserver; with config.mailserver;
@ -28,7 +33,6 @@ let
group = vmailGroupName; group = vmailGroupName;
}; };
virtualMailUsersActivationScript = pkgs.writeScript "activate-virtual-mail-users" '' virtualMailUsersActivationScript = pkgs.writeScript "activate-virtual-mail-users" ''
#!${pkgs.stdenv.shell} #!${pkgs.stdenv.shell}
@ -46,45 +50,54 @@ let
# Copy user's sieve script to the correct location (if it exists). If it # Copy user's sieve script to the correct location (if it exists). If it
# is null, remove the file. # is null, remove the file.
${lib.concatMapStringsSep "\n" ({ name, sieveScript }: ${lib.concatMapStringsSep "\n" (
if lib.isString sieveScript then '' { name, sieveScript }:
if (! test -d "${sieveDirectory}/${name}"); then if lib.isString sieveScript then
mkdir -p "${sieveDirectory}/${name}" ''
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}" if (! test -d "${sieveDirectory}/${name}"); then
chmod 770 "${sieveDirectory}/${name}" mkdir -p "${sieveDirectory}/${name}"
fi chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}"
cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve" chmod 770 "${sieveDirectory}/${name}"
${sieveScript} fi
EOF cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve"
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve" ${sieveScript}
'' else '' EOF
if (test -f "${sieveDirectory}/${name}/default.sieve"); then chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve"
rm "${sieveDirectory}/${name}/default.sieve" ''
fi else
if (test -f "${sieveDirectory}/${name}.svbin"); then ''
rm "${sieveDirectory}/${name}/default.svbin" if (test -f "${sieveDirectory}/${name}/default.sieve"); then
fi rm "${sieveDirectory}/${name}/default.sieve"
'') (map (user: { inherit (user) name sieveScript; }) fi
(lib.attrValues loginAccounts))} if (test -f "${sieveDirectory}/${name}.svbin"); then
rm "${sieveDirectory}/${name}/default.svbin"
fi
''
) (map (user: { inherit (user) name sieveScript; }) (lib.attrValues loginAccounts))}
''; '';
in { in
{
config = lib.mkIf enable { config = lib.mkIf enable {
# assert that all accounts provide a password # assert that all accounts provide a password
assertions = (map (acct: { assertions = map (acct: {
assertion = (acct.hashedPassword != null || acct.hashedPasswordFile != null); assertion = acct.hashedPassword != null || acct.hashedPasswordFile != null;
message = "${acct.name} must provide either a hashed password or a password hash file"; message = "${acct.name} must provide either a hashed password or a password hash file";
}) (lib.attrValues loginAccounts)); }) (lib.attrValues loginAccounts);
# warn for accounts that specify both password and file # warn for accounts that specify both password and file
warnings = (map warnings =
(acct: "${acct.name} specifies both a password hash and hash file; hash file will be used") 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.filter (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null)) (
(lib.attrValues loginAccounts))); lib.attrValues loginAccounts
)
);
# set the vmail gid to a specific value # set the vmail gid to a specific value
users.groups = { users.groups = {
"${vmailGroupName}" = { gid = vmailUID; }; "${vmailGroupName}" = {
gid = vmailUID;
};
}; };
# define all users # define all users

View file

@ -1,10 +1,9 @@
(import (import (
( let
let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in lock = builtins.fromJSON (builtins.readFile ./flake.lock);
fetchTarball { in
url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; fetchTarball {
sha256 = lock.nodes.flake-compat.locked.narHash; url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
} sha256 = lock.nodes.flake-compat.locked.narHash;
) }
{ src = ./.; } ) { src = ./.; }).shellNix
).shellNix

View file

@ -24,73 +24,79 @@
name = "clamav"; name = "clamav";
nodes = { nodes = {
server = { pkgs, ... }: server =
{ { pkgs, ... }:
imports = [ {
../default.nix imports = [
./lib/config.nix ../default.nix
]; ./lib/config.nix
];
virtualisation.memorySize = 1500; virtualisation.memorySize = 1500;
environment.systemPackages = with pkgs; [ netcat ]; environment.systemPackages = with pkgs; [ netcat ];
services.rsyslogd = { services.rsyslogd = {
enable = true; enable = true;
defaultConfig = '' defaultConfig = ''
*.* /dev/console *.* /dev/console
''; '';
};
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*";
};
}; };
client = { nodes, pkgs, ... }: let
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*";
};
};
client =
{ nodes, pkgs, ... }:
let
serverIP = nodes.server.networking.primaryIPAddress; serverIP = nodes.server.networking.primaryIPAddress;
clientIP = nodes.client.networking.primaryIPAddress; clientIP = nodes.client.networking.primaryIPAddress;
grep-ip = pkgs.writeScriptBin "grep-ip" '' grep-ip = pkgs.writeScriptBin "grep-ip" ''
@ -98,20 +104,25 @@
echo grep '${clientIP}' "$@" >&2 echo grep '${clientIP}' "$@" >&2
exec grep '${clientIP}' "$@" exec grep '${clientIP}' "$@"
''; '';
in { in
{
imports = [ imports = [
./lib/config.nix ./lib/config.nix
]; ];
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
fetchmail msmtp procmail findutils grep-ip fetchmail
msmtp
procmail
findutils
grep-ip
]; ];
environment.etc = { environment.etc = {
"root/.fetchmailrc" = { "root/.fetchmailrc" = {
text = '' text = ''
poll ${serverIP} with proto IMAP poll ${serverIP} with proto IMAP
user 'user1@example.com' there with password 'user1' is 'root' here user 'user1@example.com' there with password 'user1' is 'root' here
mda procmail mda procmail
''; '';
mode = "0700"; mode = "0700";
}; };
@ -185,59 +196,59 @@
''; '';
}; };
}; };
}; };
testScript = '' testScript = ''
start_all() start_all()
server.wait_for_unit("multi-user.target") server.wait_for_unit("multi-user.target")
client.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. # TODO put this blocking into the systemd units? I am not sure if rspamd already waits for the clamd socket.
server.wait_until_succeeds( server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
) )
server.wait_until_succeeds( server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/clamav/clamd.ctl < /dev/null; [ $? -eq 124 ]" "set +e; timeout 1 nc -U /run/clamav/clamd.ctl < /dev/null; [ $? -eq 124 ]"
) )
client.execute("cp -p /etc/root/.* ~/") client.execute("cp -p /etc/root/.* ~/")
client.succeed("mkdir -p ~/mail") client.succeed("mkdir -p ~/mail")
client.succeed("ls -la ~/ >&2") client.succeed("ls -la ~/ >&2")
client.succeed("cat ~/.fetchmailrc >&2") client.succeed("cat ~/.fetchmailrc >&2")
client.succeed("cat ~/.procmailrc >&2") client.succeed("cat ~/.procmailrc >&2")
client.succeed("cat ~/.msmtprc >&2") client.succeed("cat ~/.msmtprc >&2")
# fetchmail returns EXIT_CODE 1 when no new mail # fetchmail returns EXIT_CODE 1 when no new mail
client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2") client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2")
# Verify that mail can be sent and received before testing virus scanner # Verify that mail can be sent and received before testing virus scanner
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
client.succeed("msmtp -a user2 user1@example.com < /etc/root/safe-email >&2") client.succeed("msmtp -a user2 user1@example.com < /etc/root/safe-email >&2")
# give the mail server some time to process the mail # give the mail server some time to process the mail
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("fetchmail --nosslcertck -v >&2") client.succeed("fetchmail --nosslcertck -v >&2")
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
with subtest("virus scan file"): with subtest("virus scan file"):
server.succeed( server.succeed(
'set +o pipefail; clamdscan $(readlink -f /etc/root/eicar.com.txt) | grep "Txt\\.Malware\\.Agent-1787597 FOUND" >&2' 'set +o pipefail; clamdscan $(readlink -f /etc/root/eicar.com.txt) | grep "Txt\\.Malware\\.Agent-1787597 FOUND" >&2'
) )
with subtest("virus scan email"): with subtest("virus scan email"):
client.succeed( 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") server.succeed("journalctl -u rspamd | grep -i eicar")
# give the mail server some time to process the mail # give the mail server some time to process the mail
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
with subtest("no warnings or errors"): with subtest("no warnings or errors"):
server.fail("journalctl -u postfix | grep -i error >&2") server.fail("journalctl -u postfix | grep -i error >&2")
server.fail("journalctl -u postfix | grep -i warning >&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 error >&2")
server.fail("journalctl -u dovecot2 | grep -i warning >&2") server.fail("journalctl -u dovecot2 | grep -i warning >&2")
''; '';
} }

View file

@ -18,74 +18,84 @@
name = "external"; name = "external";
nodes = { nodes = {
server = { pkgs, ... }: server =
{ { pkgs, ... }:
imports = [ {
../default.nix imports = [
./lib/config.nix ../default.nix
]; ./lib/config.nix
];
environment.systemPackages = with pkgs; [ netcat ]; environment.systemPackages = with pkgs; [ netcat ];
virtualisation.memorySize = 1024; virtualisation.memorySize = 1024;
services.rsyslogd = { services.rsyslogd = {
enable = true; enable = true;
defaultConfig = '' defaultConfig = ''
*.* /dev/console *.* /dev/console
''; '';
};
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";
};
};
}; };
client = { nodes, pkgs, ... }: let
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";
};
};
};
client =
{ nodes, pkgs, ... }:
let
serverIP = nodes.server.networking.primaryIPAddress; serverIP = nodes.server.networking.primaryIPAddress;
clientIP = nodes.client.networking.primaryIPAddress; clientIP = nodes.client.networking.primaryIPAddress;
grep-ip = pkgs.writeScriptBin "grep-ip" '' grep-ip = pkgs.writeScriptBin "grep-ip" ''
@ -172,27 +182,36 @@
assert needle in repr(response) assert needle in repr(response)
imap.close() imap.close()
''; '';
in { in
{
imports = [ imports = [
./lib/config.nix ./lib/config.nix
]; ];
environment.systemPackages = with pkgs; [ 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 = { environment.etc = {
"root/.fetchmailrc" = { "root/.fetchmailrc" = {
text = '' text = ''
poll ${serverIP} with proto IMAP poll ${serverIP} with proto IMAP
user 'user1@example.com' there with password 'user1' is 'root' here user 'user1@example.com' there with password 'user1' is 'root' here
mda procmail mda procmail
''; '';
mode = "0700"; mode = "0700";
}; };
"root/.fetchmailRcLowQuota" = { "root/.fetchmailRcLowQuota" = {
text = '' text = ''
poll ${serverIP} with proto IMAP poll ${serverIP} with proto IMAP
user 'lowquota@example.com' there with password 'user2' is 'root' here user 'lowquota@example.com' there with password 'user2' is 'root' here
mda procmail mda procmail
''; '';
mode = "0700"; mode = "0700";
}; };
@ -338,176 +357,176 @@
''; '';
}; };
}; };
}; };
testScript = '' testScript = ''
start_all() start_all()
server.wait_for_unit("multi-user.target") server.wait_for_unit("multi-user.target")
client.wait_for_unit("multi-user.target") client.wait_for_unit("multi-user.target")
# TODO put this blocking into the systemd units? # TODO put this blocking into the systemd units?
server.wait_until_succeeds( server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
) )
client.execute("cp -p /etc/root/.* ~/") client.execute("cp -p /etc/root/.* ~/")
client.succeed("mkdir -p ~/mail") client.succeed("mkdir -p ~/mail")
client.succeed("ls -la ~/ >&2") client.succeed("ls -la ~/ >&2")
client.succeed("cat ~/.fetchmailrc >&2") client.succeed("cat ~/.fetchmailrc >&2")
client.succeed("cat ~/.procmailrc >&2") client.succeed("cat ~/.procmailrc >&2")
client.succeed("cat ~/.msmtprc >&2") client.succeed("cat ~/.msmtprc >&2")
with subtest("imap retrieving mail"): with subtest("imap retrieving mail"):
# fetchmail returns EXIT_CODE 1 when no new mail # fetchmail returns EXIT_CODE 1 when no new mail
client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2") client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2")
with subtest("submission port send mail"): with subtest("submission port send mail"):
# send email from user2 to user1 # send email from user2 to user1
client.succeed( 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 # give the mail server some time to process the mail
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
with subtest("imap retrieving mail 2"): with subtest("imap retrieving mail 2"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("fetchmail --nosslcertck -v >&2") client.succeed("fetchmail --nosslcertck -v >&2")
with subtest("remove sensitive information on submission port"): with subtest("remove sensitive information on submission port"):
client.succeed("cat ~/mail/* >&2") client.succeed("cat ~/mail/* >&2")
## make sure our IP is _not_ in the email header ## make sure our IP is _not_ in the email header
client.fail("grep-ip ~/mail/*") client.fail("grep-ip ~/mail/*")
client.succeed("check-mail-id ~/mail/*") client.succeed("check-mail-id ~/mail/*")
with subtest("have correct fqdn as sender"): with subtest("have correct fqdn as sender"):
client.succeed("grep 'Received: from mail.example.com' ~/mail/*") client.succeed("grep 'Received: from mail.example.com' ~/mail/*")
with subtest("dkim has user-specified size"): with subtest("dkim has user-specified size"):
server.succeed( server.succeed(
"openssl rsa -in /var/dkim/example.com.mail.key -text -noout | grep 'Private-Key: (1535 bit'" "openssl rsa -in /var/dkim/example.com.mail.key -text -noout | grep 'Private-Key: (1535 bit'"
) )
with subtest("dkim singing, multiple domains"): with subtest("dkim singing, multiple domains"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from user2 to user1 # send email from user2 to user1
client.succeed( 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" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("fetchmail --nosslcertck -v") client.succeed("fetchmail --nosslcertck -v")
client.succeed("cat ~/mail/* >&2") client.succeed("cat ~/mail/* >&2")
# make sure it is dkim signed # make sure it is dkim signed
client.succeed("grep DKIM-Signature: ~/mail/*") client.succeed("grep DKIM-Signature: ~/mail/*")
with subtest("aliases"): with subtest("aliases"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from chuck to postmaster # send email from chuck to postmaster
client.succeed( 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" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("fetchmail --nosslcertck -v") client.succeed("fetchmail --nosslcertck -v")
with subtest("catchAlls"): with subtest("catchAlls"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from chuck to non exsitent account # send email from chuck to non exsitent account
client.succeed( 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" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("fetchmail --nosslcertck -v") client.succeed("fetchmail --nosslcertck -v")
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from user1 to chuck # send email from user1 to chuck
client.succeed( 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" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 1 when no new mail # 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 recieved the mail that was intended for chuck.
client.fail("fetchmail --nosslcertck -v") client.fail("fetchmail --nosslcertck -v")
with subtest("extraVirtualAliases"): with subtest("extraVirtualAliases"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from single-alias to user1 # send email from single-alias to user1
client.succeed( 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" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("fetchmail --nosslcertck -v") client.succeed("fetchmail --nosslcertck -v")
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from user1 to multi-alias (user{1,2}@example.com) # send email from user1 to multi-alias (user{1,2}@example.com)
client.succeed( 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" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("fetchmail --nosslcertck -v") client.succeed("fetchmail --nosslcertck -v")
with subtest("quota"): with subtest("quota"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc") client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc")
client.succeed( 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" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
client.fail("fetchmail --nosslcertck -v") client.fail("fetchmail --nosslcertck -v")
with subtest("imap sieve junk trainer"): with subtest("imap sieve junk trainer"):
# send email from user2 to user1 # send email from user2 to user1
client.succeed( 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 # give the mail server some time to process the mail
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
client.succeed("imap-mark-spam >&2") client.succeed("imap-mark-spam >&2")
server.wait_until_succeeds("journalctl -u dovecot2 | grep -i rspamd-learn-spam.sh >&2") server.wait_until_succeeds("journalctl -u dovecot2 | grep -i rspamd-learn-spam.sh >&2")
client.succeed("imap-mark-ham >&2") client.succeed("imap-mark-ham >&2")
server.wait_until_succeeds("journalctl -u dovecot2 | grep -i rspamd-learn-ham.sh >&2") server.wait_until_succeeds("journalctl -u dovecot2 | grep -i rspamd-learn-ham.sh >&2")
with subtest("full text search and indexation"): with subtest("full text search and indexation"):
# send 2 email from user2 to user1 # send 2 email from user2 to user1
client.succeed( 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( 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 # give the mail server some time to process the mail
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# should find exactly one email containing this # should find exactly one email containing this
client.succeed("search INBOX 576a4565b70f5a4c1a0925cabdb587a6 >&2") client.succeed("search INBOX 576a4565b70f5a4c1a0925cabdb587a6 >&2")
# should fail because this folder is not indexed # should fail because this folder is not indexed
client.fail("search Junk a >&2") client.fail("search Junk a >&2")
# check that search really goes through the indexer # check that search really goes through the indexer
server.succeed("journalctl -u dovecot2 | grep 'fts-flatcurve(INBOX): Query ' >&2") server.succeed("journalctl -u dovecot2 | grep 'fts-flatcurve(INBOX): Query ' >&2")
# check that Junk is not indexed # check that Junk is not indexed
server.fail("journalctl -u dovecot2 | grep 'fts-flatcurve(JUNK): Indexing ' >&2") server.fail("journalctl -u dovecot2 | grep 'fts-flatcurve(JUNK): Indexing ' >&2")
with subtest("dmarc reporting"): with subtest("dmarc reporting"):
server.systemctl("start rspamd-dmarc-reporter.service") server.systemctl("start rspamd-dmarc-reporter.service")
with subtest("no warnings or errors"): with subtest("no warnings or errors"):
server.fail("journalctl -u postfix | grep -i error >&2") server.fail("journalctl -u postfix | grep -i error >&2")
server.fail("journalctl -u postfix | grep -i warning >&2") server.fail("journalctl -u postfix | grep -i warning >&2")
server.fail("journalctl -u dovecot2 | grep -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2") server.fail("journalctl -u dovecot2 | grep -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2")
# harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html # harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html
server.fail( server.fail(
"journalctl -u dovecot2 | \ "journalctl -u dovecot2 | \
grep -v 'Expunged message reappeared, giving a new UID' | \ grep -v 'Expunged message reappeared, giving a new UID' | \
grep -v 'Time moved forwards' | \ grep -v 'Time moved forwards' | \
grep -i warning >&2" grep -i warning >&2"
) )
''; '';
} }

View file

@ -30,11 +30,16 @@ let
''; '';
}; };
hashPassword = password: pkgs.runCommand hashPassword =
"password-${password}-hashed" password:
{ buildInputs = [ pkgs.mkpasswd ]; inherit password; } '' pkgs.runCommand "password-${password}-hashed"
mkpasswd -sm bcrypt <<<"$password" > $out {
''; buildInputs = [ pkgs.mkpasswd ];
inherit password;
}
''
mkpasswd -sm bcrypt <<<"$password" > $out
'';
hashedPasswordFile = hashPassword "my-password"; hashedPasswordFile = hashPassword "my-password";
passwordFile = pkgs.writeText "password" "my-password"; passwordFile = pkgs.writeText "password" "my-password";
@ -43,55 +48,62 @@ in
name = "internal"; name = "internal";
nodes = { nodes = {
machine = { pkgs, ... }: { machine =
imports = [ { pkgs, ... }:
./../default.nix {
./lib/config.nix imports = [
]; ./../default.nix
./lib/config.nix
];
virtualisation.memorySize = 1024; virtualisation.memorySize = 1024;
environment.systemPackages = [ environment.systemPackages =
(pkgs.writeScriptBin "mail-check" '' [
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ (pkgs.writeScriptBin "mail-check" ''
'') ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
] ++ (with pkgs; [ '')
curl ]
openssl ++ (with pkgs; [
netcat curl
]); openssl
netcat
]);
mailserver = { mailserver = {
enable = true; enable = true;
fqdn = "mail.example.com"; fqdn = "mail.example.com";
domains = [ "example.com" "domain.com" ]; domains = [
localDnsResolver = false; "example.com"
"domain.com"
];
localDnsResolver = false;
loginAccounts = { loginAccounts = {
"user1@example.com" = { "user1@example.com" = {
hashedPasswordFile = hashedPasswordFile; hashedPasswordFile = hashedPasswordFile;
};
"user2@example.com" = {
hashedPasswordFile = hashedPasswordFile;
aliasesRegexp = [ ''/^user2.*@domain\.com$/'' ];
};
"send-only@example.com" = {
hashedPasswordFile = hashPassword "send-only";
sendOnly = true;
};
}; };
"user2@example.com" = { forwards = {
hashedPasswordFile = hashedPasswordFile; # user2@example.com is a local account and its mails are
aliasesRegexp = [''/^user2.*@domain\.com$/'']; # also forwarded to user1@example.com
}; "user2@example.com" = "user1@example.com";
"send-only@example.com" = {
hashedPasswordFile = hashPassword "send-only";
sendOnly = true;
}; };
vmailGroupName = "vmail";
vmailUID = 5000;
enableImap = false;
}; };
forwards = {
# user2@example.com is a local account and its mails are
# also forwarded to user1@example.com
"user2@example.com" = "user1@example.com";
};
vmailGroupName = "vmail";
vmailUID = 5000;
enableImap = false;
}; };
};
}; };
testScript = '' testScript = ''
machine.start() machine.start()

View file

@ -7,110 +7,113 @@ in
name = "ldap"; name = "ldap";
nodes = { nodes = {
machine = { pkgs, ... }: { machine =
imports = [ { pkgs, ... }:
./../default.nix {
./lib/config.nix imports = [
]; ./../default.nix
./lib/config.nix
];
virtualisation.memorySize = 1024; virtualisation.memorySize = 1024;
services.openssh = { services.openssh = {
enable = true; enable = true;
settings.PermitRootLogin = "yes"; settings.PermitRootLogin = "yes";
}; };
environment.systemPackages = [ environment.systemPackages = [
(pkgs.writeScriptBin "mail-check" '' (pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
'')]; '')
];
environment.etc.bind-password.text = bindPassword; environment.etc.bind-password.text = bindPassword;
services.openldap = { services.openldap = {
enable = true; enable = true;
settings = { settings = {
children = { children = {
"cn=schema".includes = [ "cn=schema".includes = [
"${pkgs.openldap}/etc/schema/core.ldif" "${pkgs.openldap}/etc/schema/core.ldif"
"${pkgs.openldap}/etc/schema/cosine.ldif" "${pkgs.openldap}/etc/schema/cosine.ldif"
"${pkgs.openldap}/etc/schema/inetorgperson.ldif" "${pkgs.openldap}/etc/schema/inetorgperson.ldif"
"${pkgs.openldap}/etc/schema/nis.ldif" "${pkgs.openldap}/etc/schema/nis.ldif"
]; ];
"olcDatabase={1}mdb" = { "olcDatabase={1}mdb" = {
attrs = { attrs = {
objectClass = [ objectClass = [
"olcDatabaseConfig" "olcDatabaseConfig"
"olcMdbConfig" "olcMdbConfig"
]; ];
olcDatabase = "{1}mdb"; olcDatabase = "{1}mdb";
olcDbDirectory = "/var/lib/openldap/example"; olcDbDirectory = "/var/lib/openldap/example";
olcSuffix = "dc=example"; olcSuffix = "dc=example";
};
}; };
}; };
}; };
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}
'';
}; };
declarativeContents."dc=example" = ''
dn: dc=example
objectClass: domain
dc: example
dn: cn=mail,dc=example mailserver = {
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; enable = true;
uris = [ fqdn = "mail.example.com";
"ldap://" domains = [ "example.com" ];
]; localDnsResolver = false;
bind = {
dn = "cn=mail,dc=example"; ldap = {
passwordFile = "/etc/bind-password"; enable = true;
uris = [
"ldap://"
];
bind = {
dn = "cn=mail,dc=example";
passwordFile = "/etc/bind-password";
};
searchBase = "ou=users,dc=example";
searchScope = "sub";
}; };
searchBase = "ou=users,dc=example";
searchScope = "sub"; forwards = {
"bob_fw@example.com" = "bob@example.com";
};
vmailGroupName = "vmail";
vmailUID = 5000;
enableImap = false;
}; };
forwards = {
"bob_fw@example.com" = "bob@example.com";
};
vmailGroupName = "vmail";
vmailUID = 5000;
enableImap = false;
}; };
};
}; };
testScript = '' testScript = ''
import sys import sys

View file

@ -6,16 +6,23 @@
}: }:
let let
hashPassword = password: pkgs.runCommand hashPassword =
"password-${password}-hashed" password:
{ buildInputs = [ pkgs.mkpasswd ]; inherit password; } pkgs.runCommand "password-${password}-hashed"
{
buildInputs = [ pkgs.mkpasswd ];
inherit password;
}
'' ''
mkpasswd -sm bcrypt <<<"$password" > $out mkpasswd -sm bcrypt <<<"$password" > $out
''; '';
password = pkgs.writeText "password" "password"; password = pkgs.writeText "password" "password";
domainGenerator = domain: { pkgs, ... }: { domainGenerator =
domain:
{ pkgs, ... }:
{
imports = [ imports = [
../default.nix ../default.nix
./lib/config.nix ./lib/config.nix
@ -37,7 +44,10 @@ let
}; };
services.dnsmasq = { services.dnsmasq = {
enable = true; enable = true;
settings.mx-host = [ "domain1.com,domain1,10" "domain2.com,domain2,10" ]; settings.mx-host = [
"domain1.com,domain1,10"
"domain2.com,domain2,10"
];
}; };
}; };
@ -47,23 +57,34 @@ in
name = "multiple"; name = "multiple";
nodes = { nodes = {
domain1 = {...}: { domain1 =
imports = [ { ... }:
../default.nix {
(domainGenerator "domain1.com") imports = [
]; ../default.nix
mailserver.forwards = { (domainGenerator "domain1.com")
"non-local@domain1.com" = ["user@domain2.com" "user@domain1.com"]; ];
"non@domain1.com" = ["user@domain2.com" "user@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"; domain2 = domainGenerator "domain2.com";
client = { pkgs, ... }: { client =
environment.systemPackages = [ { pkgs, ... }:
(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} $@
'')
];
};
}; };
testScript = '' testScript = ''
start_all() start_all()