diff --git a/default.nix b/default.nix index 579624a..5ef87a3 100644 --- a/default.nix +++ b/default.nix @@ -66,6 +66,8 @@ in default = []; description = '' A list of aliases of this login account. + Note: Use list entries like "@example.com" to create a catchAll + that allows sending from all email addresses in these domain. ''; }; @@ -75,6 +77,7 @@ in default = []; description = '' For which domains should this account act as a catch all? + Note: Does not allow sending from all addresses of these domains. ''; }; @@ -135,19 +138,30 @@ in }; extraVirtualAliases = mkOption { - type = types.attrsOf (types.enum (builtins.attrNames cfg.loginAccounts)); + type = types.loaOf (mkOptionType { + name = "Login Account"; + check = (ele: + let accounts = builtins.attrNames cfg.loginAccounts; + in if (builtins.isList ele) + then (builtins.all (x: builtins.elem x accounts) ele) && (builtins.length ele > 0) + else (builtins.elem ele accounts)); + }); example = { "info@example.com" = "user1@example.com"; "postmaster@example.com" = "user1@example.com"; "abuse@example.com" = "user1@example.com"; + "multi@example.com" = [ "user1@example.com" "user2@example.com" ]; }; description = '' - Virtual Aliases. A virtual alias `"info@example2.com" = "user1@example.com"` means that - all mail to `info@example2.com` is forwarded to `user1@example.com`. Note + Virtual Aliases. A virtual alias `"info@example.com" = "user1@example.com"` means that + all mail to `info@example.com` is forwarded to `user1@example.com`. Note that it is expected that `postmaster@example.com` and `abuse@example.com` is forwarded to some valid email address. (Alternatively you can create login accounts for `postmaster` and (or) `abuse`). Furthermore, it also allows - the user `user1@example.com` to send emails as `info@example2.com`. + the user `user1@example.com` to send emails as `info@example.com`. + It's also possible to create an alias for multiple accounts. In this + example all mails for `multi@example.com` will be forwarded to both + `user1@example.com` and `user2@example.com`. ''; default = {}; }; @@ -395,12 +409,22 @@ in type = types.bool; default = false; description = '' - Whether to enable verbose logging for mailserver related services. This + Whether to enable verbose logging for mailserver related services. This intended be used for development purposes only, you probably don't want to enable this unless you're hacking on nixos-mailserver. ''; }; + maxConnectionsPerUser = mkOption { + type = types.int; + default = 100; + description = '' + Maximum number of IMAP/POP3 connections allowed for a user from each IP address. + E.g. a value of 50 allows for 50 IMAP and 50 POP3 connections at the same + time for a single user. + ''; + }; + localDnsResolver = mkOption { type = types.bool; default = true; @@ -464,10 +488,139 @@ in description = '' The configuration used for monitoring via monit. Use a mail address that you actively check and set it via 'set alert ...'. - ''; - }; + ''; + }; + }; + + borgbackup = { + enable = mkEnableOption "backup via borgbackup"; + + repoLocation = mkOption { + type = types.string; + default = "/var/borgbackup"; + description = '' + The location where borg saves the backups. + This can be a local path or a remote location such as user@host:/path/to/repo. + It is exported and thus available as an environment variable to cmdPreexec and cmdPostexec. + ''; + }; + + startAt = mkOption { + type = types.string; + default = "hourly"; + description = "When or how often the backup should run. Must be in the format described in systemd.time 7."; + }; + + user = mkOption { + type = types.string; + default = "virtualMail"; + description = "The user borg and its launch script is run as."; + }; + + group = mkOption { + type = types.string; + default = "virtualMail"; + description = "The group borg and its launch script is run as."; + }; + + compression = { + method = mkOption { + type = types.nullOr (types.enum ["none" "lz4" "zstd" "zlib" "lzma"]); + default = null; + description = "Leaving this unset allows borg to choose. The default for borg 1.1.4 is lz4."; }; + level = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + Denotes the level of compression used by borg. + Most methods accept levels from 0 to 9 but zstd which accepts values from 1 to 22. + If null the decision is left up to borg. + ''; + }; + + auto = mkOption { + type = types.bool; + default = false; + description = "Leaves it to borg to determine whether an individual file should be compressed."; + }; + }; + + encryption = { + method = mkOption { + type = types.enum [ + "none" + "authenticated" + "authenticated-blake2" + "repokey" + "keyfile" + "repokey-blake2" + "keyfile-blake2" + ]; + default = "none"; + description = '' + The backup can be encrypted by choosing any other value than 'none'. + When using encryption the password / passphrase must be provided in passphraseFile. + ''; + }; + + passphraseFile = mkOption { + type = types.nullOr types.path; + default = null; + }; + }; + + name = mkOption { + type = types.string; + default = "{hostname}-{user}-{now}"; + description = '' + The name of the individual backups as used by borg. + Certain placeholders will be replaced by borg. + ''; + }; + + locations = mkOption { + type = types.listOf types.path; + default = [cfg.mailDirectory]; + description = "The locations that are to be backed up by borg."; + }; + + extraArgumentsForInit = mkOption { + type = types.listOf types.string; + default = ["--critical"]; + description = "Additional arguments to add to the borg init command line."; + }; + + extraArgumentsForCreate = mkOption { + type = types.listOf types.string; + default = [ ]; + description = "Additional arguments to add to the borg create command line e.g. '--stats'."; + }; + + cmdPreexec = mkOption { + type = types.nullOr types.string; + default = null; + description = '' + The command to be executed before each backup operation. + This is called prior to borg init in the same script that runs borg init and create and cmdPostexec. + Example: + export BORG_RSH="ssh -i /path/to/private/key" + ''; + }; + + cmdPostexec = mkOption { + type = types.nullOr types.string; + default = null; + description = '' + The command to be executed after each backup operation. + This is called after borg create completed successfully and in the same script that runs + cmdPreexec, borg init and create. + ''; + }; + + }; + backup = { enable = mkEnableOption "backup via rsnapshot"; @@ -529,6 +682,7 @@ in }; imports = [ + ./mail-server/borgbackup.nix ./mail-server/rsnapshot.nix ./mail-server/clamav.nix ./mail-server/monit.nix diff --git a/mail-server/borgbackup.nix b/mail-server/borgbackup.nix new file mode 100644 index 0000000..3c60031 --- /dev/null +++ b/mail-server/borgbackup.nix @@ -0,0 +1,78 @@ +# nixos-mailserver: a simple mail server +# Copyright (C) 2016-2018 Robin Raymond +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see + +{ config, pkgs, lib, ... }: + +let + cfg = config.mailserver.borgbackup; + + methodFragment = lib.optional (cfg.compression.method != null) cfg.compression.method; + autoFragment = + if cfg.compression.auto && cfg.compression.method == null + then throw "compression.method must be set when using auto." + else lib.optional cfg.compression.auto "auto"; + levelFragment = + if cfg.compression.level != null && cfg.compression.method == null + then throw "compression.method must be set when using compression.level." + else lib.optional (cfg.compression.level != null) (toString cfg.compression.level); + compressionFragment = lib.concatStringsSep "," (lib.flatten [autoFragment methodFragment levelFragment]); + compression = lib.optionalString (compressionFragment != "") "--compression ${compressionFragment}"; + + encryptionFragment = cfg.encryption.method; + passphraseFile = lib.escapeShellArg cfg.encryption.passphraseFile; + passphraseFragment = lib.optionalString (cfg.encryption.method != "none") + (if cfg.encryption.passphraseFile != null then ''env BORG_PASSPHRASE="$(cat ${passphraseFile})"'' + else throw "passphraseFile must be set when using encryption."); + + locations = lib.escapeShellArgs cfg.locations; + name = lib.escapeShellArg cfg.name; + + repoLocation = lib.escapeShellArg cfg.repoLocation; + + extraInitArgs = lib.escapeShellArgs cfg.extraArgumentsForInit; + extraCreateArgs = lib.escapeShellArgs cfg.extraArgumentsForCreate; + + cmdPreexec = lib.optionalString (cfg.cmdPreexec != null) cfg.cmdPreexec; + cmdPostexec = lib.optionalString (cfg.cmdPostexec != null) cfg.cmdPostexec; + + borgScript = '' + export BORG_REPO=${repoLocation} + ${cmdPreexec} + ${passphraseFragment} ${pkgs.borgbackup}/bin/borg init ${extraInitArgs} --encryption ${encryptionFragment} || true + ${passphraseFragment} ${pkgs.borgbackup}/bin/borg create ${extraCreateArgs} ${compression} ::${name} ${locations} + ${cmdPostexec} + ''; +in { + config = lib.mkIf config.mailserver.borgbackup.enable { + environment.systemPackages = with pkgs; [ + borgbackup + ]; + + systemd.services.borgbackup = { + description = "borgbackup"; + unitConfig.Documentation = "man:borgbackup"; + script = borgScript; + serviceConfig = { + User = cfg.user; + Group = cfg.group; + CPUSchedulingPolicy = "idle"; + IOSchedulingClass = "idle"; + ProtectSystem = "full"; + }; + startAt = cfg.startAt; + }; + }; +} diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index 26a8002..4294a2d 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -67,6 +67,14 @@ in verbose_ssl = yes ''} + protocol imap { + mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser} + } + + protocol pop3 { + mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser} + } + mail_access_groups = ${vmailGroupName} ssl = required ${lib.optionalString (dovecotVersion.major == 2 && dovecotVersion.minor >= 3) '' diff --git a/mail-server/postfix.nix b/mail-server/postfix.nix index 394d1dd..8fd67b5 100644 --- a/mail-server/postfix.nix +++ b/mail-server/postfix.nix @@ -41,18 +41,15 @@ let (map (from: let to = cfg.extraVirtualAliases.${from}; - in "${from} ${to}") + aliasList = (l: let aliasStr = builtins.foldl' (x: y: x + y + ", ") "" l; + in builtins.substring 0 (builtins.stringLength aliasStr - 2) aliasStr); + in if (builtins.isList to) then "${from} " + (aliasList to) + else "${from} ${to}") (builtins.attrNames cfg.extraVirtualAliases)); # all_valiases_postfix :: [ String ] all_valiases_postfix = valiases_postfix ++ extra_valiases_postfix; - # accountToIdentity :: User -> String - accountToIdentity = account: "${account.name} ${account.name}"; - - # vaccounts_identity :: [ String ] - vaccounts_identity = map accountToIdentity (lib.attrValues cfg.loginAccounts); - # valiases_file :: Path valiases_file = builtins.toFile "valias" (lib.concatStringsSep "\n" (all_valiases_postfix ++ @@ -65,10 +62,9 @@ let # see # https://blog.grimneko.de/2011/12/24/a-bunch-of-tips-for-improving-your-postfix-setup/ # for details on how this file looks. By using the same file as valiases, - # every alias is owned (uniquely) by its user. We have to add the users own - # address though - vaccounts_file = builtins.toFile "vaccounts" (lib.concatStringsSep "\n" - (vaccounts_identity ++ all_valiases_postfix)); + # every alias is owned (uniquely) by its user. + # The user's own address is already in all_valiases_postfix. + vaccounts_file = builtins.toFile "vaccounts" (lib.concatStringsSep "\n" all_valiases_postfix); submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" '' # Removes sensitive headers from mails handed in via the submission port. @@ -98,7 +94,7 @@ in extraConfig = '' # Extra Config - mydestination = localhost + mydestination = smtpd_banner = ${fqdn} ESMTP NO UCE disable_vrfy_command = yes @@ -109,6 +105,7 @@ in virtual_gid_maps = static:5000 virtual_mailbox_base = ${mailDirectory} virtual_mailbox_domains = ${vhosts_file} + virtual_mailbox_maps = hash:/var/lib/postfix/conf/valias virtual_alias_maps = hash:/var/lib/postfix/conf/valias virtual_transport = lmtp:unix:private/dovecot-lmtp diff --git a/tests/extern.nix b/tests/extern.nix index f29acc2..9aea6a5 100644 --- a/tests/extern.nix +++ b/tests/extern.nix @@ -49,6 +49,11 @@ import { }; }; + extraVirtualAliases = { + "single-alias@example.com" = "user1@example.com"; + "multi-alias@example.com" = [ "user1@example.com" "user2@example.com" ]; + }; + enableImap = true; }; }; @@ -113,6 +118,13 @@ import { from postmaster@example.com user user1@example.com password user1 + + account test5 + host ${serverIP} + port 587 + from single-alias@example.com + user user1@example.com + password user1 ''; }; "root/email1".text = '' @@ -154,6 +166,34 @@ import { I think I may have misconfigured the mail server XOXO Postmaster ''; + "root/email4".text = '' + From: Single Alias + To: User1 + Cc: + Bcc: + Subject: This is a test Email from single-alias\@example.com to user1 + Reply-To: + + Hello User1, + + how are you doing today? + + XOXO User1 aka Single Alias + ''; + "root/email5".text = '' + From: User2 + To: Multi Alias + Cc: + Bcc: + Subject: This is a test Email from user2\@example.com to multi-alias + Reply-To: + + Hello Multi Alias, + + how are we doing today? + + XOXO User1 + ''; }; }; }; @@ -238,6 +278,22 @@ import { $client->fail("fetchmail -v"); }; + subtest "extraVirtualAliases", sub { + $client->execute("rm ~/mail/*"); + # send email from single-alias to user1 + $client->succeed("msmtp -a test5 --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email4 >&2"); + $server->waitUntilFails('[ "$(postqueue -p)" != "Mail queue is empty" ]'); + # fetchmail returns EXIT_CODE 0 when it retrieves mail + $client->succeed("fetchmail -v"); + + $client->execute("rm ~/mail/*"); + # send email from user1 to multi-alias (user{1,2}@example.com) + $client->succeed("msmtp -a test --tls=on --tls-certcheck=off --auth=on multi-alias\@example.com < /etc/root/email5 >&2"); + $server->waitUntilFails('[ "$(postqueue -p)" != "Mail queue is empty" ]'); + # fetchmail returns EXIT_CODE 0 when it retrieves mail + $client->succeed("fetchmail -v"); + }; + subtest "quota", sub { $client->execute("rm ~/mail/*"); $client->execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc"); @@ -249,5 +305,12 @@ import { }; + subtest "no warnings or errors", sub { + $server->fail("journalctl -u postfix | grep -i error"); + $server->fail("journalctl -u postfix | grep -i warning"); + $server->fail("journalctl -u dovecot2 | grep -i error"); + $server->fail("journalctl -u dovecot2 | grep -i warning"); + }; + ''; }