diff --git a/.envrc b/.envrc deleted file mode 100644 index 069abc3..0000000 --- a/.envrc +++ /dev/null @@ -1,3 +0,0 @@ -# shellcheck shell=bash - -use flake diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml deleted file mode 100644 index 7a76f6c..0000000 --- a/.forgejo/workflows/build.yaml +++ /dev/null @@ -1,17 +0,0 @@ -name: Build - -on: - push: - branches: - - 'master' - -jobs: - # deploy it upstream - deploy: - runs-on: docker - steps: - - name: "Deploy to Skynet" - uses: https://forgejo.skynet.ie/Skynet/actions-deploy-to-skynet@v2 - with: - input: 'simple-nixos-mailserver' - token: ${{ secrets.API_TOKEN_FORGEJO }} diff --git a/.gitignore b/.gitignore index 0d3fe25..b2be92b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1 @@ result -.direnv -.pre-commit-config.yaml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8901bb5..b72b9f9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,18 +1,13 @@ -.hydra-cli: - image: docker.nix-community.org/nixpkgs/nix-flakes - script: - - nix run --inputs-from ./. nixpkgs#hydra-cli -- -H https://hydra.nix-community.org jobset-wait simple-nixos-mailserver "${jobset}" - hydra-pr: - extends: .hydra-cli only: - merge_requests - variables: - jobset: $CI_MERGE_REQUEST_IID + image: nixos/nix + script: + - nix-shell -I nixpkgs=channel:nixos-22.05 -p hydra-cli --run 'hydra-cli -H https://hydra.nix-community.org jobset-wait simple-nixos-mailserver ${CI_MERGE_REQUEST_IID}' hydra-master: - extends: .hydra-cli only: - master - variables: - jobset: master + image: nixos/nix + script: + - nix-shell -I nixpkgs=channel:nixos-22.05 -p hydra-cli --run 'hydra-cli -H https://hydra.nix-community.org jobset-wait simple-nixos-mailserver master' diff --git a/.hydra/declarative-jobsets.nix b/.hydra/declarative-jobsets.nix index 6877235..eeb82d2 100644 --- a/.hydra/declarative-jobsets.nix +++ b/.hydra/declarative-jobsets.nix @@ -1,24 +1,25 @@ { nixpkgs, pulls, ... }: let - pkgs = import nixpkgs { }; + pkgs = import nixpkgs {}; prs = builtins.fromJSON (builtins.readFile pulls); - prJobsets = pkgs.lib.mapAttrs (num: info: { - enabled = 1; - hidden = false; - description = "PR ${num}: ${info.title}"; - checkinterval = 300; - schedulingshares = 20; - enableemail = false; - emailoverride = ""; - keepnr = 1; - type = 1; - flake = "gitlab:simple-nixos-mailserver/nixos-mailserver/merge-requests/${info.iid}/head"; - }) prs; + prJobsets = pkgs.lib.mapAttrs (num: info: + { enabled = 1; + hidden = false; + description = "PR ${num}: ${info.title}"; + checkinterval = 30; + schedulingshares = 20; + enableemail = false; + emailoverride = ""; + keepnr = 1; + type = 1; + flake = "gitlab:simple-nixos-mailserver/nixos-mailserver/merge-requests/${info.iid}/head"; + } + ) prs; mkFlakeJobset = branch: { description = "Build ${branch} branch of Simple NixOS MailServer"; - checkinterval = 300; + checkinterval = "60"; enabled = "1"; schedulingshares = 100; enableemail = false; @@ -31,8 +32,8 @@ let desc = prJobsets // { "master" = mkFlakeJobset "master"; - "nixos-24.11" = mkFlakeJobset "nixos-24.11"; - "nixos-25.05" = mkFlakeJobset "nixos-25.05"; + "nixos-22.05" = mkFlakeJobset "nixos-22.05"; + "nixos-22.11" = mkFlakeJobset "nixos-22.11"; }; log = { @@ -40,9 +41,8 @@ let jobsets = desc; }; -in -{ - jobsets = pkgs.runCommand "spec-jobsets.json" { } '' +in { + jobsets = pkgs.runCommand "spec-jobsets.json" {} '' cat >$out < /hashed/password/file/location + hashedPasswordFile = "/hashed/password/file/location"; + + aliases = [ + "info@example.com" + "postmaster@example.com" + "postmaster@example2.com" + ]; + }; + }; + }; + } +``` + +For a complete list of options, see `default.nix`. + + ## How to Set Up a 10/10 Mail Server Guide +Check out the [Complete Setup Guide](https://nixos-mailserver.readthedocs.io/en/latest/setup-guide.html) in the project's documentation. -Check out the [Setup Guide](https://nixos-mailserver.readthedocs.io/en/latest/setup-guide.html) in the project's documentation. +## How to Backup -For a complete list of options, [see in readthedocs](https://nixos-mailserver.readthedocs.io/en/latest/options.html). +Checkout the [Complete Backup Guide](https://nixos-mailserver.readthedocs.io/en/latest/backup-guide.html). Backups are easy with `SNM`. ## Development -See the [How to Develop SNM](https://nixos-mailserver.readthedocs.io/en/latest/howto-develop.html) documentation page. +See the [How to Develop SNM](https://nixos-mailserver.readthedocs.io/en/latest/howto-develop.html) wiki page. + +## Release notes + +### nixos-20.03 + +- Rspamd is upgraded to 2.0 which deprecates the SQLite Bayes + backend. We then moved to the Redis backend (the default since + Rspamd 2.0). If you don't want to relearn the Redis backend from the + scratch, we could manually run + + rspamadm statconvert --spam-db /var/lib/rspamd/bayes.spam.sqlite --ham-db /var/lib/rspamd/bayes.ham.sqlite -h 127.0.0.1:6379 --symbol-ham BAYES_HAM --symbol-spam BAYES_SPAM + + See the [Rspamd migration + notes](https://rspamd.com/doc/migration.html#migration-to-rspamd-20) + and [this SNM Merge + Request](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/164) + for details. ## Contributors - See the [contributor tab](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/graphs/master) ### Alternative Implementations - -* [NixCloud Webservices](https://github.com/nixcloud/nixcloud-webservices) + * [NixCloud Webservices](https://github.com/nixcloud/nixcloud-webservices) ### Credits - -* send mail graphic by [tnp_dreamingmao](https://thenounproject.com/dreamingmao) + * send mail graphic by [tnp_dreamingmao](https://thenounproject.com/dreamingmao) from [TheNounProject](https://thenounproject.com/) is licensed under [CC BY 3.0](http://creativecommons.org/~/3.0/) -* Logo made with [Logomakr.com](https://logomakr.com) + * Logo made with [Logomakr.com](https://logomakr.com) + + + [logo]: docs/logo.png diff --git a/default.nix b/default.nix index 3f31420..66b863e 100644 --- a/default.nix +++ b/default.nix @@ -14,49 +14,17 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - lib, - pkgs, - ... -}: +{ config, lib, pkgs, ... }: + +with lib; let - inherit (lib) - literalExpression - literalMD - mkDefault - mkEnableOption - mkOption - mkOptionType - mkRemovedOptionModule - mkRenamedOptionModule - types - warn - ; - cfg = config.mailserver; in { options.mailserver = { enable = mkEnableOption "nixos-mailserver"; - stateVersion = mkOption { - type = types.nullOr types.ints.positive; - default = null; - description = '' - Tracking stateful version changes as an incrementing number. - - When a new release comes out we may require manual migration steps to - be completed, before the new version can be put into production. - - If your `stateVersion` is too low one or multiple assertions may - trigger to give you instructions on what migrations steps are required - to continue. Increase the `stateVersion` as instructed by the assertion - message. - ''; - }; - openFirewall = mkOption { type = types.bool; default = true; @@ -72,17 +40,14 @@ in domains = mkOption { type = types.listOf types.str; example = [ "example.com" ]; - default = [ ]; + default = []; description = "The domains that this mail server serves."; }; certificateDomains = mkOption { type = types.listOf types.str; - example = [ - "imap.example.com" - "pop3.example.com" - ]; - default = [ ]; + example = [ "imap.example.com" "pop3.example.com" ]; + default = []; description = '' ({option}`mailserver.certificateScheme` == `acme-nginx`) @@ -98,141 +63,121 @@ in }; loginAccounts = mkOption { - type = types.attrsOf ( - types.submodule ( - { name, ... }: - { - options = { - name = mkOption { - type = types.str; - example = "user1@example.com"; - description = "Username"; - }; + type = types.attrsOf (types.submodule ({ name, ... }: { + options = { + name = mkOption { + type = types.str; + example = "user1@example.com"; + description = "Username"; + }; - hashedPassword = mkOption { - type = with types; nullOr str; - default = null; - example = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/"; - description = '' - The user's hashed password. Use `mkpasswd` as follows + hashedPassword = mkOption { + type = with types; nullOr str; + default = null; + example = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/"; + description = '' + 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! - Use {option}`mailserver.loginAccounts..hashedPasswordFile` instead. - ''; - }; + Warning: this is stored in plaintext in the Nix store! + Use {option}`mailserver.loginAccounts..hashedPasswordFile` instead. + ''; + }; - hashedPasswordFile = mkOption { - type = with types; nullOr path; - default = null; - example = "/run/keys/user1-passwordhash"; - description = '' - A file containing the user's hashed password. Use `mkpasswd` as follows + hashedPasswordFile = mkOption { + type = with types; nullOr path; + default = null; + example = "/run/keys/user1-passwordhash"; + description = '' + 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 { - type = with types; listOf types.str; - example = [ - "abuse@example.com" - "postmaster@example.com" - ]; - default = [ ]; - description = '' - A list of aliases of this login account. - Note: Use list entries like "@example.com" to create a catchAll - that allows sending from all email addresses in these domain. - ''; - }; + aliases = mkOption { + type = with types; listOf types.str; + example = ["abuse@example.com" "postmaster@example.com"]; + default = []; + description = '' + A list of aliases of this login account. + Note: Use list entries like "@example.com" to create a catchAll + that allows sending from all email addresses in these domain. + ''; + }; - aliasesRegexp = mkOption { - type = with types; listOf types.str; - example = [ ''/^tom\..*@domain\.com$/'' ]; - default = [ ]; - description = '' - Same as {option}`mailserver.aliases` but using PCRE (Perl compatible regex). - ''; - }; + catchAll = mkOption { + type = with types; listOf (enum cfg.domains); + example = ["example.com" "example2.com"]; + default = []; + description = '' + For which domains should this account act as a catch all? + Note: Does not allow sending from all addresses of these domains. + ''; + }; - catchAll = mkOption { - type = with types; listOf (enum cfg.domains); - example = [ - "example.com" - "example2.com" - ]; - 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 { + type = with types; nullOr types.str; + default = null; + example = "2G"; + description = '' + Per user quota rules. Accepted sizes are `xx k/M/G/T` with the + obvious meaning. Leave blank for the standard quota `100G`. + ''; + }; - quota = mkOption { - type = with types; nullOr types.str; - default = null; - example = "2G"; - description = '' - Per user quota rules. Accepted sizes are `xx k/M/G/T` with the - obvious meaning. Leave blank for the standard quota `100G`. - ''; - }; + sieveScript = mkOption { + type = with types; nullOr lines; + default = null; + example = '' + require ["fileinto", "mailbox"]; - sieveScript = mkOption { - type = with types; nullOr lines; - default = null; - example = '' - require ["fileinto", "mailbox"]; + if address :is "from" "gitlab@mg.gitlab.com" { + fileinto :create "GitLab"; + stop; + } - if address :is "from" "gitlab@mg.gitlab.com" { - fileinto :create "GitLab"; - stop; - } + # 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 + elsif header :matches "list-id" "" { + fileinto :create "Lists"; + stop; + } + ''; + description = '' + Per-user sieve script. + ''; + }; - # 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 - elsif header :matches "list-id" "" { - fileinto :create "Lists"; - stop; - } - ''; - description = '' - Per-user sieve script. - ''; - }; + sendOnly = mkOption { + type = types.bool; + default = false; + description = '' + Specifies if the account should be a send-only account. + Emails sent to send-only accounts will be rejected from + unauthorized senders with the `sendOnlyRejectMessage` + stating the reason. + ''; + }; - sendOnly = mkOption { - type = types.bool; - default = false; - description = '' - Specifies if the account should be a send-only account. - Emails sent to send-only accounts will be rejected from - unauthorized senders with the `sendOnlyRejectMessage` - stating the reason. - ''; - }; + sendOnlyRejectMessage = mkOption { + type = types.str; + default = "This account cannot receive emails."; + description = '' + 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 + as send-only. + ''; + }; + }; - sendOnlyRejectMessage = mkOption { - type = types.str; - default = "This account cannot receive emails."; - description = '' - 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 - as send-only. - ''; - }; - }; - - config.name = mkDefault name; - } - ) - ); + config.name = mkDefault name; + })); example = { user1 = { hashedPassword = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/"; @@ -250,13 +195,13 @@ in nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' ``` ''; - default = { }; + default = {}; }; ldap = { enable = mkEnableOption "LDAP support"; - uris = mkOption { + uris = mkOption { type = types.listOf types.str; example = literalExpression '' [ @@ -280,7 +225,7 @@ in tlsCAFile = mkOption { type = types.path; default = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; - defaultText = literalMD "see [source](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/default.nix)"; + defaultText = lib.literalMD "see [source](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/default.nix)"; description = '' Certifificate trust anchors used to verify the LDAP server certificate. ''; @@ -314,11 +259,7 @@ in }; searchScope = mkOption { - type = types.enum [ - "sub" - "base" - "one" - ]; + type = types.enum [ "sub" "base" "one" ]; default = "sub"; description = '' Search scope below which users accounts are looked for. @@ -327,8 +268,8 @@ in dovecot = { userAttrs = mkOption { - type = types.nullOr types.str; - default = null; + type = types.str; + default = ""; description = '' LDAP attributes to be retrieved during userdb lookups. @@ -340,8 +281,8 @@ in userFilter = mkOption { type = types.str; - default = "mail=%{user}"; - example = "(&(objectClass=inetOrgPerson)(mail=%{user}))"; + default = "mail=%u"; + example = "(&(objectClass=inetOrgPerson)(mail=%u))"; description = '' Filter for user lookups in Dovecot. @@ -365,8 +306,8 @@ in passFilter = mkOption { type = types.nullOr types.str; - default = "mail=%{user}"; - example = "(&(objectClass=inetOrgPerson)(mail=%{user}))"; + default = "mail=%u"; + example = "(&(objectClass=inetOrgPerson)(mail=%u))"; description = '' Filter for password lookups in Dovecot. @@ -430,21 +371,7 @@ in }; fullTextSearch = { - enable = mkEnableOption '' - Full text search indexing with Xapian through the fts_flatcurve plugin. - This has significant performance and disk space cost. - ''; - memoryLimit = mkOption { - type = types.nullOr types.int; - default = null; - example = 2000; - description = '' - Memory limit for the indexer process, in MiB. - If null, leaves the default (which is rather low), - and if 0, no limit. - ''; - }; - + enable = mkEnableOption "Full text search indexing with xapian. This has significant performance and disk space cost."; autoIndex = mkOption { type = types.bool; default = true; @@ -453,22 +380,20 @@ in autoIndexExclude = mkOption { type = types.listOf types.str; default = [ ]; - example = [ - "\\Trash" - "SomeFolder" - "Other/*" - ]; + example = [ "\\Trash" "SomeFolder" "Other/*" ]; description = '' Mailboxes to exclude from automatic indexing. ''; }; + indexAttachments = mkOption { + type = types.bool; + default = false; + description = "Also index text-only attachements. Binary attachements are never indexed."; + }; + enforced = mkOption { - type = types.enum [ - "yes" - "no" - "body" - ]; + type = types.enum [ "yes" "no" "body" ]; default = "no"; description = '' Fail searches when no index is available. If set to @@ -478,65 +403,46 @@ in ''; }; - languages = mkOption { - type = types.nonEmptyListOf types.str; - default = [ "en" ]; - example = [ - "en" - "de" - ]; - description = '' - A list of languages that the full text search should detect. - At least one language must be specified. - The language listed first is the default and is used when language recognition fails. - See . - ''; + minSize = mkOption { + type = types.int; + default = 2; + description = "Size of the smallest n-gram to index."; + }; + maxSize = mkOption { + type = types.int; + default = 20; + description = "Size of the largest n-gram to index."; + }; + memoryLimit = mkOption { + type = types.nullOr types.int; + default = null; + example = 2000; + description = "Memory limit for the indexer process, in MiB. If null, leaves the default (which is rather low), and if 0, no limit."; }; - substringSearch = mkOption { - type = types.bool; - default = false; - description = '' - If enabled, allows substring searches. - See . + maintenance = { + enable = mkOption { + type = types.bool; + default = true; + description = "Regularly optmize indices, as recommended by upstream."; + }; - Enabling this requires significant additional storage space. - ''; - }; + onCalendar = mkOption { + type = types.str; + default = "daily"; + description = "When to run the maintenance job. See systemd.time(7) for more information about the format."; + }; - headerExcludes = mkOption { - type = types.listOf types.str; - default = [ - "Received" - "DKIM-*" - "X-*" - "Comments" - ]; - description = '' - The list of headers to exclude. - See . - ''; - }; - - filters = mkOption { - type = types.listOf types.str; - default = [ - "normalizer-icu" - "snowball" - "stopwords" - ]; - description = '' - The list of filters to apply. - . - ''; + randomizedDelaySec = mkOption { + type = types.int; + default = 1000; + description = "Run the maintenance job not exactly at the time specified with `onCalendar`, but plus or minus this many seconds."; + }; }; }; lmtpSaveToDetailMailbox = mkOption { - type = types.enum [ - "yes" - "no" - ]; + type = types.enum ["yes" "no"]; default = "yes"; description = '' If an email address is delimited by a "+", should it be filed into a @@ -545,39 +451,18 @@ in ''; }; - lmtpMemoryLimit = mkOption { - type = types.int; - default = 256; - description = '' - The memory limit for the LMTP service, in megabytes. - ''; - }; - - quotaStatusMemoryLimit = mkOption { - type = types.int; - default = 256; - description = '' - The memory limit for the quota-status service, in megabytes. - ''; - }; - extraVirtualAliases = mkOption { - type = - let - loginAccount = mkOptionType { - name = "Login Account"; - }; - in - with types; - attrsOf (either loginAccount (nonEmptyListOf loginAccount)); + type = let + loginAccount = mkOptionType { + name = "Login Account"; + check = (account: builtins.elem account (builtins.attrNames cfg.loginAccounts)); + }; + in with types; attrsOf (either loginAccount (nonEmptyListOf loginAccount)); 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" - ]; + "multi@example.com" = [ "user1@example.com" "user2@example.com" ]; }; description = '' Virtual Aliases. A virtual alias `"info@example.com" = "user1@example.com"` means that @@ -590,7 +475,7 @@ in example all mails for `multi@example.com` will be forwarded to both `user1@example.com` and `user2@example.com`. ''; - default = { }; + default = {}; }; forwards = mkOption { @@ -607,34 +492,28 @@ in can't send mail as `user@example.com`. Also, this option allows to forward mails to external addresses. ''; - default = { }; + default = {}; }; rejectSender = mkOption { type = types.listOf types.str; - example = [ - "example.com" - "spammer@example.net" - ]; + example = [ "@example.com" "spammer@example.net" ]; description = '' Reject emails from these addresses from unauthorized senders. Use if a spammer is using the same domain or the same sender over and over. ''; - default = [ ]; + default = []; }; rejectRecipients = mkOption { type = types.listOf types.str; - example = [ - "sales@example.com" - "info@example.com" - ]; + example = [ "sales@example.com" "info@example.com" ]; description = '' 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 not want to disable the catchall. ''; - default = [ ]; + default = []; }; vmailUID = mkOption { @@ -682,15 +561,7 @@ in - /var/vmail/example.com/user/.folder.subfolder/ (default layout) - /var/vmail/example.com/user/folder/subfolder/ (FS layout) - See https://doc.dovecot.org/main/core/config/mailbox_formats/maildir.html#maildir-mailbox-format for details. - ''; - }; - - useUTF8FolderNames = mkOption { - type = types.bool; - default = false; - description = '' - Store mailbox names on disk using UTF-8 instead of modified UTF-7 (mUTF-7). + See https://wiki2.dovecot.org/MailboxFormat/Maildir for details. ''; }; @@ -703,7 +574,7 @@ in This affects how mailboxes appear to mail clients and sieve scripts. For instance when using "." then in a sieve script "example.com" would refer to the mailbox "com" in the parent mailbox "example". This does not determine the way your mails are stored on disk. - See https://doc.dovecot.org/main/core/config/namespaces.html#namespaces for details. + See https://wiki.dovecot.org/Namespaces for details. ''; }; @@ -732,46 +603,28 @@ in }; }; - certificateScheme = - let - schemes = [ - "manual" - "selfsigned" - "acme-nginx" - "acme" - ]; - 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: + certificateScheme = let + schemes = [ "manual" "selfsigned" "acme-nginx" "acme" ]; + 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 - {option}`mailserver.keyFile` and manually copy certificates there. - 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) - 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 - 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 - Nginx server on the FQDN). This is better than `manual` because the appropriate services will be reloaded - when the certificate is renewed. - ''; - }; + 1. `manual`: you specify locations via {option}`mailserver.certificateFile` and + {option}`mailserver.keyFile` and manually copy certificates there. + 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) + 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 + 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 + Nginx server on the FQDN). This is better than `manual` because the appropriate services will be reloaded + when the certificate is renewed. + ''; + }; certificateFile = mkOption { type = types.path; @@ -805,19 +658,6 @@ in ''; }; - acmeCertificateName = mkOption { - type = types.str; - default = cfg.fqdn; - example = "example.com"; - description = '' - ({option}`mailserver.certificateScheme` == `acme`) - - When the `acme` `certificateScheme` is selected, you can use this option - to override the default certificate name. This is useful if you've - generated a wildcard certificate, for example. - ''; - }; - enableImap = mkOption { type = types.bool; default = true; @@ -826,14 +666,6 @@ in ''; }; - imapMemoryLimit = mkOption { - type = types.int; - default = 256; - description = '' - The memory limit for the imap service, in megabytes. - ''; - }; - enableImapSsl = mkOption { type = types.bool; default = true; @@ -927,33 +759,37 @@ in ''; }; - dkimKeyType = mkOption { - type = types.enum [ - "rsa" - "ed25519" - ]; - default = "rsa"; - description = '' - The key type used for generating DKIM keys. ED25519 was introduced in RFC6376 (2018). + dkimKeyBits = mkOption { + type = types.int; + default = 1024; + description = '' + How many bits in generated DKIM keys. RFC6376 advises minimum 1024-bit keys. - If you have already deployed a key with a different type than specified - here, then you should use a different selector ({option}`mailserver.dkimSelector`). In order to get - this package to generate a key with the new type, you will either have to - change the selector or delete the old key file. - ''; + 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 + 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. + ''; }; - dkimKeyBits = mkOption { - type = types.int; - default = 1024; - description = '' - How many bits in generated DKIM keys. RFC6376 advises minimum 1024-bit keys. + dkimHeaderCanonicalization = mkOption { + type = types.enum ["relaxed" "simple"]; + default = "relaxed"; + description = '' + DKIM canonicalization algorithm for message headers. - 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 - 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. - ''; + See https://datatracker.ietf.org/doc/html/rfc6376/#section-3.4 for details. + ''; + }; + + dkimBodyCanonicalization = mkOption { + type = types.enum ["relaxed" "simple"]; + default = "relaxed"; + description = '' + DKIM canonicalization algorithm for message bodies. + + See https://datatracker.ietf.org/doc/html/rfc6376/#section-3.4 for details. + ''; }; dmarcReporting = { @@ -981,7 +817,7 @@ in }; domain = mkOption { - type = types.enum cfg.domains; + type = types.enum (cfg.domains); example = "example.com"; description = '' The domain from which outgoing DMARC reports are served. @@ -1015,14 +851,6 @@ in The sender name for DMARC reports. Defaults to the organization name. ''; }; - - excludeDomains = mkOption { - type = types.listOf types.str; - default = [ ]; - description = '' - List of domains or eSLDs to be excluded from DMARC reports. - ''; - }; }; debug = mkOption { @@ -1062,37 +890,38 @@ in }; redis = { - configureLocally = mkOption { - type = types.bool; - default = true; - description = '' - Whether to provision a local Redis instance. - ''; - }; - address = mkOption { type = types.str; # read the default from nixos' redis module - default = config.services.redis.servers.rspamd.unixSocket; - defaultText = literalExpression "config.services.redis.servers.rspamd.unixSocket"; + default = let + cf = config.services.redis.servers.rspamd.bind; + cfdefault = if cf == null then "127.0.0.1" else cf; + ips = lib.strings.splitString " " cfdefault; + ip = lib.lists.head (ips ++ [ "127.0.0.1" ]); + isIpv6 = ip: lib.lists.elem ":" (lib.stringToCharacters ip); + in + if (ip == "0.0.0.0" || ip == "::") + then "127.0.0.1" + else if isIpv6 ip then "[${ip}]" else ip; + defaultText = lib.literalMD "computed from `config.services.redis.servers.rspamd.bind`"; description = '' - Path, IP address or hostname that Rspamd should use to contact Redis. + Address that rspamd should use to contact redis. ''; }; port = mkOption { - type = with types; nullOr port; - default = null; - example = literalExpression "config.services.redis.servers.rspamd.port"; + type = types.port; + default = config.services.redis.servers.rspamd.port; + defaultText = lib.literalExpression "config.services.redis.servers.rspamd.port"; description = '' - Port that Rspamd should use to contact Redis. + Port that rspamd should use to contact redis. ''; }; password = mkOption { type = types.nullOr types.str; default = config.services.redis.servers.rspamd.requirePass; - defaultText = literalExpression "config.services.redis.servers.rspamd.requirePass"; + defaultText = lib.literalExpression "config.services.redis.servers.rspamd.requirePass"; description = '' Password that rspamd should use to contact redis, or null if not required. ''; @@ -1112,7 +941,7 @@ in sendingFqdn = mkOption { type = types.str; default = cfg.fqdn; - defaultText = literalMD "{option}`mailserver.fqdn`"; + defaultText = lib.literalMD "{option}`mailserver.fqdn`"; example = "myserver.example.com"; description = '' The fully qualified domain name of the mail server used to @@ -1136,6 +965,18 @@ in ''; }; + policydSPFExtraConfig = mkOption { + type = types.lines; + default = ""; + example = '' + skip_addresses = 127.0.0.0/8,::ffff:127.0.0.0/104,::1 + ''; + description = '' + Extra configuration options for policyd-spf. This can be use to among + other things skip spf checking for some IP addresses. + ''; + }; + monitoring = { enable = mkEnableOption "monitoring via monit"; @@ -1188,7 +1029,7 @@ in start program = "${pkgs.systemd}/bin/systemctl start rspamd" stop program = "${pkgs.systemd}/bin/systemctl stop rspamd" ''; - defaultText = literalMD "see [source](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/default.nix)"; + defaultText = lib.literalMD "see [source](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/default.nix)"; description = '' The configuration used for monitoring via monit. Use a mail address that you actively check and set it via 'set alert ...'. @@ -1230,15 +1071,7 @@ in compression = { method = mkOption { - type = types.nullOr ( - types.enum [ - "none" - "lz4" - "zstd" - "zlib" - "lzma" - ] - ); + 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."; }; @@ -1296,14 +1129,14 @@ in locations = mkOption { type = types.listOf types.path; - default = [ cfg.mailDirectory ]; - defaultText = literalExpression "[ config.mailserver.mailDirectory ]"; + default = [cfg.mailDirectory]; + defaultText = lib.literalExpression "[ config.mailserver.mailDirectory ]"; description = "The locations that are to be backed up by borg."; }; extraArgumentsForInit = mkOption { type = types.listOf types.str; - default = [ "--critical" ]; + default = ["--critical"]; description = "Additional arguments to add to the borg init command line."; }; @@ -1337,6 +1170,27 @@ in }; + rebootAfterKernelUpgrade = { + enable = mkOption { + type = types.bool; + default = false; + example = true; + description = '' + Whether to enable automatic reboot after kernel upgrades. + This is to be used in conjunction with `system.autoUpgrade.enable = true;` + ''; + }; + method = mkOption { + type = types.enum [ "reboot" "systemctl kexec" ]; + default = "reboot"; + description = '' + Whether to issue a full "reboot" or just a "systemctl kexec"-only reboot. + It is recommended to use the default value because the quicker kexec reboot has a number of problems. + Also if your server is running in a virtual machine the regular reboot will already be very quick. + ''; + }; + }; + backup = { enable = mkEnableOption "backup via rsnapshot"; @@ -1383,9 +1237,9 @@ in cronIntervals = mkOption { type = types.attrsOf types.str; default = { - # minute, hour, day-in-month, month, weekday (0 = sunday) + # minute, hour, day-in-month, month, weekday (0 = sunday) 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 }; description = '' @@ -1398,33 +1252,9 @@ in }; imports = [ - (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "enable" ] '' - This option is not needed for fts-flatcurve - '') - (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "onCalendar" ] '' - This option is not needed for fts-flatcurve - '') - (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "randomizedDelaySec" ] '' - This option is not needed for fts-flatcurve - '') - (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "minSize" ] '' - This option is not supported by fts-flatcurve - '') - (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maxSize" ] '' - This option is not needed since fts-xapian 1.8.3 - '') - (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "indexAttachments" ] '' - Text attachments are always indexed since fts-xapian 1.4.8 - '') - (mkRenamedOptionModule - [ "mailserver" "rebootAfterKernelUpgrade" "enable" ] - [ "system" "autoUpgrade" "allowReboot" ] - ) - (mkRemovedOptionModule [ "mailserver" "rebootAfterKernelUpgrade" "method" ] '' - Use `system.autoUpgrade` instead. - '') ./mail-server/assertions.nix ./mail-server/borgbackup.nix + ./mail-server/debug.nix ./mail-server/rsnapshot.nix ./mail-server/clamav.nix ./mail-server/monit.nix @@ -1433,22 +1263,11 @@ in ./mail-server/networking.nix ./mail-server/systemd.nix ./mail-server/dovecot.nix + ./mail-server/opendkim.nix ./mail-server/postfix.nix ./mail-server/rspamd.nix ./mail-server/nginx.nix ./mail-server/kresd.nix - (mkRemovedOptionModule [ "mailserver" "policydSPFExtraConfig" ] '' - SPF checking has been migrated to Rspamd, which makes this config redundant. Please look into the rspamd config to migrate your settings. - It may be that they are redundant and are already configured in rspamd like for skip_addresses. - '') - (mkRemovedOptionModule [ "mailserver" "dkimHeaderCanonicalization" ] '' - DKIM signing has been migrated to Rspamd, which always uses relaxed canonicalization. - '') - (mkRemovedOptionModule [ "mailserver" "dkimBodyCanonicalization" ] '' - DKIM signing has been migrated to Rspamd, which always uses relaxed canonicalization. - '') - (mkRemovedOptionModule [ "mailserver" "smtpdForbidBareNewline" ] '' - The workaround for the SMTP Smuggling attack is default enabled in Postfix >3.9. Use `services.postfix.config.smtpd_forbid_bare_newline` if you need to deviate from its default. - '') + ./mail-server/post-upgrade-check.nix ]; } diff --git a/docs/add-radicale.rst b/docs/add-radicale.rst index cf98333..2393f6e 100644 --- a/docs/add-radicale.rst +++ b/docs/add-radicale.rst @@ -24,13 +24,12 @@ have to be used. These can still be generated using `mkpasswd -m bcrypt`. in { services.radicale = { enable = true; - settings = { - auth = { - type = "htpasswd"; - htpasswd_filename = "${htpasswd}"; - htpasswd_encryption = "bcrypt"; - }; - }; + config = '' + [auth] + type = htpasswd + htpasswd_filename = ${htpasswd} + htpasswd_encryption = bcrypt + ''; }; services.nginx = { diff --git a/docs/add-roundcube.rst b/docs/add-roundcube.rst index 6b10d5b..4e6be83 100644 --- a/docs/add-roundcube.rst +++ b/docs/add-roundcube.rst @@ -20,7 +20,7 @@ servers may require more work. extraConfig = '' # starttls needed for authentication, so the fqdn required to match # the certificate - $config['smtp_host'] = "tls://${config.mailserver.fqdn}"; + $config['smtp_server'] = "tls://${config.mailserver.fqdn}"; $config['smtp_user'] = "%u"; $config['smtp_pass'] = "%p"; ''; diff --git a/docs/advanced-configurations.rst b/docs/advanced-configurations.rst deleted file mode 100644 index e2b7837..0000000 --- a/docs/advanced-configurations.rst +++ /dev/null @@ -1,14 +0,0 @@ -Advanced Configurations -======================= - -Congratulations on completing the `Setup Guide `_! - -If you're an experienced mailserver admin, then you probably know what you want -to do next. Our How-to guides (accessible in the navigation sidebar) -might help you accomplish your goals. If not, consider contributing a guide! - -If this is your first mailserver, consider the following: - -- Set up `backups `_. -- Enable `DMARC reporting `_ to be a - good citizen in the mail ecosystem. diff --git a/docs/backup-guide.rst b/docs/backup-guide.rst index 67d08d0..ef7a848 100644 --- a/docs/backup-guide.rst +++ b/docs/backup-guide.rst @@ -14,13 +14,6 @@ forget to ``chown`` them to ``virtualMail:virtualMail`` if you copy them back (or whatever you specified as ``vmailUserName``, and ``vmailGoupName``). -If you enabled ``enableManageSieve`` then you also may want to backup -``/var/sieve`` or whatever you have specified as ``sieveDirectory``. -The same considerations regarding file ownership apply as for the -Maildir. - -To backup spam and ham training data, backup ``/var/lib/redis-rspamd``. - Finally you can (optionally) make a backup of ``/var/dkim`` (or whatever you specified as ``dkimKeyDirectory``). If you should lose those don’t worry, new ones will be created on the fly. But you will need to repeat diff --git a/docs/conf.py b/docs/conf.py index 7bc771b..1845917 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,9 +17,9 @@ # -- Project information ----------------------------------------------------- -project = "NixOS Mailserver" -copyright = "2022, NixOS Mailserver Contributors" -author = "NixOS Mailserver Contributors" +project = 'NixOS Mailserver' +copyright = '2022, NixOS Mailserver Contributors' +author = 'NixOS Mailserver Contributors' # -- General configuration --------------------------------------------------- @@ -27,31 +27,33 @@ author = "NixOS Mailserver Contributors" # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["myst_parser"] +extensions = [ + 'myst_parser' +] myst_enable_extensions = [ - "colon_fence", - "linkify", + 'colon_fence', + 'linkify', ] smartquotes = False # Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] +templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] -master_doc = "index" +master_doc = 'index' # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = "sphinx_rtd_theme" +html_theme = 'sphinx_rtd_theme' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/flakes.rst b/docs/flakes.rst index f56ec96..254a02a 100644 --- a/docs/flakes.rst +++ b/docs/flakes.rst @@ -1,7 +1,7 @@ Nix Flakes ========== -If you're using `flakes `__, you can use +If you're using `flakes `__, you can use the following minimal ``flake.nix`` as an example: .. code:: nix diff --git a/docs/fts.rst b/docs/fts.rst index bb2fe88..5d84eaf 100644 --- a/docs/fts.rst +++ b/docs/fts.rst @@ -4,7 +4,7 @@ Full text search By default, when your IMAP client searches for an email containing some text in its *body*, dovecot will read all your email sequentially. This is very slow and IO intensive. To speed body searches up, it is possible to -*index* emails with a plugin to dovecot, ``fts_flatcurve``. +*index* emails with a plugin to dovecot, ``fts_xapian``. Enabling full text search ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -20,6 +20,8 @@ To enable indexing for full text search here is an example configuration. enable = true; # index new email as they arrive autoIndex = true; + # this only applies to plain text attachments, binary attachments are never indexed + indexAttachments = true; enforced = "body"; }; }; @@ -59,8 +61,8 @@ Mitigating resources requirements You can: -* exclude some headers from indexation with ``mailserver.fullTextSearch.headerExcludes`` -* disable expensive token normalisation in ``mailserver.fullTextSearch.filters`` +* disable indexation of attachements ``mailserver.fullTextSearch.indexAttachments = false`` +* reduce the size of ngrams to be indexed ``mailserver.fullTextSearch.minSize`` and ``maxSize`` * disable automatic indexation for some folders with ``mailserver.fullTextSearch.autoIndexExclude``. Folders can be specified by name (``"Trash"``), by special use (``"\\Junk"``) or with a wildcard. diff --git a/docs/howto-develop.rst b/docs/howto-develop.rst index dbf3024..ded90b9 100644 --- a/docs/howto-develop.rst +++ b/docs/howto-develop.rst @@ -4,33 +4,13 @@ Contribute or troubleshoot To report an issue, please go to ``_. -If you have questions, feel free to reach out: - -* Matrix: `#nixos-mailserver:nixos.org `__ -* IRC: `#nixos-mailserver `__ on `Libera Chat `__ - -All our workflows rely on Nix being configured with `Flakes `__. - -Development Shell ------------------ - -We provide a `flake.nix` devshell that automatically sets up pre-commit hooks, -which allows for fast feedback cycles when making changes to the repository. - - -:: - - $ nix develop - - -We recommend setting up `direnv `__ to automatically -attach to the development environment when entering the project directories. +You can also chat with us on the Libera IRC channel ``#nixos-mailserver``. Run NixOS tests --------------- To run the test suite, you need to enable `Nix Flakes -`__. +`_. You can then run the testsuite via @@ -57,7 +37,7 @@ For the syntax, see the `RST/Sphinx primer `_. To build the documentation, you need to enable `Nix Flakes -`__. +`_. :: @@ -65,43 +45,28 @@ To build the documentation, you need to enable `Nix Flakes $ nix build .#documentation $ xdg-open result/index.html +Nixops +------ -Manual migrations ------------------ +You can test the setup via ``nixops``. After installation, do -We need to take great care around providing a migration story around breaking -changes. If manual intervention becomes necessary we provide the `stateVersion` -option to notify the user that they need to complete a migration before -they can deploy an update. +:: -If that is the case for your change, find the highest `stateVersion` that is -being asserted on in `mail-server/assertions.nix`. Then pick the next number -and add a new assertion, write a good summary describing the issue and what -remediation steps are necessary. Finally reference the URL to the specific -section on the migration page in the documentation. + $ nixops create nixops/single-server.nix nixops/vbox.nix -d mail + $ nixops deploy -d mail + $ nixops info -d mail -.. code-block:: nix +You can then test the server via e.g. \ ``telnet``. To log into it, use - { - assertions = [ - { - assertion = config.mailserver.stateVersion >= 1; - message = '' - Problem: The home directory for the foobar service is snafu. - Remediation: - - Stop the `foobar.service` - - Rename `/var/lib/foobaz` to `/var/lib/foobar` - - Increase the `mailserver.stateVersion` to 1. +:: - Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#specific-anchor-here for further details. - ''; - } - ]; - } + $ nixops ssh -d mail mailserver -The setup guide should always reference the latest `stateVersion`, since we -don't require any migration steps for new setups. +Imap +---- -The migration documentation should paint a more complete picture about the steps -that need to be carried out and why this has become necessary. Make sure to -reference the correct anchor in the URL you put into the assertion message. +To test imap manually use + +:: + + $ openssl s_client -host mail.example.com -port 143 -starttls imap diff --git a/docs/index.rst b/docs/index.rst index 0536c3c..717eed0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,12 +14,10 @@ Welcome to NixOS Mailserver's documentation! :maxdepth: 2 setup-guide - advanced-configurations howto-develop faq release-notes options - migrations .. toctree:: :maxdepth: 1 @@ -32,7 +30,6 @@ Welcome to NixOS Mailserver's documentation! fts flakes autodiscovery - ldap Indices and tables ================== diff --git a/docs/ldap.rst b/docs/ldap.rst deleted file mode 100644 index efd975d..0000000 --- a/docs/ldap.rst +++ /dev/null @@ -1,14 +0,0 @@ -LDAP Support -============ - -It is possible to manage mail user accounts with LDAP rather than with -the option `loginAccounts `_. - -All related LDAP options are described in the `LDAP options section -`_ and the `LDAP test -`_ -provides a getting started example. - -.. note:: - The LDAP support can not be enabled if some accounts are also defined with ``mailserver.loginAccounts``. - diff --git a/docs/migrations.rst b/docs/migrations.rst deleted file mode 100644 index daef17e..0000000 --- a/docs/migrations.rst +++ /dev/null @@ -1,45 +0,0 @@ -Migrations -========== - -With mail server configuration best practices changing over time we might need -to make changes that require you to complete manual migration steps before you -can deploy a new version of NixOS mailserver. - -The initial `mailserver.stateVersion` value should be copied from the setup -guide that you used to initially set up your mail server. If in doubt you can -always initialize it at `1` and walk through all assertions, that might apply -to your setup. - -NixOS 25.11 ------------ - -#2 Dovecot LDAP home directory migration -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The Dovecot configuration for LDAP home directories previously did not respect -the ``mailserver.mailDirectory`` setting. - -This means that home directories were unconditionally located at -``/var/vmail/ldap/%{user}``. - -This migration is required if you both: - -* enabled the LDAP integration (``mailserver.ldap.enable``) -* and customized the default mail directory (``mailserver.mailDirectory != "/var/vmail"``) - -For remediating this issue the following steps are required: - -1. Stop ``dovecot2.service``. -2. Move ``/var/vmail/ldap`` below your ``m̀ailserver.mailDirectory``. -3. Update the ``mailserver.stateVersion`` to ``2``. - -#1 Initialization -^^^^^^^^^^^^^^^^^ - -This option was introduced in the NixOS 25.11 release cycle, in which case you -can safely initialize its value at `1`. - -.. code-block:: nix - - mailserver.stateVersion = 1; - diff --git a/docs/release-notes.rst b/docs/release-notes.rst index 7e1429f..fa1a87c 100644 --- a/docs/release-notes.rst +++ b/docs/release-notes.rst @@ -1,67 +1,10 @@ Release Notes ============= -NixOS 25.05 ------------ - -- OpenDKIM has been removed and DKIM signing is now handled by Rspamd, which only supports ``relaxed`` canoncalizaliaton. - (`merge request `__) -- Rspamd now connects to Redis over its Unix Domain Socket by default - (`merge request `__) - - - If you need to revert TCP connections, configure ``mailserver.redis.address`` to reference the value of ``config.services.redis.servers.rspamd.bind``. -- The integration with policyd-spf was removed and SPF handling is now fully based on Rspamd scoring. - (`merge request `__) -- Switch to the more efficient `fts-flatcurve` indexer for full text search - (`merge request `__). - - This makes use of a new index, which will be automatically re-generated the - next time a folder is searched. - The operation is now quick enough to be performed "just-in-time". - Alternatively, all indices can be immediately re-generated for all users and - folders by running - - .. code-block:: bash - - doveadm fts rescan -u '*' && doveadm index -u '*' -q '*' - - The previous index (which is not automatically discarded to allow rollbacks) - can be cleaned up by removing all the `xapian-indexes` directories within - ``mailserver.indexDir``. -- Individual domains can now be excluded from DMARC Reporting through ``mailserver.dmarcReporting.excludedDomains``. - (`merge request `__) -- Configuring ``mailserver.forwards`` is now possible when the setup relies on LDAP. - (`merge request `__) -- Support for TLS 1.1 was disabled in accordance with `Mozilla's recommendations `_. - (`merge request `__) - -NixOS 24.11 ------------ - -- No new feature, only bug fixes and documentation improvements - -NixOS 24.05 ------------ - -- Add new option ``acmeCertificateName`` which can be used to support - wildcard certificates - -NixOS 23.11 ------------ - -- Add basic support for LDAP users -- Add support for regex (PCRE) aliases - -NixOS 23.05 ------------ - -- Existing ACME certificates can be reused without configuring NGINX -- Certificate scheme is no longer a number, but a meaningful string instead - NixOS 22.11 ----------- -- Allow Rspamd to send DMARC reporting +- Allow Rspamd to send dmarc reporting (`merge request `__) NixOS 22.05 @@ -84,6 +27,7 @@ NixOS 21.11 - New option ``certificateDomains`` to generate certificate for additional domains (such as ``imap.example.com``) + NixOS 21.05 ----------- diff --git a/docs/requirements.txt b/docs/requirements.txt index c77dd1e..2211dd5 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,4 +2,3 @@ sphinx ~= 5.3 sphinx_rtd_theme ~= 1.1 myst-parser ~= 0.18 linkify-it-py ~= 2.0 -standard-imghdr diff --git a/docs/rspamd-tuning.rst b/docs/rspamd-tuning.rst index 3ba8133..049858d 100644 --- a/docs/rspamd-tuning.rst +++ b/docs/rspamd-tuning.rst @@ -24,14 +24,17 @@ You can run the training in a root shell as follows: .. code:: bash + # Path to the controller socket + export RSOCK="/var/run/rspamd/worker-controller.sock" + # Learn the Junk folder as spam - rspamc learn_spam /var/vmail/$DOMAIN/$USER/.Junk/cur/ + rspamc -h $RSOCK learn_spam /var/vmail/$DOMAIN/$USER/.Junk/cur/ # Learn the INBOX as ham - rspamc learn_ham /var/vmail/$DOMAIN/$USER/cur/ + rspamc -h $RSOCK learn_ham /var/vmail/$DOMAIN/$USER/cur/ # Check that training was successful - rspamc stat | grep learned + rspamc -h $RSOCK stat | grep learned Tune symbol weight ~~~~~~~~~~~~~~~~~~ diff --git a/docs/setup-guide.rst b/docs/setup-guide.rst index 6c07a1e..c74a53d 100644 --- a/docs/setup-guide.rst +++ b/docs/setup-guide.rst @@ -20,30 +20,25 @@ an up and running mail server. Once the server is deployed, we could then set all DNS entries required to send and receive mails on this server. -Setup DNS A/AAAA records for server -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Setup DNS A record for server +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Add DNS records to the domain ``example.com`` with the following +Add a DNS record to the domain ``example.com`` with the following entries ==================== ===== ==== ============= Name (Subdomain) TTL Type Value ==================== ===== ==== ============= ``mail.example.com`` 10800 A ``1.2.3.4`` -``mail.example.com`` 10800 AAAA ``2001::1`` ==================== ===== ==== ============= -If your server does not have an IPv6 address, you must skip the `AAAA` record. - You can check this with :: - $ nix-shell -p bind --command "host -t A mail.example.com" - mail.example.com has address 1.2.3.4 - - $ nix-shell -p bind --command "host -t AAAA mail.example.com" - mail.example.com has address 2001::1 + $ ping mail.example.com + 64 bytes from mail.example.com (1.2.3.4): icmp_seq=1 ttl=46 time=21.3 ms + ... Note that it can take a while until a DNS entry is propagated. This DNS entry is required for the Let's Encrypt certificate generation @@ -53,45 +48,41 @@ Setup the server ~~~~~~~~~~~~~~~~ The following describes a server setup that is fairly complete. Even -though there are more possible options (see the `NixOS Mailserver -options documentation `_), these should be the most -common ones. +though there are more possible options (see the ``default.nix`` file), +these should be the most common ones. .. code:: nix - { config, pkgs, ... }: { + { config, pkgs, ... }: + { imports = [ (builtins.fetchTarball { - # Pick a release version you are interested in and set its hash, e.g. - url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/nixos-25.05/nixos-mailserver-nixos-25.05.tar.gz"; - # To get the sha256 of the nixos-mailserver tarball, we can use the nix-prefetch-url command: - # release="nixos-25.05"; nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/${release}/nixos-mailserver-${release}.tar.gz" --unpack + # Pick a commit from the branch you are interested in + url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/A-COMMIT-ID/nixos-mailserver-A-COMMIT-ID.tar.gz"; + # And set its hash sha256 = "0000000000000000000000000000000000000000000000000000"; }) ]; mailserver = { enable = true; - stateVersion = 2; fqdn = "mail.example.com"; domains = [ "example.com" ]; # A list of all login accounts. To create the password hashes, use # nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' loginAccounts = { - "user1@example.com" = { - hashedPasswordFile = "/a/file/containing/a/hashed/password"; - aliases = ["postmaster@example.com"]; - }; - "user2@example.com" = { ... }; + "user1@example.com" = { + hashedPasswordFile = "/a/file/containing/a/hashed/password"; + aliases = ["postmaster@example.com"]; + }; + "user2@example.com" = { ... }; }; # Use Let's Encrypt certificates. Note that this needs to set up a stripped # down nginx and opens port 80. certificateScheme = "acme-nginx"; }; - security.acme.acceptTerms = true; - security.acme.defaults.email = "security@example.com"; } After a ``nixos-rebuild switch`` your server should be running all @@ -104,11 +95,8 @@ Set rDNS (reverse DNS) entry for server ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Wherever you have rented your server, you should be able to set reverse -DNS entries for the IP’s you own: - -- Add an entry resolving IPv4 address ``1.2.3.4`` to ``mail.example.com``. -- Add an entry resolving IPv6 ``2001::1`` to ``mail.example.com``. Again, this - must be skipped if your server does not have an IPv6 address. +DNS entries for the IP’s you own. Add an entry resolving ``1.2.3.4`` +to ``mail.example.com``. .. warning:: @@ -124,9 +112,6 @@ You can check this with $ nix-shell -p bind --command "host 1.2.3.4" 4.3.2.1.in-addr.arpa domain name pointer mail.example.com. - $ nix-shell -p bind --command "host 2001::1" - 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.0.2.ip6.arpa domain name pointer mail.example.com. - Note that it can take a while until a DNS entry is propagated. Set a ``MX`` record @@ -174,26 +159,25 @@ Note that it can take a while until a DNS entry is propagated. Set ``DKIM`` signature ^^^^^^^^^^^^^^^^^^^^^^ -On your server, the ``rspamd`` systemd service generated a file +On your server, the ``opendkim`` systemd service generated a file containing your DKIM public key in the file ``/var/dkim/example.com.mail.txt``. The content of this file looks like :: - mail._domainkey IN TXT ( "v=DKIM1; k=rsa; " - "p=" ) ; ----- DKIM key mail for nixos.org + mail._domainkey IN TXT "v=DKIM1; k=rsa; s=email; p=" ; ----- DKIM mail for domain.tld where ``really-long-key`` is your public key. Based on the content of this file, we can add a ``DKIM`` record to the domain ``example.com``. -=========================== ===== ==== ================================================ +=========================== ===== ==== ============================== Name (Subdomain) TTL Type Value -=========================== ===== ==== ================================================ -mail._domainkey.example.com 10800 TXT ``v=DKIM1; k=rsa; s=email; p=`` -=========================== ===== ==== ================================================ +=========================== ===== ==== ============================== +mail._domainkey.example.com 10800 TXT ``v=DKIM1; p=`` +=========================== ===== ==== ============================== You can check this with @@ -238,8 +222,3 @@ Besides that, you can send an email to score, and let `mxtoolbox.com `__ take a look at your setup, but if you followed the steps closely then everything should be awesome! - -Next steps (optional) -~~~~~~~~~~~~~~~~~~~~~ - -Take a look through our `Advanced Configurations `_. diff --git a/flake.lock b/flake.lock index c7979e2..4c8b160 100644 --- a/flake.lock +++ b/flake.lock @@ -19,11 +19,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1747046372, - "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", + "lastModified": 1668681692, + "narHash": "sha256-Ht91NGdewz8IQLtWZ9LCeNXMSXHUss+9COoqu6JLmXU=", "owner": "edolstra", "repo": "flake-compat", - "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "rev": "009399224d5e398d03b22badca40a37ac85412a1", "type": "github" }, "original": { @@ -32,90 +32,58 @@ "type": "github" } }, - "git-hooks": { - "inputs": { - "flake-compat": [ - "flake-compat" - ], - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1749636823, - "narHash": "sha256-WUaIlOlPLyPgz9be7fqWJA5iG6rHcGRtLERSCfUDne4=", - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "623c56286de5a3193aa38891a6991b28f9bab056", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, - "gitignore": { - "inputs": { - "nixpkgs": [ - "git-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1709087332, - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1749285348, - "narHash": "sha256-frdhQvPbmDYaScPFiCnfdh3B/Vh81Uuoo0w5TkWmmjU=", + "lastModified": 1670751203, + "narHash": "sha256-XdoH1v3shKDGlrwjgrNX/EN8s3c+kQV7xY6cLCE8vcI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3e3afe5174c561dee0df6f2c2b2236990146329f", + "rev": "64e0bf055f9d25928c31fb12924e59ff8ce71e60", "type": "github" }, "original": { - "owner": "NixOS", + "id": "nixpkgs", "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" + "type": "indirect" } }, - "nixpkgs-25_05": { + "nixpkgs-22_11": { "locked": { - "lastModified": 1749727998, - "narHash": "sha256-mHv/yeUbmL91/TvV95p+mBVahm9mdQMJoqaTVTALaFw=", + "lastModified": 1669558522, + "narHash": "sha256-yqxn+wOiPqe6cxzOo4leeJOp1bXE/fjPEi/3F/bBHv8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "fd487183437963a59ba763c0cc4f27e3447dd6dd", + "rev": "ce5fe99df1f15a09a91a86be9738d68fadfbad82", "type": "github" }, "original": { - "owner": "NixOS", - "ref": "nixos-25.05", - "repo": "nixpkgs", - "type": "github" + "id": "nixpkgs", + "ref": "nixos-22.11", + "type": "indirect" } }, "root": { "inputs": { "blobs": "blobs", "flake-compat": "flake-compat", - "git-hooks": "git-hooks", "nixpkgs": "nixpkgs", - "nixpkgs-25_05": "nixpkgs-25_05" + "nixpkgs-22_11": "nixpkgs-22_11", + "utils": "utils" + } + }, + "utils": { + "locked": { + "lastModified": 1605370193, + "narHash": "sha256-YyMTf3URDL/otKdKgtoMChu4vfVL3vCMkRqpGifhUn0=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "5021eac20303a61fafe17224c087f5519baed54d", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index 18757c5..3cbc8ed 100644 --- a/flake.nix +++ b/flake.nix @@ -3,222 +3,121 @@ inputs = { flake-compat = { - # for shell.nix compat url = "github:edolstra/flake-compat"; flake = false; }; - git-hooks = { - url = "github:cachix/git-hooks.nix"; - inputs.flake-compat.follows = "flake-compat"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - nixpkgs-25_05.url = "github:NixOS/nixpkgs/nixos-25.05"; + utils.url = "github:numtide/flake-utils"; + nixpkgs.url = "flake:nixpkgs/nixos-unstable"; + nixpkgs-22_11.url = "flake:nixpkgs/nixos-22.11"; blobs = { url = "gitlab:simple-nixos-mailserver/blobs"; flake = false; }; }; - outputs = - { - self, - blobs, - git-hooks, - nixpkgs, - nixpkgs-25_05, - ... - }: - let - lib = nixpkgs.lib; - system = "x86_64-linux"; - pkgs = nixpkgs.legacyPackages.${system}; - releases = [ - { - name = "unstable"; - nixpkgs = nixpkgs; - pkgs = nixpkgs.legacyPackages.${system}; - } - { - name = "25.05"; - nixpkgs = nixpkgs-25_05; - pkgs = nixpkgs-25_05.legacyPackages.${system}; - } - ]; - testNames = [ - "clamav" - "external" - "internal" - "ldap" - "multiple" - ]; - - genTest = - testName: release: - let - pkgs = release.pkgs; - nixos-lib = import (release.nixpkgs + "/nixos/lib") { - inherit (pkgs) lib; - }; - in - { - name = "${testName}-${builtins.replaceStrings [ "." ] [ "_" ] release.name}"; - value = nixos-lib.runTest { - hostPkgs = pkgs; - imports = [ ./tests/${testName}.nix ]; - _module.args = { inherit blobs; }; - extraBaseModules.imports = [ ./default.nix ]; - }; - }; - - # Generate an attribute set such as - # { - # external-unstable = ; - # external-21_05 = ; - # ... - # } - 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 - ''; + outputs = { self, utils, blobs, nixpkgs, nixpkgs-22_11, ... }: let + lib = nixpkgs.lib; + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + releases = [ + { + name = "unstable"; + pkgs = nixpkgs.legacyPackages.${system}; + } + ]; + testNames = [ + "internal" + "external" + "clamav" + "multiple" + "ldap" + ]; + genTest = testName: release: { + "name"= "${testName}-${builtins.replaceStrings ["."] ["_"] release.name}"; + "value"= import (./tests/. + "/${testName}.nix") { + pkgs = release.pkgs; + inherit blobs; }; + }; + # Generate an attribute set such as + # { + # external-unstable = ; + # external-21_05 = ; + # ... + # } + allTests = lib.listToAttrs ( + lib.flatten (map (t: map (r: genTest t r) releases) testNames)); - 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; + 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"; }; }; - 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 + 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 + ''; - formatter.${system} = pkgs.nixfmt-tree; + documentation = pkgs.stdenv.mkDerivation { + name = "documentation"; + src = lib.sourceByRegex ./docs ["logo\\.png" "conf\\.py" "Makefile" ".*\\.rst"]; + buildInputs = [( + pkgs.python3.withPackages (p: with p; [ + sphinx + sphinx_rtd_theme + myst-parser + ]) + )]; + buildPhase = '' + cp ${optionsDoc} options.md + # Workaround for https://github.com/sphinx-doc/sphinx/issues/3451 + unset SOURCE_DATE_EPOCH + make html + ''; + installPhase = '' + cp -Tr _build/html $out + ''; }; + + in { + nixosModules = rec { + mailserver = mailserverModule; + default = mailserver; + }; + nixosModule = self.nixosModules.default; # compatibility + hydraJobs.${system} = allTests // { + inherit documentation; + }; + checks.${system} = allTests; + packages.${system} = { + inherit optionsDoc documentation; + }; + devShells.${system}.default = pkgs.mkShell { + inputsFrom = [ documentation ]; + packages = with pkgs; [ + clamav + ]; + }; + devShell.${system} = self.devShells.${system}.default; # compatibility + }; } diff --git a/mail-server/assertions.nix b/mail-server/assertions.nix index 1c7d3f9..d2c44ea 100644 --- a/mail-server/assertions.nix +++ b/mail-server/assertions.nix @@ -1,48 +1,17 @@ +{ config, lib, pkgs, ... }: { - config, - lib, - ... -}: -{ - # We guard all assertions by requiring mailserver to be actually enabled - assertions = lib.optionals config.mailserver.enable ( - [ - { - assertion = config.mailserver.stateVersion != null; - message = "The `mailserver.stateVersion` option is not set. Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html to determine the proper value to initialize it at."; - } - ] - ++ lib.optionals config.mailserver.ldap.enable [ - { - assertion = config.mailserver.loginAccounts == { }; - message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.loginAccounts"; - } -# { -# assertion = config.mailserver.extraVirtualAliases == { }; -# message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.extraVirtualAliases"; -# } - ] - ++ - lib.optionals (config.mailserver.ldap.enable && config.mailserver.mailDirectory != "/var/vmail") - [ - { - assertion = config.mailserver.stateVersion >= 2; - message = '' - Issue: The dovecot homedir for LDAP users was previously not respecting `mailserver.mailDirectory`. - Remediation: - - Stop the `dovecot2.service` - - Move `/var/vmail/ldap` below your `mailserver.mailDirectory` - - Increase the `stateVersion` to 2. - - Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-home-directory-migration for more information. - ''; - } - ] - ++ lib.optionals (config.mailserver.certificateScheme != "acme") [ - { - assertion = config.mailserver.acmeCertificateName == config.mailserver.fqdn; - message = "When the certificate scheme is not 'acme' (mailserver.certificateScheme != \"acme\"), it is not possible to define mailserver.acmeCertificateName"; - } - ] - ); + assertions = lib.optionals config.mailserver.ldap.enable [ + { + assertion = config.mailserver.loginAccounts == {}; + message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.loginAccounts"; + } + { + assertion = config.mailserver.extraVirtualAliases == {}; + message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.extraVirtualAliases"; + } + { + assertion = config.mailserver.forwards == {}; + message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.forwards"; + } + ]; } diff --git a/mail-server/borgbackup.nix b/mail-server/borgbackup.nix index 51ae986..ef83b0d 100644 --- a/mail-server/borgbackup.nix +++ b/mail-server/borgbackup.nix @@ -14,44 +14,28 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: +{ 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"; + if cfg.compression.auto && cfg.compression.method == null + then throw "compression.method must be set when using auto." + else lib.optional cfg.compression.auto "auto"; levelFragment = - if cfg.compression.level != null && cfg.compression.method == null then - throw "compression.method must be set when using compression.level." - else - lib.optional (cfg.compression.level != null) (toString cfg.compression.level); - compressionFragment = lib.concatStringsSep "," ( - lib.flatten [ - autoFragment - methodFragment - levelFragment - ] - ); + if cfg.compression.level != null && cfg.compression.method == null + then throw "compression.method must be set when using compression.level." + else lib.optional (cfg.compression.level != null) (toString cfg.compression.level); + compressionFragment = lib.concatStringsSep "," (lib.flatten [autoFragment methodFragment levelFragment]); compression = lib.optionalString (compressionFragment != "") "--compression ${compressionFragment}"; encryptionFragment = cfg.encryption.method; passphraseFile = lib.escapeShellArg cfg.encryption.passphraseFile; - passphraseFragment = lib.optionalString (cfg.encryption.method != "none") ( - if cfg.encryption.passphraseFile != null then - ''env BORG_PASSPHRASE="$(cat ${passphraseFile})"'' - else - throw "passphraseFile must be set when using encryption." - ); + passphraseFragment = lib.optionalString (cfg.encryption.method != "none") + (if cfg.encryption.passphraseFile != null then ''env BORG_PASSPHRASE="$(cat ${passphraseFile})"'' + else throw "passphraseFile must be set when using encryption."); locations = lib.escapeShellArgs cfg.locations; name = lib.escapeShellArg cfg.name; @@ -71,8 +55,7 @@ let ${passphraseFragment} ${pkgs.borgbackup}/bin/borg create ${extraCreateArgs} ${compression} ::${name} ${locations} ${cmdPostexec} ''; -in -{ +in { config = lib.mkIf (config.mailserver.enable && cfg.enable) { environment.systemPackages = with pkgs; [ borgbackup diff --git a/mail-server/clamav.nix b/mail-server/clamav.nix index 0dafd4f..25418f0 100644 --- a/mail-server/clamav.nix +++ b/mail-server/clamav.nix @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ config, lib, ... }: +{ config, pkgs, lib, options, ... }: let cfg = config.mailserver; diff --git a/mail-server/common.nix b/mail-server/common.nix index cb044b6..236530b 100644 --- a/mail-server/common.nix +++ b/mail-server/common.nix @@ -14,76 +14,56 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, -}: +{ config, pkgs, lib }: let cfg = config.mailserver; in { # cert :: PATH - certificatePath = - if cfg.certificateScheme == "manual" then - cfg.certificateFile - else if cfg.certificateScheme == "selfsigned" then - "${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem" - else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" then - "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/fullchain.pem" - else - throw "unknown certificate scheme"; + certificatePath = if cfg.certificateScheme == "manual" + then cfg.certificateFile + else if cfg.certificateScheme == "selfsigned" + then "${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem" + else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" + then "${config.security.acme.certs.${cfg.fqdn}.directory}/fullchain.pem" + else throw "unknown certificate scheme"; # key :: PATH - keyPath = - if cfg.certificateScheme == "manual" then - cfg.keyFile - else if cfg.certificateScheme == "selfsigned" then - "${cfg.certificateDirectory}/key-${cfg.fqdn}.pem" - else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" then - "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/key.pem" - else - throw "unknown certificate scheme"; + keyPath = if cfg.certificateScheme == "manual" + then cfg.keyFile + else if cfg.certificateScheme == "selfsigned" + then "${cfg.certificateDirectory}/key-${cfg.fqdn}.pem" + else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" + then "${config.security.acme.certs.${cfg.fqdn}.directory}/key.pem" + else throw "unknown certificate scheme"; - passwordFiles = - let - mkHashFile = name: hash: pkgs.writeText "${builtins.hashString "sha256" name}-password-hash" hash; - in - lib.mapAttrs ( - name: value: - if value.hashedPasswordFile == null then - builtins.toString (mkHashFile name value.hashedPassword) - else - value.hashedPasswordFile - ) cfg.loginAccounts; + passwordFiles = let + mkHashFile = name: hash: pkgs.writeText "${builtins.hashString "sha256" name}-password-hash" hash; + in + lib.mapAttrs (name: value: + if value.hashedPasswordFile == null then + builtins.toString (mkHashFile name value.hashedPassword) + else value.hashedPasswordFile) cfg.loginAccounts; # Appends the LDAP bind password to files to avoid writing this # password into the Nix store. - appendLdapBindPwd = - { - name, - file, - prefix, - suffix ? "", - passwordFile, - destination, - }: - pkgs.writeScript "append-ldap-bind-pwd-in-${name}" '' - #!${pkgs.stdenv.shell} - set -euo pipefail + appendLdapBindPwd = { + name, file, prefix, passwordFile, destination + }: pkgs.writeScript "append-ldap-bind-pwd-in-${name}" '' + #!${pkgs.stdenv.shell} + set -euo pipefail - baseDir=$(dirname ${destination}) - if (! test -d "$baseDir"); then - mkdir -p $baseDir - chmod 755 $baseDir - fi + baseDir=$(dirname ${destination}) + if (! test -d "$baseDir"); then + mkdir -p $baseDir + chmod 755 $baseDir + fi - cat ${file} > ${destination} - echo -n '${prefix}' >> ${destination} - cat ${passwordFile} | tr -d '\n' >> ${destination} - echo -n '${suffix}' >> ${destination} - chmod 600 ${destination} - ''; + cat ${file} > ${destination} + echo -n "${prefix}" >> ${destination} + cat ${passwordFile} >> ${destination} + chmod 600 ${destination} + ''; } diff --git a/mail-server/debug.nix b/mail-server/debug.nix new file mode 100644 index 0000000..8107515 --- /dev/null +++ b/mail-server/debug.nix @@ -0,0 +1,4 @@ +{ config, lib, ... }: +{ + mailserver.policydSPFExtraConfig = lib.mkIf config.mailserver.debug "debugLevel = 4"; +} diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index d2da51b..c9f4ca7 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -14,12 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: +{ config, pkgs, lib, ... }: with (import ./common.nix { inherit config pkgs lib; }); @@ -31,26 +26,39 @@ let userdbFile = "${passwdDir}/userdb"; # This file contains the ldap bind password ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext"; - boolToYesNo = x: if x then "yes" else "no"; - listToLine = lib.concatStringsSep " "; - listToMultiAttrs = - keyPrefix: attrs: - lib.listToAttrs ( - lib.imap1 (n: x: { - name = "${keyPrefix}${if n == 1 then "" else toString n}"; - value = x; - }) attrs - ); + bool2int = x: if x then "1" else "0"; maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs"; - maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8"; # maildir in format "/${domain}/${user}" dovecotMaildir = - "maildir:${cfg.mailDirectory}/%{domain}/%{username}${maildirLayoutAppendix}${maildirUTF8FolderNames}" - + (lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/%{domain}/%{username}"); + "maildir:${cfg.mailDirectory}/%d/%n${maildirLayoutAppendix}" + + (lib.optionalString (cfg.indexDir != null) + ":INDEX=${cfg.indexDir}/%d/%n" + ); postfixCfg = config.services.postfix; + dovecot2Cfg = config.services.dovecot2; + + stateDir = "/var/lib/dovecot"; + + pipeBin = pkgs.stdenv.mkDerivation { + name = "pipe_bin"; + src = ./dovecot/pipe_bin; + buildInputs = with pkgs; [ makeWrapper coreutils bash rspamd ]; + buildCommand = '' + mkdir -p $out/pipe/bin + cp $src/* $out/pipe/bin/ + chmod a+x $out/pipe/bin/* + patchShebangs $out/pipe/bin + + for file in $out/pipe/bin/*; do + wrapProgram $file \ + --set PATH "${pkgs.coreutils}/bin:${pkgs.rspamd}/bin" + done + ''; + }; + ldapConfig = pkgs.writeTextFile { name = "dovecot-ldap.conf.ext.template"; @@ -58,7 +66,7 @@ let ldap_version = 3 uris = ${lib.concatStringsSep " " cfg.ldap.uris} ${lib.optionalString cfg.ldap.startTls '' - tls = yes + tls = yes ''} tls_require_cert = hard tls_ca_cert_file = ${cfg.ldap.tlsCAFile} @@ -67,12 +75,12 @@ let auth_bind = yes base = ${cfg.ldap.searchBase} scope = ${mkLdapSearchScope cfg.ldap.searchScope} - ${lib.optionalString (cfg.ldap.dovecot.userAttrs != null) '' - user_attrs = ${cfg.ldap.dovecot.userAttrs} + ${lib.optionalString (cfg.ldap.dovecot.userAttrs != "") '' + user_attrs = ${cfg.ldap.dovecot.userAttrs} ''} user_filter = ${cfg.ldap.dovecot.userFilter} ${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") '' - pass_attrs = ${cfg.ldap.dovecot.passAttrs} + pass_attrs = ${cfg.ldap.dovecot.passAttrs} ''} pass_filter = ${cfg.ldap.dovecot.passFilter} ''; @@ -81,8 +89,7 @@ let setPwdInLdapConfFile = appendLdapBindPwd { name = "ldap-conf-file"; file = ldapConfig; - prefix = ''dnpass = "''; - suffix = ''"''; + prefix = "dnpass = "; passwordFile = cfg.ldap.bind.passwordFile; destination = ldapConfFile; }; @@ -97,12 +104,7 @@ let chmod 755 "${passwdDir}" fi - # Prevent world-readable password files, even temporarily. - umask 077 - - for f in ${ - builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.loginAccounts) - }; do + for f in ${builtins.toString (lib.mapAttrsToList (name: value: passwordFiles."${name}") cfg.loginAccounts)}; do if [ ! -f "$f" ]; then echo "Expected password hash file $f does not exist!" exit 1 @@ -110,60 +112,38 @@ let done cat < ${passwdFile} - ${lib.concatStringsSep "\n" ( - lib.mapAttrsToList ( - name: _: "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::" - ) cfg.loginAccounts - )} + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: + "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::" + ) cfg.loginAccounts)} EOF cat < ${userdbFile} - ${lib.concatStringsSep "\n" ( - lib.mapAttrsToList ( - name: value: - "${name}:::::::" - + lib.optionalString (value.quota != null) "userdb_quota_rule=*:storage=${value.quota}" - ) cfg.loginAccounts - )} + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: + "${name}:::::::" + + (if lib.isString value.quota + then "userdb_quota_rule=*:storage=${value.quota}" + else "") + ) cfg.loginAccounts)} EOF + + chmod 600 ${passwdFile} + chmod 600 ${userdbFile} ''; - junkMailboxes = builtins.attrNames ( - lib.filterAttrs (_: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes - ); + junkMailboxes = builtins.attrNames (lib.filterAttrs (n: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes); junkMailboxNumber = builtins.length junkMailboxes; # The assertion garantees there is exactly one Junk mailbox. junkMailboxName = if junkMailboxNumber == 1 then builtins.elemAt junkMailboxes 0 else ""; - mkLdapSearchScope = - scope: - ( - if scope == "sub" then - "subtree" - else if scope == "one" then - "onelevel" - else - scope - ); - - ftsPluginSettings = { - fts = "flatcurve"; - fts_languages = listToLine cfg.fullTextSearch.languages; - fts_tokenizers = listToLine [ - "generic" - "email-address" - ]; - fts_tokenizer_email_address = "maxlen=100"; # default 254 too large for Xapian - fts_flatcurve_substring_search = boolToYesNo cfg.fullTextSearch.substringSearch; - fts_filters = listToLine cfg.fullTextSearch.filters; - fts_header_excludes = listToLine cfg.fullTextSearch.headerExcludes; - fts_autoindex = boolToYesNo cfg.fullTextSearch.autoIndex; - fts_enforced = cfg.fullTextSearch.enforced; - } // (listToMultiAttrs "fts_autoindex_exclude" cfg.fullTextSearch.autoIndexExclude); + mkLdapSearchScope = scope: ( + if scope == "sub" then "subtree" + else if scope == "one" then "onelevel" + else scope + ); in { - config = lib.mkIf cfg.enable { + config = with cfg; lib.mkIf enable { assertions = [ { assertion = junkMailboxNumber == 1; @@ -171,62 +151,24 @@ in } ]; - 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 = cfg.enableImap || cfg.enableImapSsl; - enablePop3 = cfg.enablePop3 || cfg.enablePop3Ssl; + enableImap = enableImap || enableImapSsl; + enablePop3 = enablePop3 || enablePop3Ssl; enablePAM = false; enableQuota = true; - mailGroup = cfg.vmailGroupName; - mailUser = cfg.vmailUserName; + 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" - ]; + modules = [ pkgs.dovecot_pigeonhole ] ++ (lib.optional cfg.fullTextSearch.enable pkgs.dovecot_fts_xapian ); + mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [ "fts" "fts_xapian" ]; protocols = lib.optional cfg.enableManageSieve "sieve"; - pluginSettings = { - 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); - - sieve = { - extensions = [ - "fileinto" - ]; - - scripts.after = builtins.toFile "spam.sieve" '' + sieveScripts = { + after = builtins.toFile "spam.sieve" '' require "fileinto"; if header :is "X-Spam" "Yes" { @@ -234,35 +176,13 @@ in 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; - } - ]; - mailboxes = cfg.mailboxes; extraConfig = '' #Extra Config - ${lib.optionalString cfg.debug '' + ${lib.optionalString debug '' mail_debug = yes auth_debug = yes verbose_ssl = yes @@ -271,62 +191,42 @@ in ${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 - '' - } + ${if cfg.enableImap then '' + port = 143 + '' else '' + # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html + port = 0 + ''} } inet_listener imaps { - ${ - if cfg.enableImapSsl then - '' - port = 993 - ssl = yes - '' - else - '' - # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html - port = 0 - '' - } + ${if 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 - '' - } + ${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 - '' - } + ${if cfg.enablePop3Ssl then '' + port = 995 + ssl = yes + '' else '' + # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html + port = 0 + ''} } } ''} @@ -336,21 +236,14 @@ in 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 = ${cfg.vmailGroupName} - - # https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7 + mail_access_groups = ${vmailGroupName} ssl = required ssl_min_protocol = TLSv1.2 - ssl_prefer_server_ciphers = no - ssl_curve_list = X25519:prime256v1:secp384r1 + ssl_prefer_server_ciphers = yes service lmtp { unix_listener dovecot-lmtp { @@ -358,17 +251,6 @@ in 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} @@ -390,16 +272,16 @@ in } ${lib.optionalString cfg.ldap.enable '' - passdb { - driver = ldap - args = ${ldapConfFile} - } + 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} - } + userdb { + driver = ldap + args = ${ldapConfFile} + default_fields = home=/var/vmail/ldap/%u uid=${toString cfg.vmailUID} gid=${toString cfg.vmailUID} + } ''} service auth { @@ -417,27 +299,90 @@ in inbox = yes } - service indexer-worker { - ${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) '' - vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit * 1024 * 1024)} - ''} + plugin { + sieve_plugins = sieve_imapsieve sieve_extprograms + sieve = file:${cfg.sieveDirectory}/%u/scripts;active=${cfg.sieveDirectory}/%u/active.sieve + sieve_default = file:${cfg.sieveDirectory}/%u/default.sieve + sieve_default_name = default + + # From elsewhere to Spam folder + imapsieve_mailbox1_name = ${junkMailboxName} + imapsieve_mailbox1_causes = COPY,APPEND + imapsieve_mailbox1_before = file:${stateDir}/imap_sieve/report-spam.sieve + + # From Spam folder to elsewhere + imapsieve_mailbox2_name = * + imapsieve_mailbox2_from = ${junkMailboxName} + imapsieve_mailbox2_causes = COPY + imapsieve_mailbox2_before = file:${stateDir}/imap_sieve/report-ham.sieve + + sieve_pipe_bin_dir = ${pipeBin}/pipe/bin + + sieve_global_extensions = +vnd.dovecot.pipe +vnd.dovecot.environment } + ${lib.optionalString cfg.fullTextSearch.enable '' + plugin { + plugin = fts fts_xapian + fts = xapian + fts_xapian = partial=${toString cfg.fullTextSearch.minSize} full=${toString cfg.fullTextSearch.maxSize} attachments=${bool2int cfg.fullTextSearch.indexAttachments} verbose=${bool2int cfg.debug} + + fts_autoindex = ${if cfg.fullTextSearch.autoIndex then "yes" else "no"} + + ${lib.strings.concatImapStringsSep "\n" (n: x: "fts_autoindex_exclude${if n==1 then "" else toString n} = ${x}") cfg.fullTextSearch.autoIndexExclude} + + fts_enforced = ${cfg.fullTextSearch.enforced} + } + + ${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) '' + service indexer-worker { + vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit*1024*1024)} + } + ''} + ''} + lda_mailbox_autosubscribe = yes lda_mailbox_autocreate = yes ''; }; systemd.services.dovecot2 = { - preStart = - '' - ${genPasswdScript} - '' - + (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile); + preStart = '' + ${genPasswdScript} + rm -rf '${stateDir}/imap_sieve' + mkdir '${stateDir}/imap_sieve' + cp -p "${./dovecot/imap_sieve}"/*.sieve '${stateDir}/imap_sieve/' + for k in "${stateDir}/imap_sieve"/*.sieve ; do + ${pkgs.dovecot_pigeonhole}/bin/sievec "$k" + done + chown -R '${dovecot2Cfg.mailUser}:${dovecot2Cfg.mailGroup}' '${stateDir}/imap_sieve' + '' + (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile); }; - systemd.services.postfix.restartTriggers = [ - genPasswdScript - ] ++ (lib.optional cfg.ldap.enable [ setPwdInLdapConfFile ]); + systemd.services.postfix.restartTriggers = [ genPasswdScript ] ++ (lib.optional cfg.ldap.enable [setPwdInLdapConfFile]); + + systemd.services.dovecot-fts-xapian-optimize = lib.mkIf (cfg.fullTextSearch.enable && cfg.fullTextSearch.maintenance.enable) { + description = "Optimize dovecot indices for fts_xapian"; + requisite = [ "dovecot2.service" ]; + after = [ "dovecot2.service" ]; + startAt = cfg.fullTextSearch.maintenance.onCalendar; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.dovecot}/bin/doveadm fts optimize -A"; + PrivateDevices = true; + PrivateNetwork = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectSystem = true; + PrivateTmp = true; + }; + }; + systemd.timers.dovecot-fts-xapian-optimize = lib.mkIf (cfg.fullTextSearch.enable && cfg.fullTextSearch.maintenance.enable && cfg.fullTextSearch.maintenance.randomizedDelaySec != 0) { + timerConfig = { + RandomizedDelaySec = cfg.fullTextSearch.maintenance.randomizedDelaySec; + }; + }; }; } diff --git a/mail-server/dovecot/imap_sieve/report-ham.sieve b/mail-server/dovecot/imap_sieve/report-ham.sieve index 720be7a..a9d30cf 100644 --- a/mail-server/dovecot/imap_sieve/report-ham.sieve +++ b/mail-server/dovecot/imap_sieve/report-ham.sieve @@ -12,4 +12,4 @@ if environment :matches "imap.user" "*" { set "username" "${1}"; } -pipe :copy "rspamd-learn-ham.sh" [ "${username}" ]; +pipe :copy "sa-learn-ham.sh" [ "${username}" ]; diff --git a/mail-server/dovecot/imap_sieve/report-spam.sieve b/mail-server/dovecot/imap_sieve/report-spam.sieve index 4681aac..4024b7a 100644 --- a/mail-server/dovecot/imap_sieve/report-spam.sieve +++ b/mail-server/dovecot/imap_sieve/report-spam.sieve @@ -4,4 +4,4 @@ if environment :matches "imap.user" "*" { set "username" "${1}"; } -pipe :copy "rspamd-learn-spam.sh" [ "${username}" ]; +pipe :copy "sa-learn-spam.sh" [ "${username}" ]; \ No newline at end of file diff --git a/mail-server/dovecot/pipe_bin/sa-learn-ham.sh b/mail-server/dovecot/pipe_bin/sa-learn-ham.sh new file mode 100755 index 0000000..76fc4ed --- /dev/null +++ b/mail-server/dovecot/pipe_bin/sa-learn-ham.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -o errexit +exec rspamc -h /run/rspamd/worker-controller.sock learn_ham \ No newline at end of file diff --git a/mail-server/dovecot/pipe_bin/sa-learn-spam.sh b/mail-server/dovecot/pipe_bin/sa-learn-spam.sh new file mode 100755 index 0000000..2a2f766 --- /dev/null +++ b/mail-server/dovecot/pipe_bin/sa-learn-spam.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -o errexit +exec rspamc -h /run/rspamd/worker-controller.sock learn_spam \ No newline at end of file diff --git a/mail-server/environment.nix b/mail-server/environment.nix index 462cb05..e509ea6 100644 --- a/mail-server/environment.nix +++ b/mail-server/environment.nix @@ -14,26 +14,15 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: +{ config, pkgs, lib, ... }: let cfg = config.mailserver; in { - config = lib.mkIf cfg.enable { - environment.systemPackages = - with pkgs; - [ - dovecot - openssh - postfix - rspamd - ] - ++ (if cfg.certificateScheme == "selfsigned" then [ openssl ] else [ ]); + config = with cfg; lib.mkIf enable { + environment.systemPackages = with pkgs; [ + dovecot opendkim openssh postfix rspamd + ] ++ (if certificateScheme == "selfsigned" then [ openssl ] else []); }; } diff --git a/mail-server/kresd.nix b/mail-server/kresd.nix index 3920534..e3baa07 100644 --- a/mail-server/kresd.nix +++ b/mail-server/kresd.nix @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ config, lib, ... }: +{ config, pkgs, lib, ... }: let cfg = config.mailserver; @@ -24,3 +24,4 @@ in services.kresd.enable = true; }; } + diff --git a/mail-server/monit.nix b/mail-server/monit.nix index c3f8760..c69b19e 100644 --- a/mail-server/monit.nix +++ b/mail-server/monit.nix @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ config, lib, ... }: +{ config, pkgs, lib, ... }: let cfg = config.mailserver; diff --git a/mail-server/networking.nix b/mail-server/networking.nix index f560ec0..6af186a 100644 --- a/mail-server/networking.nix +++ b/mail-server/networking.nix @@ -20,19 +20,18 @@ let cfg = config.mailserver; in { - config = lib.mkIf (cfg.enable && cfg.openFirewall) { + config = with cfg; lib.mkIf (enable && openFirewall) { networking.firewall = { - allowedTCPPorts = - [ 25 ] - ++ lib.optional cfg.enableSubmission 587 - ++ lib.optional cfg.enableSubmissionSsl 465 - ++ lib.optional cfg.enableImap 143 - ++ lib.optional cfg.enableImapSsl 993 - ++ lib.optional cfg.enablePop3 110 - ++ lib.optional cfg.enablePop3Ssl 995 - ++ lib.optional cfg.enableManageSieve 4190 - ++ lib.optional (cfg.certificateScheme == "acme-nginx") 80; + allowedTCPPorts = [ 25 ] + ++ lib.optional enableSubmission 587 + ++ lib.optional enableSubmissionSsl 465 + ++ lib.optional enableImap 143 + ++ lib.optional enableImapSsl 993 + ++ lib.optional enablePop3 110 + ++ lib.optional enablePop3Ssl 995 + ++ lib.optional enableManageSieve 4190 + ++ lib.optional (certificateScheme == "acme-nginx") 80; }; }; } diff --git a/mail-server/nginx.nix b/mail-server/nginx.nix index 27de2fe..e5fa597 100644 --- a/mail-server/nginx.nix +++ b/mail-server/nginx.nix @@ -14,35 +14,31 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: -with (import ./common.nix { inherit config lib pkgs; }); +{ config, pkgs, lib, ... }: + +with (import ./common.nix { inherit config; }); let cfg = config.mailserver; + acmeRoot = "/var/lib/acme/acme-challenge"; in { - config = - lib.mkIf (cfg.enable && (cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx")) - { - services.nginx = lib.mkIf (cfg.certificateScheme == "acme-nginx") { - enable = true; - virtualHosts."${cfg.fqdn}" = { - serverName = cfg.fqdn; - serverAliases = cfg.certificateDomains; - forceSSL = true; - enableACME = true; - }; - }; - - security.acme.certs."${cfg.acmeCertificateName}".reloadServices = [ - "postfix.service" - "dovecot2.service" - ]; + config = lib.mkIf (cfg.enable && (cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx")) { + services.nginx = lib.mkIf (cfg.certificateScheme == "acme-nginx") { + enable = true; + virtualHosts."${cfg.fqdn}" = { + serverName = cfg.fqdn; + serverAliases = cfg.certificateDomains; + forceSSL = true; + enableACME = true; + acmeRoot = acmeRoot; }; + }; + + security.acme.certs."${cfg.fqdn}".reloadServices = [ + "postfix.service" + "dovecot2.service" + ]; + }; } diff --git a/mail-server/opendkim.nix b/mail-server/opendkim.nix new file mode 100644 index 0000000..cdb283c --- /dev/null +++ b/mail-server/opendkim.nix @@ -0,0 +1,89 @@ +# nixos-mailserver: a simple mail server +# Copyright (C) 2017 Brian Olsen +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.mailserver; + + dkimUser = config.services.opendkim.user; + dkimGroup = config.services.opendkim.group; + + createDomainDkimCert = dom: + let + dkim_key = "${cfg.dkimKeyDirectory}/${dom}.${cfg.dkimSelector}.key"; + dkim_txt = "${cfg.dkimKeyDirectory}/${dom}.${cfg.dkimSelector}.txt"; + in + '' + if [ ! -f "${dkim_key}" ] + then + ${pkgs.opendkim}/bin/opendkim-genkey -s "${cfg.dkimSelector}" \ + -d "${dom}" \ + --bits="${toString cfg.dkimKeyBits}" \ + --directory="${cfg.dkimKeyDirectory}" + mv "${cfg.dkimKeyDirectory}/${cfg.dkimSelector}.private" "${dkim_key}" + mv "${cfg.dkimKeyDirectory}/${cfg.dkimSelector}.txt" "${dkim_txt}" + chmod 644 "${dkim_txt}" + echo "Generated key for domain ${dom} selector ${cfg.dkimSelector}" + fi + ''; + createAllCerts = lib.concatStringsSep "\n" (map createDomainDkimCert cfg.domains); + + keyTable = pkgs.writeText "opendkim-KeyTable" + (lib.concatStringsSep "\n" (lib.flip map cfg.domains + (dom: "${dom} ${dom}:${cfg.dkimSelector}:${cfg.dkimKeyDirectory}/${dom}.${cfg.dkimSelector}.key"))); + signingTable = pkgs.writeText "opendkim-SigningTable" + (lib.concatStringsSep "\n" (lib.flip map cfg.domains (dom: "${dom} ${dom}"))); + + dkim = config.services.opendkim; + args = [ "-f" "-l" ] ++ lib.optionals (dkim.configFile != null) [ "-x" dkim.configFile ]; +in +{ + config = mkIf (cfg.dkimSigning && cfg.enable) { + services.opendkim = { + enable = true; + selector = cfg.dkimSelector; + keyPath = cfg.dkimKeyDirectory; + domains = "csl:${builtins.concatStringsSep "," cfg.domains}"; + configFile = pkgs.writeText "opendkim.conf" ('' + Canonicalization ${cfg.dkimHeaderCanonicalization}/${cfg.dkimBodyCanonicalization} + UMask 0002 + Socket ${dkim.socket} + KeyTable file:${keyTable} + SigningTable file:${signingTable} + '' + (lib.optionalString cfg.debug '' + Syslog yes + SyslogSuccess yes + LogWhy yes + '')); + }; + + users.users = optionalAttrs (config.services.postfix.user == "postfix") { + postfix.extraGroups = [ "${dkimGroup}" ]; + }; + systemd.services.opendkim = { + preStart = lib.mkForce createAllCerts; + serviceConfig = { + ExecStart = lib.mkForce "${pkgs.opendkim}/bin/opendkim ${escapeShellArgs args}"; + PermissionsStartOnly = lib.mkForce false; + }; + }; + systemd.tmpfiles.rules = [ + "d '${cfg.dkimKeyDirectory}' - ${dkimUser} ${dkimGroup} - -" + ]; + }; +} diff --git a/mail-server/post-upgrade-check.nix b/mail-server/post-upgrade-check.nix new file mode 100644 index 0000000..9b418b2 --- /dev/null +++ b/mail-server/post-upgrade-check.nix @@ -0,0 +1,46 @@ +# 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, ... }: + +with lib; + +let + cfg = config.mailserver; +in +{ + config = mkIf (cfg.enable && cfg.rebootAfterKernelUpgrade.enable) { + systemd.services.nixos-upgrade.serviceConfig.ExecStartPost = pkgs.writeScript "post-upgrade-check" '' + #!${pkgs.stdenv.shell} + + # Checks whether the "current" kernel is different from the booted kernel + # and then triggers a reboot so that the "current" kernel will be the booted one. + # This is just an educated guess. If the links do not differ the kernels might still be different, according to spacefrogg in #nixos. + + current=$(readlink -f /run/current-system/kernel) + booted=$(readlink -f /run/booted-system/kernel) + + if [ "$current" == "$booted" ]; then + echo "kernel version seems unchanged, skipping reboot" | systemd-cat --priority 4 --identifier "post-upgrade-check"; + else + echo "kernel path changed, possibly a new version" | systemd-cat --priority 2 --identifier "post-upgrade-check" + echo "$booted" | systemd-cat --priority 2 --identifier "post-upgrade-kernel-check" + echo "$current" | systemd-cat --priority 2 --identifier "post-upgrade-kernel-check" + ${cfg.rebootAfterKernelUpgrade.method} + fi + ''; + }; +} diff --git a/mail-server/postfix.nix b/mail-server/postfix.nix index e29983a..ad7ce35 100644 --- a/mail-server/postfix.nix +++ b/mail-server/postfix.nix @@ -14,12 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: +{ config, pkgs, lib, ... }: with (import ./common.nix { inherit config pkgs lib; }); @@ -30,58 +25,29 @@ let # Merge several lookup tables. A lookup table is a attribute set where # - the key is an address (user@example.com) or a domain (@example.com) # - the value is a list of addresses - mergeLookupTables = tables: lib.zipAttrsWith (_: v: lib.flatten v) tables; + mergeLookupTables = tables: lib.zipAttrsWith (n: v: lib.flatten v) tables; # valiases_postfix :: Map String [String] - valiases_postfix = mergeLookupTables ( - lib.flatten ( - lib.mapAttrsToList ( - name: value: - let - to = name; - in - map (from: { "${from}" = to; }) (value.aliases ++ lib.singleton name) - ) cfg.loginAccounts - ) - ); - regex_valiases_postfix = mergeLookupTables ( - lib.flatten ( - lib.mapAttrsToList ( - name: value: - let - to = name; - in - map (from: { "${from}" = to; }) value.aliasesRegexp - ) cfg.loginAccounts - ) - ); + valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList + (name: value: + let to = name; + in map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name)) + cfg.loginAccounts)); # catchAllPostfix :: Map String [String] - catchAllPostfix = mergeLookupTables ( - lib.flatten ( - lib.mapAttrsToList ( - name: value: - let - to = name; - in - map (from: { "@${from}" = to; }) value.catchAll - ) cfg.loginAccounts - ) - ); + catchAllPostfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList + (name: value: + let to = name; + in map (from: {"@${from}" = to;}) value.catchAll) + cfg.loginAccounts)); # all_valiases_postfix :: Map String [String] - all_valiases_postfix = mergeLookupTables [ - valiases_postfix - extra_valiases_postfix - ]; + all_valiases_postfix = mergeLookupTables [valiases_postfix extra_valiases_postfix]; # attrsToLookupTable :: Map String (Either String [ String ]) -> Map String [String] - attrsToLookupTable = - aliases: - let - lookupTables = lib.mapAttrsToList (from: to: { "${from}" = to; }) aliases; - in - mergeLookupTables lookupTables; + attrsToLookupTable = aliases: let + lookupTables = lib.mapAttrsToList (from: to: {"${from}" = to;}) aliases; + in mergeLookupTables lookupTables; # extra_valiases_postfix :: Map String [String] extra_valiases_postfix = attrsToLookupTable cfg.extraVirtualAliases; @@ -90,49 +56,33 @@ let forwards = attrsToLookupTable cfg.forwards; # lookupTableToString :: Map String [String] -> String - lookupTableToString = - attrs: - let - valueToString = value: lib.concatStringsSep ", " value; - in - lib.concatStringsSep "\n" ( - lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs - ); + lookupTableToString = attrs: let + valueToString = value: lib.concatStringsSep ", " value; + in lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs); # valiases_file :: Path - valiases_file = - let - content = lookupTableToString (mergeLookupTables [ - all_valiases_postfix - catchAllPostfix - ]); - in - builtins.toFile "valias" content; - - regex_valiases_file = - let - content = lookupTableToString regex_valiases_postfix; - in - builtins.toFile "regex_valias" content; + valiases_file = let + content = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix]); + in builtins.toFile "valias" content; # denied_recipients_postfix :: [ String ] - denied_recipients_postfix = map (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") ( - lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts) - ); - denied_recipients_file = builtins.toFile "denied_recipients" ( - lib.concatStringsSep "\n" denied_recipients_postfix - ); + denied_recipients_postfix = (map + (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") + (lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts))); + denied_recipients_file = builtins.toFile "denied_recipients" (lib.concatStringsSep "\n" denied_recipients_postfix); - reject_senders_postfix = map (sender: "${sender} REJECT") cfg.rejectSender; - reject_senders_file = builtins.toFile "reject_senders" ( - lib.concatStringsSep "\n" reject_senders_postfix - ); + reject_senders_postfix = (map + (sender: + "${sender} REJECT") + (cfg.rejectSender)); + reject_senders_file = builtins.toFile "reject_senders" (lib.concatStringsSep "\n" (reject_senders_postfix)) ; - reject_recipients_postfix = map (recipient: "${recipient} REJECT") cfg.rejectRecipients; + reject_recipients_postfix = (map + (recipient: + "${recipient} REJECT") + (cfg.rejectRecipients)); # rejectRecipients :: [ Path ] - reject_recipients_file = builtins.toFile "reject_recipients" ( - lib.concatStringsSep "\n" reject_recipients_postfix - ); + reject_recipients_file = builtins.toFile "reject_recipients" (lib.concatStringsSep "\n" (reject_recipients_postfix)) ; # vhosts_file :: Path vhosts_file = builtins.toFile "vhosts" (concatStringsSep "\n" cfg.domains); @@ -144,51 +94,50 @@ let # every alias is owned (uniquely) by its user. # The user's own address is already in all_valiases_postfix. vaccounts_file = builtins.toFile "vaccounts" (lookupTableToString all_valiases_postfix); - regex_vaccounts_file = builtins.toFile "regex_vaccounts" ( - lookupTableToString regex_valiases_postfix - ); - submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" ( - '' - # Removes sensitive headers from mails handed in via the submission port. - # See https://thomas-leister.de/mailserver-debian-stretch/ - # Uses "pcre" style regex. + submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" ('' + # Removes sensitive headers from mails handed in via the submission port. + # See https://thomas-leister.de/mailserver-debian-stretch/ + # Uses "pcre" style regex. - /^Received:/ IGNORE - /^X-Originating-IP:/ IGNORE - /^X-Mailer:/ IGNORE - /^User-Agent:/ IGNORE - /^X-Enigmail:/ IGNORE - '' - + lib.optionalString cfg.rewriteMessageId '' + /^Received:/ IGNORE + /^X-Originating-IP:/ IGNORE + /^X-Mailer:/ IGNORE + /^User-Agent:/ IGNORE + /^X-Enigmail:/ IGNORE + '' + lib.optionalString cfg.rewriteMessageId '' - # Replaces the user submitted hostname with the server's FQDN to hide the - # user's host or network. + # Replaces the user submitted hostname with the server's FQDN to hide the + # user's host or network. - /^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}> - '' - ); + /^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}> + ''); - smtpdMilters = [ "unix:/run/rspamd/rspamd-milter.sock" ]; + inetSocket = addr: port: "inet:[${toString port}@${addr}]"; + unixSocket = sock: "unix:${sock}"; + + smtpdMilters = + (lib.optional cfg.dkimSigning "unix:/run/opendkim/opendkim.sock") + ++ [ "unix:/run/rspamd/rspamd-milter.sock" ]; + + policyd-spf = pkgs.writeText "policyd-spf.conf" cfg.policydSPFExtraConfig; mappedFile = name: "hash:/var/lib/postfix/conf/${name}"; - mappedRegexFile = name: "pcre:/var/lib/postfix/conf/${name}"; - submissionOptions = { - smtpd_tls_security_level = "encrypt"; - smtpd_sasl_auth_enable = "yes"; - smtpd_sasl_type = "dovecot"; - smtpd_sasl_path = "/run/dovecot2/auth"; - smtpd_sasl_security_options = "noanonymous"; - smtpd_sasl_local_domain = "$myhostname"; - smtpd_client_restrictions = "permit_sasl_authenticated,reject"; - smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}${ - lib.optionalString (regex_valiases_postfix != { }) ",pcre:/etc/postfix/regex_vaccounts" - }"; - smtpd_sender_restrictions = "reject_sender_login_mismatch"; - smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject"; - cleanup_service_name = "submission-header-cleanup"; - }; + submissionOptions = + { + smtpd_tls_security_level = "encrypt"; + smtpd_sasl_auth_enable = "yes"; + smtpd_sasl_type = "dovecot"; + smtpd_sasl_path = "/run/dovecot2/auth"; + smtpd_sasl_security_options = "noanonymous"; + smtpd_sasl_local_domain = "$myhostname"; + smtpd_client_restrictions = "permit_sasl_authenticated,reject"; + smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}"; + smtpd_sender_restrictions = "reject_sender_login_mismatch"; + smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject"; + cleanup_service_name = "submission-header-cleanup"; + }; commonLdapConfig = '' server_host = ${lib.concatStringsSep " " cfg.ldap.uris} @@ -233,66 +182,50 @@ let }; in { - config = lib.mkIf cfg.enable { + config = with cfg; lib.mkIf enable { systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable { preStart = '' ${appendPwdInVirtualMailboxMap} ${appendPwdInSenderLoginMap} ''; - restartTriggers = [ - appendPwdInVirtualMailboxMap - appendPwdInSenderLoginMap - ]; + restartTriggers = [ appendPwdInVirtualMailboxMap appendPwdInSenderLoginMap ]; }; services.postfix = { enable = true; + hostname = "${sendingFqdn}"; + networksStyle = "host"; mapFiles."valias" = valiases_file; - mapFiles."regex_valias" = regex_valiases_file; mapFiles."vaccounts" = vaccounts_file; - mapFiles."regex_vaccounts" = regex_vaccounts_file; mapFiles."denied_recipients" = denied_recipients_file; mapFiles."reject_senders" = reject_senders_file; mapFiles."reject_recipients" = reject_recipients_file; + sslCert = certificatePath; + sslKey = keyPath; enableSubmission = cfg.enableSubmission; enableSubmissions = cfg.enableSubmissionSsl; - virtual = lookupTableToString (mergeLookupTables [ - all_valiases_postfix - catchAllPostfix - forwards - ]); + virtual = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix forwards]); config = { - myhostname = cfg.sendingFqdn; - mydestination = ""; # disable local mail delivery + # Extra Config + mydestination = ""; recipient_delimiter = cfg.recipientDelimiter; - smtpd_banner = "${cfg.fqdn} ESMTP NO UCE"; + smtpd_banner = "${fqdn} ESMTP NO UCE"; disable_vrfy_command = true; message_size_limit = toString cfg.messageSizeLimit; # virtual mail system virtual_uid_maps = "static:5000"; virtual_gid_maps = "static:5000"; - virtual_mailbox_base = cfg.mailDirectory; + virtual_mailbox_base = mailDirectory; virtual_mailbox_domains = vhosts_file; - 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_mailbox_maps = [ + (mappedFile "valias") + ] ++ lib.optionals (cfg.ldap.enable) [ + "ldap:${ldapVirtualMailboxMapFile}" + ]; 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"; @@ -301,90 +234,63 @@ in smtpd_sasl_path = "/run/dovecot2/auth"; smtpd_sasl_auth_enable = true; smtpd_relay_restrictions = [ - "permit_mynetworks" - "permit_sasl_authenticated" - "reject_unauth_destination" + "permit_mynetworks" "permit_sasl_authenticated" "reject_unauth_destination" ]; + policy-spf_time_limit = "3600s"; + # reject selected senders smtpd_sender_restrictions = [ "check_sender_access ${mappedFile "reject_senders"}" ]; + # quota and spf checking smtpd_recipient_restrictions = [ - # reject selected recipients "check_recipient_access ${mappedFile "denied_recipients"}" "check_recipient_access ${mappedFile "reject_recipients"}" - # quota checking - "check_policy_service unix:/run/dovecot2/quota-status" + "check_policy_service inet:localhost:12340" + "check_policy_service unix:private/policy-spf" ]; - # The X509 private key followed by the corresponding certificate - smtpd_tls_chain_files = [ - "${keyPath}" - "${certificatePath}" - ]; - - # TLS for incoming mail is optional + # TLS settings, inspired by https://github.com/jeaye/nix-files + # Submission by mail clients is handled in submissionOptions smtpd_tls_security_level = "may"; - # But required for authentication attempts - smtpd_tls_auth_only = true; + # strong might suffice and is computationally less expensive + smtpd_tls_eecdh_grade = "ultra"; - # TLS versions supported for the SMTP server - smtpd_tls_protocols = ">=TLSv1.2"; - smtpd_tls_mandatory_protocols = ">=TLSv1.2"; + # Disable obselete protocols + smtpd_tls_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3"; + smtp_tls_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3"; + smtpd_tls_mandatory_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3"; + smtp_tls_mandatory_protocols = "TLSv1.3, TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3"; - # Require ciphersuites that OpenSSL classifies as "High" + smtp_tls_ciphers = "high"; smtpd_tls_ciphers = "high"; + smtp_tls_mandatory_ciphers = "high"; smtpd_tls_mandatory_ciphers = "high"; - # Exclude cipher suites with undesirable properties - smtpd_tls_exclude_ciphers = "eNULL, aNULL"; - smtpd_tls_mandatory_exclude_ciphers = "eNULL, aNULL"; + # Disable deprecated ciphers + smtpd_tls_mandatory_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL"; + smtpd_tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL"; + smtp_tls_mandatory_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL"; + smtp_tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL"; - # Opportunistic DANE support when delivering mail to other servers - # https://www.postfix.org/postconf.5.html#smtp_tls_security_level - smtp_dns_support_level = "dnssec"; - smtp_tls_security_level = "dane"; - - # TLS versions supported for the SMTP client - smtp_tls_protocols = ">=TLSv1.2"; - smtp_tls_mandatory_protocols = ">=TLSv1.2"; - - # Require ciphersuites that OpenSSL classifies as "High" - smtp_tls_ciphers = "high"; - smtp_tls_mandatory_ciphers = "high"; - - # Exclude ciphersuites with undesirable properties - smtp_tls_exclude_ciphers = "eNULL, aNULL"; - smtp_tls_mandatory_exclude_ciphers = "eNULL, aNULL"; - - # Restrict and prioritize the following curves in the given order - # Excludes curves that have no widespread support, so we don't bloat the handshake needlessly. - # https://www.postfix.org/postconf.5.html#tls_eecdh_auto_curves - # 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 - # https://www.postfix.org/postconf.5.html#tls_ffdhe_auto_groups - tls_ffdhe_auto_groups = [ ]; - - # As long as all cipher suites are considered safe, let the client use its preferred cipher - tls_preempt_cipherlist = false; + tls_preempt_cipherlist = true; + # Allowing AUTH on a non encrypted connection poses a security risk + smtpd_tls_auth_only = true; # Log only a summary message on TLS handshake completion - smtp_tls_loglevel = "1"; smtpd_tls_loglevel = "1"; + # Configure a non blocking source of randomness + tls_random_source = "dev:/dev/urandom"; + smtpd_milters = smtpdMilters; - non_smtpd_milters = lib.mkIf cfg.dkimSigning [ "unix:/run/rspamd/rspamd-milter.sock" ]; + non_smtpd_milters = lib.mkIf cfg.dkimSigning ["unix:/run/opendkim/opendkim.sock"]; milter_protocol = "6"; - milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_authen}"; + milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer}"; + }; submissionOptions = submissionOptions; @@ -396,16 +302,20 @@ in # D => Delivered-To, O => X-Original-To, R => Return-Path args = [ "flags=O" ]; }; + "policy-spf" = { + type = "unix"; + privileged = true; + chroot = false; + command = "spawn"; + args = [ "user=nobody" "argv=${pkgs.pypolicyd-spf}/bin/policyd-spf" "${policyd-spf}"]; + }; "submission-header-cleanup" = { type = "unix"; private = false; chroot = false; maxproc = 0; command = "cleanup"; - args = [ - "-o" - "header_checks=pcre:${submissionHeaderCleanupRules}" - ]; + args = ["-o" "header_checks=pcre:${submissionHeaderCleanupRules}"]; }; }; }; diff --git a/mail-server/rsnapshot.nix b/mail-server/rsnapshot.nix index f01ff8d..a801c24 100644 --- a/mail-server/rsnapshot.nix +++ b/mail-server/rsnapshot.nix @@ -14,19 +14,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: +{ config, pkgs, lib, ... }: + +with lib; let - inherit (lib) - optionalString - mkIf - ; - cfg = config.mailserver; preexecDefined = cfg.backup.cmdPreexec != null; @@ -46,8 +38,7 @@ let ${cfg.backup.cmdPostexec} ''; postexecString = optionalString postexecDefined "cmd_postexec ${postexecWrapped}"; -in -{ +in { config = mkIf (cfg.enable && cfg.backup.enable) { services.rsnapshot = { enable = true; diff --git a/mail-server/rspamd.nix b/mail-server/rspamd.nix index 7ed2a0e..a506904 100644 --- a/mail-server/rspamd.nix +++ b/mail-server/rspamd.nix @@ -14,12 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: +{ config, pkgs, lib, ... }: let cfg = config.mailserver; @@ -27,121 +22,67 @@ let postfixCfg = config.services.postfix; rspamdCfg = config.services.rspamd; rspamdSocket = "rspamd.service"; - - rspamdUser = config.services.rspamd.user; - rspamdGroup = config.services.rspamd.group; - - createDkimKeypair = - domain: - let - privateKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.key"; - publicKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.txt"; - in - pkgs.writeShellScript "dkim-keygen-${domain}" '' - if [ ! -f "${privateKey}" ] - then - ${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen \ - --domain "${domain}" \ - --selector "${cfg.dkimSelector}" \ - --type "${cfg.dkimKeyType}" \ - --bits ${toString cfg.dkimKeyBits} \ - --privkey "${privateKey}" > "${publicKey}" - chmod 0644 "${publicKey}" - echo "Generated key for domain ${domain} and selector ${cfg.dkimSelector}" - fi - ''; in { - config = lib.mkIf cfg.enable { - environment.systemPackages = lib.mkBefore [ - (pkgs.runCommand "rspamc-wrapped" - { - nativeBuildInputs = with pkgs; [ makeWrapper ]; - } - '' - makeWrapper ${pkgs.rspamd}/bin/rspamc $out/bin/rspamc \ - --add-flags "-h /run/rspamd/worker-controller.sock" - '' - ) - ]; - + config = with cfg; lib.mkIf enable { services.rspamd = { enable = true; - inherit (cfg) debug; + inherit debug; locals = { - "milter_headers.conf" = { - text = '' - extended_spam_headers = true; - ''; - }; - "redis.conf" = { - text = - '' - servers = "${ - if cfg.redis.port == null then - cfg.redis.address - else - "${cfg.redis.address}:${toString cfg.redis.port}" - }"; - '' - + (lib.optionalString (cfg.redis.password != null) '' + "milter_headers.conf" = { text = '' + extended_spam_headers = yes; + ''; }; + "redis.conf" = { text = '' + servers = "${cfg.redis.address}:${toString cfg.redis.port}"; + '' + (lib.optionalString (cfg.redis.password != null) '' password = "${cfg.redis.password}"; - ''); - }; - "classifier-bayes.conf" = { - text = '' - cache { - backend = "redis"; - } - ''; - }; - "antivirus.conf" = lib.mkIf cfg.virusScanning { - text = '' - clamav { - action = "reject"; - symbol = "CLAM_VIRUS"; - type = "clamav"; - log_clean = true; - servers = "/run/clamav/clamd.ctl"; - scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all - } - ''; - }; - "dkim_signing.conf" = { - text = '' - enabled = ${lib.boolToString cfg.dkimSigning}; - path = "${cfg.dkimKeyDirectory}/$domain.$selector.key"; - selector = "${cfg.dkimSelector}"; - # Allow for usernames w/o domain part - allow_username_mismatch = true - ''; - }; - "dmarc.conf" = { - text = '' - ${lib.optionalString cfg.dmarcReporting.enable '' + ''); }; + "classifier-bayes.conf" = { text = '' + cache { + backend = "redis"; + } + ''; }; + "antivirus.conf" = lib.mkIf cfg.virusScanning { text = '' + clamav { + action = "reject"; + symbol = "CLAM_VIRUS"; + type = "clamav"; + log_clean = true; + servers = "/run/clamav/clamd.ctl"; + scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all + } + ''; }; + "dkim_signing.conf" = { text = '' + # Disable outbound email signing, we use opendkim for this + enabled = false; + ''; }; + "dmarc.conf" = { text = '' + ${lib.optionalString cfg.dmarcReporting.enable '' reporting { enabled = true; email = "${cfg.dmarcReporting.email}"; domain = "${cfg.dmarcReporting.domain}"; org_name = "${cfg.dmarcReporting.organizationName}"; from_name = "${cfg.dmarcReporting.fromName}"; - msgid_from = "${cfg.dmarcReporting.domain}"; - ${lib.optionalString (cfg.dmarcReporting.excludeDomains != [ ]) '' - exclude_domains = ${builtins.toJSON cfg.dmarcReporting.excludeDomains}; - ''} + msgid_from = "dmarc-rua"; }''} + ''; }; + }; + + overrides = { + "milter_headers.conf" = { + text = '' + extended_spam_headers = true; ''; }; }; workers.rspamd_proxy = { type = "rspamd_proxy"; - bindSockets = [ - { - socket = "/run/rspamd/rspamd-milter.sock"; - mode = "0664"; - } - ]; + bindSockets = [{ + socket = "/run/rspamd/rspamd-milter.sock"; + mode = "0664"; + }]; count = 1; # Do not spawn too many processes of this type extraConfig = '' milter = yes; # Enable milter mode @@ -156,13 +97,11 @@ in workers.controller = { type = "controller"; count = 1; - bindSockets = [ - { - socket = "/run/rspamd/worker-controller.sock"; - mode = "0666"; - } - ]; - includes = [ ]; + bindSockets = [{ + socket = "/run/rspamd/worker-controller.sock"; + mode = "0666"; + }]; + includes = []; extraConfig = '' static_dir = "''${WWWDIR}"; # Serve the web UI static assets ''; @@ -170,38 +109,17 @@ in }; - 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; - }; - }; + services.redis.servers.rspamd = { + enable = lib.mkDefault true; + port = lib.mkDefault 6380; }; 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 { + 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 @@ -212,7 +130,7 @@ in User = "${config.services.rspamd.user}"; Group = "${config.services.rspamd.group}"; - AmbientCapabilities = [ ]; + AmbientCapabilities = []; CapabilityBoundingSet = ""; DevicePolicy = "closed"; IPAddressAllow = "localhost"; @@ -233,10 +151,7 @@ in ProcSubset = "pid"; ProtectSystem = "strict"; RemoveIPC = true; - RestrictAddressFamilies = [ - "AF_INET" - "AF_INET6" - ]; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; @@ -249,7 +164,7 @@ in }; }; - systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable { + systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs (cfg.dmarcReporting.enable) { description = "Daily delivery of aggregated DMARC reports"; wantedBy = [ "timers.target" @@ -270,3 +185,4 @@ in users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ]; }; } + diff --git a/mail-server/systemd.nix b/mail-server/systemd.nix index 8fb0da7..0fdcf90 100644 --- a/mail-server/systemd.nix +++ b/mail-server/systemd.nix @@ -14,77 +14,70 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: +{ config, pkgs, lib, ... }: let cfg = config.mailserver; certificatesDeps = if cfg.certificateScheme == "manual" then - [ ] + [] else if cfg.certificateScheme == "selfsigned" then [ "mailserver-selfsigned-certificate.service" ] else [ "acme-finished-${cfg.fqdn}.target" ]; in { - config = lib.mkIf cfg.enable { + config = with cfg; lib.mkIf enable { # Create self signed certificate - systemd.services.mailserver-selfsigned-certificate = - lib.mkIf (cfg.certificateScheme == "selfsigned") - { - after = [ "local-fs.target" ]; - script = '' - # Create certificates if they do not exist yet - dir="${cfg.certificateDirectory}" - fqdn="${cfg.fqdn}" - [[ $fqdn == /* ]] && fqdn=$(< "$fqdn") - key="$dir/key-${cfg.fqdn}.pem"; - cert="$dir/cert-${cfg.fqdn}.pem"; + systemd.services.mailserver-selfsigned-certificate = lib.mkIf (cfg.certificateScheme == "selfsigned") { + after = [ "local-fs.target" ]; + script = '' + # Create certificates if they do not exist yet + dir="${cfg.certificateDirectory}" + fqdn="${cfg.fqdn}" + [[ $fqdn == /* ]] && fqdn=$(< "$fqdn") + key="$dir/key-${cfg.fqdn}.pem"; + cert="$dir/cert-${cfg.fqdn}.pem"; - if [[ ! -f $key || ! -f $cert ]]; then - mkdir -p "${cfg.certificateDirectory}" - (umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) && - "${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \ - -days 3650 -out "$cert" - fi - ''; - serviceConfig = { - Type = "oneshot"; - PrivateTmp = true; - }; - }; + if [[ ! -f $key || ! -f $cert ]]; then + mkdir -p "${cfg.certificateDirectory}" + (umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) && + "${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \ + -days 3650 -out "$cert" + fi + ''; + serviceConfig = { + Type = "oneshot"; + PrivateTmp = true; + }; + }; # Create maildir folder before dovecot startup systemd.services.dovecot2 = { wants = certificatesDeps; after = certificatesDeps; - preStart = - let - directories = lib.strings.escapeShellArgs ( - [ cfg.mailDirectory ] ++ lib.optional (cfg.indexDir != null) cfg.indexDir - ); - in - '' - # Create mail directory and set permissions. See - # . - # Prevent world-readable paths, even temporarily. - umask 007 - mkdir -p ${directories} - chgrp "${cfg.vmailGroupName}" ${directories} - chmod 02770 ${directories} - ''; + preStart = let + directories = lib.strings.escapeShellArgs ( + [ mailDirectory ] + ++ lib.optional (cfg.indexDir != null) cfg.indexDir + ); + in '' + # Create mail directory and set permissions. See + # . + 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"; + after = [ "dovecot2.service" ] + ++ lib.optional cfg.dkimSigning "opendkim.service" + ++ certificatesDeps; + requires = [ "dovecot2.service" ] + ++ lib.optional cfg.dkimSigning "opendkim.service"; }; }; } diff --git a/mail-server/users.nix b/mail-server/users.nix index e9af05a..916ec0c 100644 --- a/mail-server/users.nix +++ b/mail-server/users.nix @@ -14,12 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: +{ config, pkgs, lib, ... }: with config.mailserver; @@ -33,14 +28,12 @@ let group = vmailGroupName; }; + virtualMailUsersActivationScript = pkgs.writeScript "activate-virtual-mail-users" '' #!${pkgs.stdenv.shell} set -euo pipefail - # Prevent world-readable paths, even temporarily. - umask 007 - # Create directory to store user sieve scripts if it doesn't exist if (! test -d "${sieveDirectory}"); then mkdir "${sieveDirectory}" @@ -50,54 +43,45 @@ let # Copy user's sieve script to the correct location (if it exists). If it # is null, remove the file. - ${lib.concatMapStringsSep "\n" ( - { name, sieveScript }: - if lib.isString sieveScript then - '' - if (! test -d "${sieveDirectory}/${name}"); then - mkdir -p "${sieveDirectory}/${name}" - chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}" - chmod 770 "${sieveDirectory}/${name}" - fi - cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve" - ${sieveScript} - EOF - chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve" - '' - else - '' - if (test -f "${sieveDirectory}/${name}/default.sieve"); then - rm "${sieveDirectory}/${name}/default.sieve" - fi - if (test -f "${sieveDirectory}/${name}.svbin"); then - rm "${sieveDirectory}/${name}/default.svbin" - fi - '' - ) (map (user: { inherit (user) name sieveScript; }) (lib.attrValues loginAccounts))} + ${lib.concatMapStringsSep "\n" ({ name, sieveScript }: + if lib.isString sieveScript then '' + if (! test -d "${sieveDirectory}/${name}"); then + mkdir -p "${sieveDirectory}/${name}" + chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}" + chmod 770 "${sieveDirectory}/${name}" + fi + cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve" + ${sieveScript} + EOF + chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve" + '' else '' + if (test -f "${sieveDirectory}/${name}/default.sieve"); then + rm "${sieveDirectory}/${name}/default.sieve" + fi + if (test -f "${sieveDirectory}/${name}.svbin"); then + rm "${sieveDirectory}/${name}/default.svbin" + fi + '') (map (user: { inherit (user) name sieveScript; }) + (lib.attrValues loginAccounts))} ''; -in -{ +in { config = lib.mkIf enable { # assert that all accounts provide a password - assertions = map (acct: { - assertion = acct.hashedPassword != null || acct.hashedPasswordFile != null; + assertions = (map (acct: { + assertion = (acct.hashedPassword != null || acct.hashedPasswordFile != null); message = "${acct.name} must provide either a hashed password or a password hash file"; - }) (lib.attrValues loginAccounts); + }) (lib.attrValues loginAccounts)); # warn for accounts that specify both password and file - warnings = - map (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used") - ( - lib.filter (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null)) ( - lib.attrValues loginAccounts - ) - ); + warnings = (map + (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used") + (lib.filter + (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null)) + (lib.attrValues loginAccounts))); # set the vmail gid to a specific value users.groups = { - "${vmailGroupName}" = { - gid = vmailUID; - }; + "${vmailGroupName}" = { gid = vmailUID; }; }; # define all users diff --git a/nixops/single-server.nix b/nixops/single-server.nix new file mode 100644 index 0000000..c002da7 --- /dev/null +++ b/nixops/single-server.nix @@ -0,0 +1,31 @@ +{ + network.description = "mail server"; + + mailserver = + { config, pkgs, ... }: + { + imports = [ + ../default.nix + ]; + + mailserver = { + enable = true; + fqdn = "mail.example.com"; + domains = [ "example.com" "example2.com" ]; + loginAccounts = { + "user1@example.com" = { + hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/"; + }; + }; + extraVirtualAliases = { + "info@example.com" = "user1@example.com"; + "postmaster@example.com" = "user1@example.com"; + "abuse@example.com" = "user1@example.com"; + "user1@example2.com" = "user1@example.com"; + "info@example2.com" = "user1@example.com"; + "postmaster@example2.com" = "user1@example.com"; + "abuse@example2.com" = "user1@example.com"; + }; + }; + }; +} diff --git a/nixops/vbox.nix b/nixops/vbox.nix new file mode 100644 index 0000000..2af7518 --- /dev/null +++ b/nixops/vbox.nix @@ -0,0 +1,9 @@ +{ + mailserver = + { config, pkgs, ... }: + { deployment.targetEnv = "virtualbox"; + deployment.virtualbox.memorySize = 1024; # megabytes + deployment.virtualbox.vcpu = 2; # number of cpus + deployment.virtualbox.headless = true; + }; +} diff --git a/scripts/generate-options.py b/scripts/generate-options.py index e78e262..75a25ae 100644 --- a/scripts/generate-options.py +++ b/scripts/generate-options.py @@ -1,7 +1,5 @@ import json import sys -from textwrap import indent -from typing import Any, Mapping header = """ # Mailserver options @@ -23,87 +21,62 @@ template = """ f = open(sys.argv[1]) options = json.load(f) -groups = [ - "mailserver.loginAccounts", - "mailserver.certificate", - "mailserver.dkim", - "mailserver.dmarcReporting", - "mailserver.fullTextSearch", - "mailserver.redis", - "mailserver.ldap", - "mailserver.monitoring", - "mailserver.backup", - "mailserver.borgbackup", -] +groups = ["mailserver.loginAccounts", + "mailserver.certificate", + "mailserver.dkim", + "mailserver.dmarcReporting", + "mailserver.fullTextSearch", + "mailserver.redis", + "mailserver.ldap", + "mailserver.monitoring", + "mailserver.backup", + "mailserver.borgbackup"] +def render_option_value(opt, attr): + if attr in opt: + if isinstance(opt[attr], dict) and '_type' in opt[attr]: + if opt[attr]['_type'] == 'literalExpression': + if '\n' in opt[attr]['text']: + res = '\n```nix\n' + opt[attr]['text'].rstrip('\n') + '\n```' + else: + res = '```{}```'.format(opt[attr]['text']) + elif opt[attr]['_type'] == 'literalMD': + res = opt[attr]['text'] + else: + s = str(opt[attr]) + if s == "": + res = '`""`' + elif '\n' in s: + res = '\n```\n' + s.rstrip('\n') + '\n```' + else: + res = '```{}```'.format(s) + res = '- ' + attr + ': ' + res + else: + res = "" + return res -def md_literal(value: str) -> str: - return f"`{value}`" - - -def md_codefence(value: str, language: str = "nix") -> str: - return indent( - f"\n```{language}\n{value}\n```", - prefix=2 * " ", - ) - - -def render_option_value(option: Mapping[str, Any], key: str) -> str: - if key not in option: - return "" - - if isinstance(option[key], dict) and "_type" in option[key]: - if option[key]["_type"] == "literalExpression": - # multi-line codeblock - if "\n" in option[key]["text"]: - text = option[key]["text"].rstrip("\n") - value = md_codefence(text) - # inline codeblock - else: - value = md_literal(option[key]["text"]) - # literal markdown - elif option[key]["_type"] == "literalMD": - value = option[key]["text"] - else: - assert RuntimeError(f"Unhandled option type {option[key]['_type']}") +def print_option(opt): + if isinstance(opt['description'], dict) and '_type' in opt['description']: # mdDoc + description = opt['description']['text'] else: - text = str(option[key]) - if text == "": - value = md_literal('""') - elif "\n" in text: - value = md_codefence(text.rstrip("\n")) - else: - value = md_literal(text) - - return f"- {key}: {value}" # type: ignore - - -def print_option(option): - if ( - isinstance(option["description"], dict) and "_type" in option["description"] - ): # mdDoc - description = option["description"]["text"] - else: - description = option["description"] - print( - template.format( - key=option["name"], - description=description or "", - type=f"- type: {md_literal(option['type'])}", - default=render_option_value(option, "default"), - example=render_option_value(option, "example"), - ) - ) + description = opt['description'] + print(template.format( + key=opt['name'], + description=description or "", + type="- type: ```{}```".format(opt['type']), + default=render_option_value(opt, 'default'), + example=render_option_value(opt, 'example'))) print(header) for opt in options: - if any([opt["name"].startswith(c) for c in groups]): + if any([opt['name'].startswith(c) for c in groups]): continue print_option(opt) for c in groups: - print(f"## `{c}`\n") + print('## `{}`'.format(c)) + print() for opt in options: - if opt["name"].startswith(c): + if opt['name'].startswith(c): print_option(opt) diff --git a/scripts/mail-check.py b/scripts/mail-check.py index 39b2688..0a96ce1 100644 --- a/scripts/mail-check.py +++ b/scripts/mail-check.py @@ -1,31 +1,26 @@ +import smtplib, sys import argparse -import email -import email.utils -import imaplib -import smtplib -import time +import os import uuid +import imaplib from datetime import datetime, timedelta -from typing import cast +import email +import time RETRY = 100 +def _send_mail(smtp_host, smtp_port, smtp_username, from_addr, from_pwd, to_addr, subject, starttls): + print("Sending mail with subject '{}'".format(subject)) + message = "\n".join([ + "From: {from_addr}", + "To: {to_addr}", + "Subject: {subject}", + "", + "This validates our mail server can send to Gmail :/"]).format( + from_addr=from_addr, + to_addr=to_addr, + subject=subject) -def _send_mail( - smtp_host, smtp_port, smtp_username, from_addr, from_pwd, to_addr, subject, starttls -): - print(f"Sending mail with subject '{subject}'") - message = "\n".join( - [ - f"From: {from_addr}", - f"To: {to_addr}", - f"Subject: {subject}", - f"Message-ID: {uuid.uuid4()}@mail-check.py", - f"Date: {email.utils.formatdate()}", - "", - "This validates our mail server can send to Gmail :/", - ] - ) retry = RETRY while True: @@ -42,9 +37,7 @@ def _send_mail( except smtplib.SMTPResponseException as e: if e.smtp_code == 451: # service unavailable error print(e) - elif ( - e.smtp_code == 454 - ): # smtplib.SMTPResponseException: (454, b'4.3.0 Try again later') + elif e.smtp_code == 454: # smtplib.SMTPResponseException: (454, b'4.3.0 Try again later') print(e) else: raise @@ -62,18 +55,16 @@ def _send_mail( print("Retry attempts exhausted") exit(5) - def _read_mail( - imap_host, - imap_port, - imap_username, - to_pwd, - subject, - ignore_dkim_spf, - show_body=False, - delete=True, -): - print("Reading mail from {imap_username}") + imap_host, + imap_port, + imap_username, + to_pwd, + subject, + ignore_dkim_spf, + show_body=False, + delete=True): + print("Reading mail from %s" % imap_username) message = None @@ -83,62 +74,49 @@ def _read_mail( today = datetime.today() cutoff = today - timedelta(days=1) - dt = cutoff.strftime("%d-%b-%Y") + dt = cutoff.strftime('%d-%b-%Y') for _ in range(0, RETRY): print("Retrying") obj.select() - _, data = obj.search(None, f'(SINCE {dt}) (SUBJECT "{subject}")') - if data == [b""]: + typ, data = obj.search(None, '(SINCE %s) (SUBJECT "%s")'%(dt, subject)) + if data == [b'']: time.sleep(1) continue uids = data[0].decode("utf-8").split(" ") if len(uids) != 1: - print( - f"Warning: {len(uids)} messages have been found with subject containing {subject}" - ) + print("Warning: %d messages have been found with subject containing %s " % (len(uids), subject)) # FIXME: we only consider the first matching message... uid = uids[0] - _, raw = obj.fetch(uid, "(RFC822)") + _, raw = obj.fetch(uid, '(RFC822)') if delete: - obj.store(uid, "+FLAGS", "\\Deleted") + obj.store(uid, '+FLAGS', '\\Deleted') obj.expunge() - assert raw[0] and raw[0][1] - message = email.message_from_bytes(cast(bytes, raw[0][1])) - print(f"Message with subject '{message['subject']}' has been found") + message = email.message_from_bytes(raw[0][1]) + print("Message with subject '%s' has been found" % message['subject']) if show_body: - if message.is_multipart(): - for part in message.walk(): - ctype = part.get_content_type() - if ctype == "text/plain": - body = cast(bytes, part.get_payload(decode=True)).decode() - print(f"Body:\n{body}") - else: - print(f"Body with content type {ctype} not printed") - else: - body = cast(bytes, message.get_payload(decode=True)).decode() - print(f"Body:\n{body}") + for m in message.get_payload(): + if m.get_content_type() == 'text/plain': + print("Body:\n%s" % m.get_payload(decode=True).decode('utf-8')) break if message is None: - print( - f"Error: no message with subject '{subject}' has been found in INBOX of {imap_username}" - ) + print("Error: no message with subject '%s' has been found in INBOX of %s" % (subject, imap_username)) exit(1) if ignore_dkim_spf: return # gmail set this standardized header - if "ARC-Authentication-Results" in message: - if "dkim=pass" in message["ARC-Authentication-Results"]: + if 'ARC-Authentication-Results' in message: + if "dkim=pass" in message['ARC-Authentication-Results']: print("DKIM ok") else: print("Error: no DKIM validation found in message:") print(message.as_string()) exit(2) - if "spf=pass" in message["ARC-Authentication-Results"]: + if "spf=pass" in message['ARC-Authentication-Results']: print("SPF ok") else: print("Error: no SPF validation found in message:") @@ -148,108 +126,71 @@ def _read_mail( print("DKIM and SPF verification failed") exit(4) - def send_and_read(args): src_pwd = None if args.src_password_file is not None: src_pwd = args.src_password_file.readline().rstrip() dst_pwd = args.dst_password_file.readline().rstrip() - if args.imap_username != "": + if args.imap_username != '': imap_username = args.imap_username else: imap_username = args.to_addr - subject = f"{uuid.uuid4()}" + subject = "{}".format(uuid.uuid4()) - _send_mail( - smtp_host=args.smtp_host, - smtp_port=args.smtp_port, - smtp_username=args.smtp_username, - from_addr=args.from_addr, - from_pwd=src_pwd, - to_addr=args.to_addr, - subject=subject, - starttls=args.smtp_starttls, - ) - - _read_mail( - imap_host=args.imap_host, - imap_port=args.imap_port, - imap_username=imap_username, - to_pwd=dst_pwd, - subject=subject, - ignore_dkim_spf=args.ignore_dkim_spf, - ) + _send_mail(smtp_host=args.smtp_host, + smtp_port=args.smtp_port, + smtp_username=args.smtp_username, + from_addr=args.from_addr, + from_pwd=src_pwd, + to_addr=args.to_addr, + subject=subject, + starttls=args.smtp_starttls) + _read_mail(imap_host=args.imap_host, + imap_port=args.imap_port, + imap_username=imap_username, + to_pwd=dst_pwd, + subject=subject, + ignore_dkim_spf=args.ignore_dkim_spf) def read(args): - _read_mail( - imap_host=args.imap_host, - imap_port=args.imap_port, - imap_username=args.imap_username, - to_pwd=args.imap_password, - subject=args.subject, - ignore_dkim_spf=args.ignore_dkim_spf, - show_body=args.show_body, - delete=False, - ) - + _read_mail(imap_host=args.imap_host, + imap_port=args.imap_port, + to_addr=args.imap_username, + to_pwd=args.imap_password, + subject=args.subject, + ignore_dkim_spf=args.ignore_dkim_spf, + show_body=args.show_body, + delete=False) parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() -parser_send_and_read = subparsers.add_parser( - "send-and-read", - description="Send a email with a subject containing a random UUID and then try to read this email from the recipient INBOX.", -) -parser_send_and_read.add_argument("--smtp-host", type=str) -parser_send_and_read.add_argument("--smtp-port", type=str, default=25) -parser_send_and_read.add_argument("--smtp-starttls", action="store_true") -parser_send_and_read.add_argument( - "--smtp-username", - type=str, - default="", - help="username used for smtp login. If not specified, the from-addr value is used", -) -parser_send_and_read.add_argument("--from-addr", type=str) -parser_send_and_read.add_argument("--imap-host", required=True, type=str) -parser_send_and_read.add_argument("--imap-port", type=str, default=993) -parser_send_and_read.add_argument("--to-addr", type=str, required=True) -parser_send_and_read.add_argument( - "--imap-username", - type=str, - default="", - help="username used for imap login. If not specified, the to-addr value is used", -) -parser_send_and_read.add_argument("--src-password-file", type=argparse.FileType("r")) -parser_send_and_read.add_argument( - "--dst-password-file", required=True, type=argparse.FileType("r") -) -parser_send_and_read.add_argument( - "--ignore-dkim-spf", - action="store_true", - help="to ignore the dkim and spf verification on the read mail", -) +parser_send_and_read = subparsers.add_parser('send-and-read', description="Send a email with a subject containing a random UUID and then try to read this email from the recipient INBOX.") +parser_send_and_read.add_argument('--smtp-host', type=str) +parser_send_and_read.add_argument('--smtp-port', type=str, default=25) +parser_send_and_read.add_argument('--smtp-starttls', action='store_true') +parser_send_and_read.add_argument('--smtp-username', type=str, default='', help="username used for smtp login. If not specified, the from-addr value is used") +parser_send_and_read.add_argument('--from-addr', type=str) +parser_send_and_read.add_argument('--imap-host', required=True, type=str) +parser_send_and_read.add_argument('--imap-port', type=str, default=993) +parser_send_and_read.add_argument('--to-addr', type=str, required=True) +parser_send_and_read.add_argument('--imap-username', type=str, default='', help="username used for imap login. If not specified, the to-addr value is used") +parser_send_and_read.add_argument('--src-password-file', type=argparse.FileType('r')) +parser_send_and_read.add_argument('--dst-password-file', required=True, type=argparse.FileType('r')) +parser_send_and_read.add_argument('--ignore-dkim-spf', action='store_true', help="to ignore the dkim and spf verification on the read mail") parser_send_and_read.set_defaults(func=send_and_read) -parser_read = subparsers.add_parser( - "read", - description="Search for an email with a subject containing 'subject' in the INBOX.", -) -parser_read.add_argument("--imap-host", type=str, default="localhost") -parser_read.add_argument("--imap-port", type=str, default=993) -parser_read.add_argument("--imap-username", required=True, type=str) -parser_read.add_argument("--imap-password", required=True, type=str) -parser_read.add_argument( - "--ignore-dkim-spf", - action="store_true", - help="to ignore the dkim and spf verification on the read mail", -) -parser_read.add_argument( - "--show-body", action="store_true", help="print mail text/plain payload" -) -parser_read.add_argument("subject", type=str) +parser_read = subparsers.add_parser('read', description="Search for an email with a subject containing 'subject' in the INBOX.") +parser_read.add_argument('--imap-host', type=str, default="localhost") +parser_read.add_argument('--imap-port', type=str, default=993) +parser_read.add_argument('--imap-username', required=True, type=str) +parser_read.add_argument('--imap-password', required=True, type=str) +parser_read.add_argument('--ignore-dkim-spf', action='store_true', help="to ignore the dkim and spf verification on the read mail") +parser_read.add_argument('--show-body', action='store_true', help="print mail text/plain payload") +parser_read.add_argument('subject', type=str) parser_read.set_defaults(func=read) args = parser.parse_args() diff --git a/shell.nix b/shell.nix index 493783d..6234bb4 100644 --- a/shell.nix +++ b/shell.nix @@ -1,9 +1,10 @@ -(import ( - let - lock = builtins.fromJSON (builtins.readFile ./flake.lock); - in - fetchTarball { - url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; - sha256 = lock.nodes.flake-compat.locked.narHash; - } -) { src = ./.; }).shellNix +(import + ( + let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in + fetchTarball { + url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; + sha256 = lock.nodes.flake-compat.locked.narHash; + } + ) + { src = ./.; } +).shellNix diff --git a/tests/clamav.nix b/tests/clamav.nix index 209e91e..7a9f43c 100644 --- a/tests/clamav.nix +++ b/tests/clamav.nix @@ -14,115 +14,97 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - lib, - blobs, - ... -}: +{ pkgs ? import {}, blobs}: -{ +pkgs.nixosTest { name = "clamav"; - nodes = { - server = - { pkgs, ... }: - { - imports = [ - ../default.nix - ./lib/config.nix - ]; + server = { config, pkgs, lib, ... }: + { + imports = [ + ../default.nix + ./lib/config.nix + ]; - virtualisation.memorySize = 1500; + virtualisation.memorySize = 1500; - environment.systemPackages = with pkgs; [ netcat ]; - - services.rsyslogd = { - enable = true; - defaultConfig = '' - *.* /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" ]; + services.rsyslogd = { + enable = true; + defaultConfig = '' + *.* /dev/console + ''; }; - "user@example2.com" = { - hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0"; - }; - }; - enableImap = true; - }; - environment.etc = { - "root/eicar.com.txt".text = "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; + services.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; - clientIP = nodes.client.networking.primaryIPAddress; + client = { nodes, config, pkgs, ... }: let + serverIP = nodes.server.config.networking.primaryIPAddress; + clientIP = nodes.client.config.networking.primaryIPAddress; grep-ip = pkgs.writeScriptBin "grep-ip" '' #!${pkgs.stdenv.shell} echo grep '${clientIP}' "$@" >&2 exec grep '${clientIP}' "$@" ''; - in - { + in { imports = [ - ./lib/config.nix + ./lib/config.nix ]; environment.systemPackages = with pkgs; [ - fetchmail - msmtp - procmail - findutils - grep-ip + fetchmail msmtp procmail findutils grep-ip ]; environment.etc = { "root/.fetchmailrc" = { text = '' - poll ${serverIP} with proto IMAP - user 'user1@example.com' there with password 'user1' is 'root' here - mda procmail + poll ${serverIP} with proto IMAP + user 'user1@example.com' there with password 'user1' is 'root' here + mda procmail ''; mode = "0700"; }; @@ -196,59 +178,60 @@ ''; }; }; - }; + }; - testScript = '' - start_all() + testScript = { nodes, ... }: + '' + start_all() - server.wait_for_unit("multi-user.target") - client.wait_for_unit("multi-user.target") + server.wait_for_unit("multi-user.target") + client.wait_for_unit("multi-user.target") - # TODO put this blocking into the systemd units? I am not sure if rspamd already waits for the clamd socket. - server.wait_until_succeeds( - "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" - ) - server.wait_until_succeeds( - "set +e; timeout 1 nc -U /run/clamav/clamd.ctl < /dev/null; [ $? -eq 124 ]" - ) + # TODO put this blocking into the systemd units? I am not sure if rspamd already waits for the clamd socket. + server.wait_until_succeeds( + "set +e; timeout 1 ${nodes.server.nixpkgs.pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" + ) + server.wait_until_succeeds( + "set +e; timeout 1 ${nodes.server.nixpkgs.pkgs.netcat}/bin/nc -U /run/clamav/clamd.ctl < /dev/null; [ $? -eq 124 ]" + ) - client.execute("cp -p /etc/root/.* ~/") - client.succeed("mkdir -p ~/mail") - client.succeed("ls -la ~/ >&2") - client.succeed("cat ~/.fetchmailrc >&2") - client.succeed("cat ~/.procmailrc >&2") - client.succeed("cat ~/.msmtprc >&2") + client.execute("cp -p /etc/root/.* ~/") + client.succeed("mkdir -p ~/mail") + client.succeed("ls -la ~/ >&2") + client.succeed("cat ~/.fetchmailrc >&2") + client.succeed("cat ~/.procmailrc >&2") + client.succeed("cat ~/.msmtprc >&2") - # fetchmail returns EXIT_CODE 1 when no new mail - client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2") + # fetchmail returns EXIT_CODE 1 when no new mail + client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2") - # Verify that mail can be sent and received before testing virus scanner - client.execute("rm ~/mail/*") - client.succeed("msmtp -a user2 user1@example.com < /etc/root/safe-email >&2") - # give the mail server some time to process the mail - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - client.execute("rm ~/mail/*") - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.succeed("fetchmail --nosslcertck -v >&2") - client.execute("rm ~/mail/*") + # Verify that mail can be sent and received before testing virus scanner + client.execute("rm ~/mail/*") + client.succeed("msmtp -a user2 user1@example.com < /etc/root/safe-email >&2") + # give the mail server some time to process the mail + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + client.execute("rm ~/mail/*") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("fetchmail --nosslcertck -v >&2") + client.execute("rm ~/mail/*") - with subtest("virus scan file"): - server.succeed( - 'set +o pipefail; clamdscan $(readlink -f /etc/root/eicar.com.txt) | grep "Txt\\.Malware\\.Agent-1787597 FOUND" >&2' - ) + with subtest("virus scan file"): + server.succeed( + 'set +o pipefail; clamdscan $(readlink -f /etc/root/eicar.com.txt) | grep "Txt\\.Malware\\.Agent-1787597 FOUND" >&2' + ) - with subtest("virus scan email"): - client.succeed( - 'set +o pipefail; msmtp -a user2 user1@example.com < /etc/root/virus-email 2>&1 | tee /dev/stderr | grep "server message: 554 5\\.7\\.1" >&2' - ) - server.succeed("journalctl -u rspamd | grep -i eicar") - # give the mail server some time to process the mail - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + with subtest("virus scan email"): + client.succeed( + 'set +o pipefail; msmtp -a user2 user1\@example.com < /etc/root/virus-email 2>&1 | tee /dev/stderr | grep "server message: 554 5\\.7\\.1" >&2' + ) + server.succeed("journalctl -u rspamd | grep -i eicar") + # give the mail server some time to process the mail + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - with subtest("no warnings or errors"): - server.fail("journalctl -u postfix | grep -i error >&2") - server.fail("journalctl -u postfix | grep -i warning >&2") - server.fail("journalctl -u dovecot2 | grep -i error >&2") - server.fail("journalctl -u dovecot2 | grep -i warning >&2") - ''; + with subtest("no warnings or errors"): + server.fail("journalctl -u postfix | grep -i error >&2") + server.fail("journalctl -u postfix | grep -i warning >&2") + server.fail("journalctl -u dovecot2 | grep -i error >&2") + server.fail("journalctl -u dovecot2 | grep -i warning >&2") + ''; } diff --git a/tests/external.nix b/tests/external.nix index 82abb65..6c03144 100644 --- a/tests/external.nix +++ b/tests/external.nix @@ -14,90 +14,81 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ +{ pkgs ? import {}, ...}: + +pkgs.nixosTest { name = "external"; - nodes = { - server = - { pkgs, ... }: - { - imports = [ - ../default.nix - ./lib/config.nix - ]; - - environment.systemPackages = with pkgs; [ netcat ]; - - virtualisation.memorySize = 1024; - - services.rsyslogd = { - enable = true; - defaultConfig = '' - *.* /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" + server = { config, pkgs, ... }: + { + imports = [ + ../default.nix + ./lib/config.nix ]; - }; - 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"; - }; + virtualisation.memorySize = 1024; + + services.rsyslogd = { + enable = true; + defaultConfig = '' + *.* /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"; + # fts-xapian warns when memory is low, which makes the test fail + memoryLimit = 100000; + }; + }; }; - }; - client = - { nodes, pkgs, ... }: - let - serverIP = nodes.server.networking.primaryIPAddress; - clientIP = nodes.client.networking.primaryIPAddress; + client = { nodes, config, pkgs, ... }: let + serverIP = nodes.server.config.networking.primaryIPAddress; + clientIP = nodes.client.config.networking.primaryIPAddress; grep-ip = pkgs.writeScriptBin "grep-ip" '' #!${pkgs.stdenv.shell} echo grep '${clientIP}' "$@" >&2 @@ -182,36 +173,27 @@ assert needle in repr(response) imap.close() ''; - in - { + in { imports = [ - ./lib/config.nix + ./lib/config.nix ]; environment.systemPackages = with pkgs; [ - fetchmail - msmtp - procmail - findutils - grep-ip - check-mail-id - test-imap-spam - test-imap-ham - search + fetchmail msmtp procmail findutils grep-ip check-mail-id test-imap-spam test-imap-ham search ]; environment.etc = { "root/.fetchmailrc" = { text = '' - poll ${serverIP} with proto IMAP - user 'user1@example.com' there with password 'user1' is 'root' here - mda procmail + poll ${serverIP} with proto IMAP + user 'user1@example.com' there with password 'user1' is 'root' here + mda procmail ''; mode = "0700"; }; "root/.fetchmailRcLowQuota" = { text = '' - poll ${serverIP} with proto IMAP - user 'lowquota@example.com' there with password 'user2' is 'root' here - mda procmail + poll ${serverIP} with proto IMAP + user 'lowquota@example.com' there with password 'user2' is 'root' here + mda procmail ''; mode = "0700"; }; @@ -290,7 +272,7 @@ To: Chuck Cc: Bcc: - Subject: This is a test Email from postmaster@example.com to chuck + Subject: This is a test Email from postmaster\@example.com to chuck Reply-To: Hello Chuck, @@ -304,7 +286,7 @@ To: User1 Cc: Bcc: - Subject: This is a test Email from single-alias@example.com to user1 + Subject: This is a test Email from single-alias\@example.com to user1 Reply-To: Hello User1, @@ -319,7 +301,7 @@ To: Multi Alias Cc: Bcc: - Subject: This is a test Email from user2@example.com to multi-alias + Subject: This is a test Email from user2\@example.com to multi-alias Reply-To: Hello Multi Alias, @@ -340,7 +322,7 @@ Hello User1, this email contains the needle: - 576a4565b70f5a4c1a0925cabdb587a6 + 576a4565b70f5a4c1a0925cabdb587a6 ''; "root/email7".text = '' Message-ID: <1234578qwerty@host.local.network> @@ -357,176 +339,177 @@ ''; }; }; - }; + }; - testScript = '' - start_all() + testScript = { nodes, ... }: + '' + start_all() - server.wait_for_unit("multi-user.target") - client.wait_for_unit("multi-user.target") + server.wait_for_unit("multi-user.target") + client.wait_for_unit("multi-user.target") - # TODO put this blocking into the systemd units? - server.wait_until_succeeds( - "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" - ) + # TODO put this blocking into the systemd units? + server.wait_until_succeeds( + "set +e; timeout 1 ${nodes.server.nixpkgs.pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" + ) - client.execute("cp -p /etc/root/.* ~/") - client.succeed("mkdir -p ~/mail") - client.succeed("ls -la ~/ >&2") - client.succeed("cat ~/.fetchmailrc >&2") - client.succeed("cat ~/.procmailrc >&2") - client.succeed("cat ~/.msmtprc >&2") + client.execute("cp -p /etc/root/.* ~/") + client.succeed("mkdir -p ~/mail") + client.succeed("ls -la ~/ >&2") + client.succeed("cat ~/.fetchmailrc >&2") + client.succeed("cat ~/.procmailrc >&2") + client.succeed("cat ~/.msmtprc >&2") - with subtest("imap retrieving mail"): - # fetchmail returns EXIT_CODE 1 when no new mail - client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2") + with subtest("imap retrieving mail"): + # fetchmail returns EXIT_CODE 1 when no new mail + client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2") - with subtest("submission port send mail"): - # send email from user2 to user1 - client.succeed( - "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email1 >&2" - ) - # give the mail server some time to process the mail - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + with subtest("submission port send mail"): + # send email from user2 to user1 + client.succeed( + "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email1 >&2" + ) + # give the mail server some time to process the mail + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - with subtest("imap retrieving mail 2"): - client.execute("rm ~/mail/*") - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.succeed("fetchmail --nosslcertck -v >&2") + with subtest("imap retrieving mail 2"): + client.execute("rm ~/mail/*") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("fetchmail --nosslcertck -v >&2") - with subtest("remove sensitive information on submission port"): - client.succeed("cat ~/mail/* >&2") - ## make sure our IP is _not_ in the email header - client.fail("grep-ip ~/mail/*") - client.succeed("check-mail-id ~/mail/*") + with subtest("remove sensitive information on submission port"): + client.succeed("cat ~/mail/* >&2") + ## make sure our IP is _not_ in the email header + client.fail("grep-ip ~/mail/*") + client.succeed("check-mail-id ~/mail/*") - with subtest("have correct fqdn as sender"): - client.succeed("grep 'Received: from mail.example.com' ~/mail/*") + with subtest("have correct fqdn as sender"): + client.succeed("grep 'Received: from mail.example.com' ~/mail/*") - with subtest("dkim has user-specified size"): - server.succeed( - "openssl rsa -in /var/dkim/example.com.mail.key -text -noout | grep 'Private-Key: (1535 bit'" - ) + with subtest("dkim has user-specified size"): + server.succeed( + "openssl rsa -in /var/dkim/example.com.mail.key -text -noout | grep 'Private-Key: (1535 bit'" + ) - with subtest("dkim singing, multiple domains"): - client.execute("rm ~/mail/*") - # send email from user2 to user1 - client.succeed( - "msmtp -a test2 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email2 >&2" - ) - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.succeed("fetchmail --nosslcertck -v") - client.succeed("cat ~/mail/* >&2") - # make sure it is dkim signed - client.succeed("grep DKIM-Signature: ~/mail/*") + with subtest("dkim singing, multiple domains"): + client.execute("rm ~/mail/*") + # send email from user2 to user1 + client.succeed( + "msmtp -a test2 --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email2 >&2" + ) + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("fetchmail --nosslcertck -v") + client.succeed("cat ~/mail/* >&2") + # make sure it is dkim signed + client.succeed("grep DKIM ~/mail/*") - with subtest("aliases"): - client.execute("rm ~/mail/*") - # send email from chuck to postmaster - client.succeed( - "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on postmaster@example.com < /etc/root/email2 >&2" - ) - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.succeed("fetchmail --nosslcertck -v") + with subtest("aliases"): + client.execute("rm ~/mail/*") + # send email from chuck to postmaster + client.succeed( + "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on postmaster\@example.com < /etc/root/email2 >&2" + ) + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("fetchmail --nosslcertck -v") - with subtest("catchAlls"): - client.execute("rm ~/mail/*") - # send email from chuck to non exsitent account - client.succeed( - "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lol@example.com < /etc/root/email2 >&2" - ) - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.succeed("fetchmail --nosslcertck -v") + with subtest("catchAlls"): + client.execute("rm ~/mail/*") + # send email from chuck to non exsitent account + client.succeed( + "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lol\@example.com < /etc/root/email2 >&2" + ) + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("fetchmail --nosslcertck -v") - client.execute("rm ~/mail/*") - # send email from user1 to chuck - client.succeed( - "msmtp -a test4 --tls=on --tls-certcheck=off --auth=on chuck@example.com < /etc/root/email2 >&2" - ) - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # fetchmail returns EXIT_CODE 1 when no new mail - # if this succeeds, it means that user1 recieved the mail that was intended for chuck. - client.fail("fetchmail --nosslcertck -v") + client.execute("rm ~/mail/*") + # send email from user1 to chuck + client.succeed( + "msmtp -a test4 --tls=on --tls-certcheck=off --auth=on chuck\@example.com < /etc/root/email2 >&2" + ) + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + # fetchmail returns EXIT_CODE 1 when no new mail + # if this succeeds, it means that user1 recieved the mail that was intended for chuck. + client.fail("fetchmail --nosslcertck -v") - with subtest("extraVirtualAliases"): - 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.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.succeed("fetchmail --nosslcertck -v") + with subtest("extraVirtualAliases"): + 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.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("fetchmail --nosslcertck -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.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.succeed("fetchmail --nosslcertck -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.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("fetchmail --nosslcertck -v") - with subtest("quota"): - client.execute("rm ~/mail/*") - client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc") + with subtest("quota"): + client.execute("rm ~/mail/*") + client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc") - client.succeed( - "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lowquota@example.com < /etc/root/email2 >&2" - ) - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.fail("fetchmail --nosslcertck -v") + client.succeed( + "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lowquota\@example.com < /etc/root/email2 >&2" + ) + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.fail("fetchmail --nosslcertck -v") - with subtest("imap sieve junk trainer"): - # send email from user2 to user1 - client.succeed( - "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email1 >&2" - ) - # give the mail server some time to process the mail - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + with subtest("imap sieve junk trainer"): + # send email from user2 to user1 + client.succeed( + "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email1 >&2" + ) + # give the mail server some time to process the mail + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - client.succeed("imap-mark-spam >&2") - server.wait_until_succeeds("journalctl -u dovecot2 | grep -i rspamd-learn-spam.sh >&2") - client.succeed("imap-mark-ham >&2") - server.wait_until_succeeds("journalctl -u dovecot2 | grep -i rspamd-learn-ham.sh >&2") + client.succeed("imap-mark-spam >&2") + server.wait_until_succeeds("journalctl -u dovecot2 | grep -i sa-learn-spam.sh >&2") + client.succeed("imap-mark-ham >&2") + server.wait_until_succeeds("journalctl -u dovecot2 | grep -i sa-learn-ham.sh >&2") - with subtest("full text search and indexation"): - # send 2 email from user2 to user1 - client.succeed( - "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email6 >&2" - ) - client.succeed( - "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email7 >&2" - ) - # give the mail server some time to process the mail - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + with subtest("full text search and indexation"): + # send 2 email from user2 to user1 + client.succeed( + "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email6 >&2" + ) + client.succeed( + "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email7 >&2" + ) + # give the mail server some time to process the mail + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # should find exactly one email containing this - client.succeed("search INBOX 576a4565b70f5a4c1a0925cabdb587a6 >&2") - # should fail because this folder is not indexed - client.fail("search Junk a >&2") - # check that search really goes through the indexer - server.succeed("journalctl -u dovecot2 | grep 'fts-flatcurve(INBOX): Query ' >&2") - # check that Junk is not indexed - server.fail("journalctl -u dovecot2 | grep 'fts-flatcurve(JUNK): Indexing ' >&2") + # should find exactly one email containing this + client.succeed("search INBOX 576a4565b70f5a4c1a0925cabdb587a6 >&2") + # should fail because this folder is not indexed + client.fail("search Junk a >&2") + # check that search really goes through the indexer + server.succeed( + "journalctl -u dovecot2 | grep -E 'indexer-worker.* Done indexing .INBOX.' >&2" + ) + # check that Junk is not indexed + server.fail("journalctl -u dovecot2 | grep 'indexer-worker' | grep -i 'JUNK' >&2") - with subtest("dmarc reporting"): - server.systemctl("start rspamd-dmarc-reporter.service") + with subtest("dmarc reporting"): + server.systemctl("start rspamd-dmarc-reporter.service") + server.wait_until_succeeds("journalctl -eu rspamd-dmarc-reporter.service -o cat | grep -q 'No reports for '") - with subtest("no warnings or errors"): - server.fail("journalctl -u postfix | grep -i error >&2") - server.fail("journalctl -u postfix | grep -i warning >&2") - server.fail("journalctl -u dovecot2 | grep -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2") - # harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html - server.fail( - "journalctl -u dovecot2 | \ - grep -v 'Expunged message reappeared, giving a new UID' | \ - grep -v 'Time moved forwards' | \ - grep -i warning >&2" - ) - ''; + with subtest("no warnings or errors"): + server.fail("journalctl -u postfix | grep -i error >&2") + server.fail("journalctl -u postfix | grep -i warning >&2") + server.fail("journalctl -u dovecot2 | grep -i error >&2") + # harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html + server.fail( + "journalctl -u dovecot2 |grep -v 'Expunged message reappeared, giving a new UID'| grep -i warning >&2" + ) + ''; } diff --git a/tests/internal.nix b/tests/internal.nix index af552c3..02609fd 100644 --- a/tests/internal.nix +++ b/tests/internal.nix @@ -14,10 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - pkgs, - ... -}: +{ pkgs ? import {}, ...}: let sendMail = pkgs.writeTextFile { @@ -30,80 +27,61 @@ let ''; }; - hashPassword = - password: - pkgs.runCommand "password-${password}-hashed" - { - buildInputs = [ pkgs.mkpasswd ]; - inherit password; - } - '' - mkpasswd -sm bcrypt <<<"$password" > $out - ''; + hashPassword = password: pkgs.runCommand + "password-${password}-hashed" + { buildInputs = [ pkgs.mkpasswd ]; inherit password; } '' + mkpasswd -sm bcrypt <<<"$password" > $out + ''; hashedPasswordFile = hashPassword "my-password"; passwordFile = pkgs.writeText "password" "my-password"; in -{ +pkgs.nixosTest { name = "internal"; - nodes = { - machine = - { pkgs, ... }: - { - imports = [ - ./../default.nix - ./lib/config.nix - ]; + machine = { config, pkgs, ... }: { + imports = [ + ./../default.nix + ./lib/config.nix + ]; - virtualisation.memorySize = 1024; + virtualisation.memorySize = 1024; - environment.systemPackages = - [ - (pkgs.writeScriptBin "mail-check" '' - ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ - '') - ] - ++ (with pkgs; [ - curl - openssl - netcat - ]); + environment.systemPackages = [ + (pkgs.writeScriptBin "mail-check" '' + ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ + '')]; - mailserver = { - enable = true; - fqdn = "mail.example.com"; - domains = [ - "example.com" - "domain.com" - ]; - localDnsResolver = false; + mailserver = { + enable = true; + fqdn = "mail.example.com"; + domains = [ "example.com" ]; + localDnsResolver = false; - loginAccounts = { - "user1@example.com" = { - hashedPasswordFile = hashedPasswordFile; - }; - "user2@example.com" = { - hashedPasswordFile = hashedPasswordFile; - aliasesRegexp = [ ''/^user2.*@domain\.com$/'' ]; - }; - "send-only@example.com" = { - hashedPasswordFile = hashPassword "send-only"; - sendOnly = true; - }; + loginAccounts = { + "user1@example.com" = { + hashedPasswordFile = hashedPasswordFile; }; - forwards = { - # user2@example.com is a local account and its mails are - # also forwarded to user1@example.com - "user2@example.com" = "user1@example.com"; + "user2@example.com" = { + hashedPasswordFile = hashedPasswordFile; + }; + "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 = '' machine.start() @@ -148,46 +126,6 @@ in ) ) - with subtest("regex email alias are received"): - # A mail sent to user2-regex-alias@domain.com is in the user2@example.com mailbox - machine.succeed( - " ".join( - [ - "mail-check send-and-read", - "--smtp-port 587", - "--smtp-starttls", - "--smtp-host localhost", - "--imap-host localhost", - "--imap-username user2@example.com", - "--from-addr user1@example.com", - "--to-addr user2-regex-alias@domain.com", - "--src-password-file ${passwordFile}", - "--dst-password-file ${passwordFile}", - "--ignore-dkim-spf", - ] - ) - ) - - with subtest("user can send from regex email alias"): - # A mail sent from user2-regex-alias@domain.com, using user2@example.com credentials is received - machine.succeed( - " ".join( - [ - "mail-check send-and-read", - "--smtp-port 587", - "--smtp-starttls", - "--smtp-host localhost", - "--imap-host localhost", - "--smtp-username user2@example.com", - "--from-addr user2-regex-alias@domain.com", - "--to-addr user1@example.com", - "--src-password-file ${passwordFile}", - "--dst-password-file ${passwordFile}", - "--ignore-dkim-spf", - ] - ) - ) - with subtest("vmail gid is set correctly"): machine.succeed("getent group vmail | grep 5000") @@ -195,22 +133,22 @@ in machine.wait_for_open_port(25) # TODO put this blocking into the systemd units machine.wait_until_succeeds( - "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" + "set +e; timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" ) machine.succeed( - "cat ${sendMail} | nc localhost 25 | grep -q '554 5.5.0 Error'" + "cat ${sendMail} | ${pkgs.netcat-gnu}/bin/nc localhost 25 | grep -q 'This account cannot receive emails'" ) with subtest("rspamd controller serves web ui"): machine.succeed( - "set +o pipefail; curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q ''" + "set +o pipefail; ${pkgs.curl}/bin/curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q ''" ) with subtest("imap port 143 is closed and imaps is serving SSL"): machine.wait_for_closed_port(143) machine.wait_for_open_port(993) machine.succeed( - "echo | openssl s_client -connect localhost:993 | grep 'New, TLS'" + "echo | ${pkgs.openssl}/bin/openssl s_client -connect localhost:993 | grep 'New, TLS'" ) ''; } diff --git a/tests/ldap.nix b/tests/ldap.nix index 1c92572..172a77d 100644 --- a/tests/ldap.nix +++ b/tests/ldap.nix @@ -1,119 +1,115 @@ +{ pkgs ? import {} +, ... +}: + let bindPassword = "unsafegibberish"; alicePassword = "testalice"; bobPassword = "testbob"; in -{ +pkgs.nixosTest { name = "ldap"; - nodes = { - machine = - { pkgs, ... }: - { - imports = [ - ./../default.nix - ./lib/config.nix - ]; + machine = { config, pkgs, ... }: { + imports = [ + ./../default.nix + ./lib/config.nix + ]; - virtualisation.memorySize = 1024; + virtualisation.memorySize = 1024; - services.openssh = { - enable = true; - settings.PermitRootLogin = "yes"; - }; + services.openssh = { + enable = true; + permitRootLogin = "yes"; + }; - environment.systemPackages = [ - (pkgs.writeScriptBin "mail-check" '' - ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ - '') - ]; + environment.systemPackages = [ + (pkgs.writeScriptBin "mail-check" '' + ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ + '')]; - environment.etc.bind-password.text = bindPassword; + environment.etc.bind-password.text = bindPassword; - services.openldap = { - enable = true; - settings = { - children = { - "cn=schema".includes = [ - "${pkgs.openldap}/etc/schema/core.ldif" - "${pkgs.openldap}/etc/schema/cosine.ldif" - "${pkgs.openldap}/etc/schema/inetorgperson.ldif" - "${pkgs.openldap}/etc/schema/nis.ldif" - ]; - "olcDatabase={1}mdb" = { - attrs = { - objectClass = [ - "olcDatabaseConfig" - "olcMdbConfig" - ]; - olcDatabase = "{1}mdb"; - olcDbDirectory = "/var/lib/openldap/example"; - olcSuffix = "dc=example"; - }; + services.openldap = { + enable = true; + settings = { + children = { + "cn=schema".includes = [ + "${pkgs.openldap}/etc/schema/core.ldif" + "${pkgs.openldap}/etc/schema/cosine.ldif" + "${pkgs.openldap}/etc/schema/inetorgperson.ldif" + "${pkgs.openldap}/etc/schema/nis.ldif" + ]; + "olcDatabase={1}mdb" = { + attrs = { + objectClass = [ + "olcDatabaseConfig" + "olcMdbConfig" + ]; + olcDatabase = "{1}mdb"; + olcDbDirectory = "/var/lib/openldap/example"; + olcSuffix = "dc=example"; }; }; }; - declarativeContents."dc=example" = '' - 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 - mailserver = { - enable = true; - fqdn = "mail.example.com"; - domains = [ "example.com" ]; - localDnsResolver = false; + dn: cn=mail,dc=example + objectClass: organizationalRole + objectClass: simpleSecurityObject + objectClass: top + cn: mail + userPassword: ${bindPassword} - ldap = { - enable = true; - uris = [ - "ldap://" - ]; - bind = { - dn = "cn=mail,dc=example"; - passwordFile = "/etc/bind-password"; - }; - searchBase = "ou=users,dc=example"; - searchScope = "sub"; - }; + dn: ou=users,dc=example + objectClass: organizationalUnit + ou: users - forwards = { - "bob_fw@example.com" = "bob@example.com"; - }; + dn: cn=alice,ou=users,dc=example + objectClass: inetOrgPerson + cn: alice + sn: Foo + mail: alice@example.com + userPassword: ${alicePassword} - vmailGroupName = "vmail"; - vmailUID = 5000; - - enableImap = false; - }; + dn: cn=bob,ou=users,dc=example + objectClass: inetOrgPerson + cn: bob + sn: Bar + mail: bob@example.com + userPassword: ${bobPassword} + ''; }; + + mailserver = { + enable = true; + fqdn = "mail.example.com"; + domains = [ "example.com" ]; + localDnsResolver = false; + + ldap = { + enable = true; + uris = [ + "ldap://" + ]; + bind = { + dn = "cn=mail,dc=example"; + passwordFile = "/etc/bind-password"; + }; + searchBase = "ou=users,dc=example"; + searchScope = "sub"; + }; + + vmailGroupName = "vmail"; + vmailUID = 5000; + + enableImap = false; + }; + }; }; testScript = '' import sys @@ -183,39 +179,5 @@ in "--dst-password-file <(echo '${bobPassword}')", "--ignore-dkim-spf" ])) - - with subtest("Test mail forwarding works"): - machine.succeed(" ".join([ - "mail-check send-and-read", - "--smtp-port 587", - "--smtp-starttls", - "--smtp-host localhost", - "--smtp-username alice@example.com", - "--imap-host localhost", - "--imap-username bob@example.com", - "--from-addr alice@example.com", - "--to-addr bob_fw@example.com", - "--src-password-file <(echo '${alicePassword}')", - "--dst-password-file <(echo '${bobPassword}')", - "--ignore-dkim-spf" - ])) - - with subtest("Test cannot send mail from forwarded address"): - machine.fail(" ".join([ - "mail-check send-and-read", - "--smtp-port 587", - "--smtp-starttls", - "--smtp-host localhost", - "--smtp-username bob@example.com", - "--imap-host localhost", - "--imap-username alice@example.com", - "--from-addr bob_fw@example.com", - "--to-addr alice@example.com", - "--src-password-file <(echo '${bobPassword}')", - "--dst-password-file <(echo '${alicePassword}')", - "--ignore-dkim-spf" - ])) - machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user bob@example.com'") - ''; } diff --git a/tests/lib/config.nix b/tests/lib/config.nix index fe66875..2cdc9d2 100644 --- a/tests/lib/config.nix +++ b/tests/lib/config.nix @@ -1,4 +1,3 @@ { - # Testing eval failures that result from stateVersion assertion is out of scope - mailserver.stateVersion = 999; + security.dhparams.defaultBitSize = 1024; # minimum size required by dovecot } diff --git a/tests/minimal.nix b/tests/minimal.nix index e78814e..88cb276 100644 --- a/tests/minimal.nix +++ b/tests/minimal.nix @@ -14,17 +14,18 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - name = "minimal"; +import { - nodes.machine = { - imports = [ - ../default.nix - ./lib/config.nix - ]; - }; + nodes.machine = + { config, pkgs, ... }: + { + imports = [ + ./../default.nix + ]; + }; - testScript = '' - machine.wait_for_unit("multi-user.target"); - ''; + testScript = + '' + machine.wait_for_unit("multi-user.target"); + ''; } diff --git a/tests/multiple.nix b/tests/multiple.nix index 2c6d0fc..8a4c07b 100644 --- a/tests/multiple.nix +++ b/tests/multiple.nix @@ -1,33 +1,19 @@ # This tests is used to test features requiring several mail domains. -{ - pkgs, - ... -}: +{ pkgs ? import {}, ...}: let - hashPassword = - password: - pkgs.runCommand "password-${password}-hashed" - { - buildInputs = [ pkgs.mkpasswd ]; - inherit password; - } + hashPassword = password: pkgs.runCommand + "password-${password}-hashed" + { buildInputs = [ pkgs.mkpasswd ]; inherit password; } '' mkpasswd -sm bcrypt <<<"$password" > $out ''; - password = pkgs.writeText "password" "password"; + password = pkgs.writeText "password" "password"; - domainGenerator = - domain: - { pkgs, ... }: - { - imports = [ - ../default.nix - ./lib/config.nix - ]; - environment.systemPackages = with pkgs; [ netcat ]; + domainGenerator = domain: { config, pkgs, ... }: { + imports = [../default.nix]; virtualisation.memorySize = 1024; mailserver = { enable = true; @@ -44,47 +30,32 @@ let }; services.dnsmasq = { enable = true; - settings.mx-host = [ - "domain1.com,domain1,10" - "domain2.com,domain2,10" - ]; + settings.mx-host = [ "domain1.com,domain1,10" "domain2.com,domain2,10" ]; }; }; in -{ +pkgs.nixosTest { name = "multiple"; - nodes = { - domain1 = - { ... }: - { - imports = [ - ../default.nix - (domainGenerator "domain1.com") - ]; - mailserver.forwards = { - "non-local@domain1.com" = [ - "user@domain2.com" - "user@domain1.com" - ]; - "non@domain1.com" = [ - "user@domain2.com" - "user@domain1.com" - ]; - }; + domain1 = {...}: { + imports = [ + ../default.nix + (domainGenerator "domain1.com") + ]; + mailserver.forwards = { + "non-local@domain1.com" = ["user@domain2.com" "user@domain1.com"]; + "non@domain1.com" = ["user@domain2.com" "user@domain1.com"]; }; + }; domain2 = domainGenerator "domain2.com"; - client = - { pkgs, ... }: - { - environment.systemPackages = [ - (pkgs.writeScriptBin "mail-check" '' - ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ - '') - ]; - }; + client = { config, pkgs, ... }: { + environment.systemPackages = [ + (pkgs.writeScriptBin "mail-check" '' + ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ + '')]; + }; }; testScript = '' start_all() @@ -94,10 +65,10 @@ in # TODO put this blocking into the systemd units? domain1.wait_until_succeeds( - "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" + "set +e; timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" ) domain2.wait_until_succeeds( - "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" + "set +e; timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" ) # user@domain1.com sends a mail to user@domain2.com diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..39d6402 --- /dev/null +++ b/update.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +sed -i -e "s/v[0-9]\+\.[0-9]\+\.[0-9]\+/$1/g" README.md + +HASH=$(nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/v2.3.0/nixos-mailserver-$1.tar.gz" --unpack) + +sed -i -e "s/sha256 = \"[0-9a-z]\{52\}\"/sha256 = \"$HASH\"/g" README.md