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..89a9df8 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 run -f channel:nixos-unstable hydra-cli -c hydra-cli -H https://hydra.nix-community.org jobset-wait simple-nixos-mailserver ${CI_MERGE_REQUEST_IID} || (echo Go to https://hydra.nix-community.org/jobset/simple-nixos-mailserver/${CI_MERGE_REQUEST_IID}/latest-eval for details; exit 1) hydra-master: - extends: .hydra-cli only: - master - variables: - jobset: master + image: nixos/nix + script: + - nix run -f channel:nixos-unstable hydra-cli -c hydra-cli -H https://hydra.nix-community.org jobset-wait simple-nixos-mailserver master || (echo Go to https://hydra.nix-community.org/jobset/simple-nixos-mailserver/master/latest-eval for details; exit 1) diff --git a/.hydra/declarative-jobsets.nix b/.hydra/declarative-jobsets.nix index 6877235..3749d04 100644 --- a/.hydra/declarative-jobsets.nix +++ b/.hydra/declarative-jobsets.nix @@ -1,55 +1,98 @@ -{ nixpkgs, pulls, ... }: +{ nixpkgs, declInput, 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; - mkFlakeJobset = branch: { - description = "Build ${branch} branch of Simple NixOS MailServer"; - checkinterval = 300; - enabled = "1"; - schedulingshares = 100; - enableemail = false; - emailoverride = ""; - keepnr = 3; - hidden = false; - type = 1; - flake = "gitlab:simple-nixos-mailserver/nixos-mailserver/${branch}"; - }; + prJobsets = pkgs.lib.mapAttrs (num: info: + { enabled = 1; + hidden = false; + description = "PR ${num}: ${info.title}"; + nixexprinput = "snm"; + nixexprpath = ".hydra/default.nix"; + checkinterval = 30; + schedulingshares = 20; + enableemail = false; + emailoverride = ""; + keepnr = 1; + type = 0; + inputs = { + snm = { + type = "git"; + value = "${info.target_repo_url} merge-requests/${info.iid}/head"; + emailresponsible = false; + }; + }; + } + ) prs; desc = prJobsets // { - "master" = mkFlakeJobset "master"; - "nixos-24.11" = mkFlakeJobset "nixos-24.11"; - "nixos-25.05" = mkFlakeJobset "nixos-25.05"; + master = { + description = "Build master branch of Simple NixOS MailServer"; + checkinterval = "60"; + enabled = "1"; + nixexprinput = "snm"; + nixexprpath = ".hydra/default.nix"; + schedulingshares = 100; + enableemail = false; + emailoverride = ""; + keepnr = 3; + hidden = false; + type = 0; + inputs = { + snm = { + value = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver master"; + type = "git"; + emailresponsible = false; + }; + }; + }; + "nixos-20.03" = { + description = "Build the nixos-20.03 branch of Simple NixOS MailServer"; + checkinterval = "60"; + enabled = "1"; + nixexprinput = "snm"; + nixexprpath = ".hydra/default.nix"; + schedulingshares = 100; + enableemail = false; + emailoverride = ""; + keepnr = 3; + hidden = false; + type = 0; + inputs = { + snm = { + value = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver nixos-20.03"; + type = "git"; + emailresponsible = false; + }; + }; + }; + "nixos-19.09" = { + description = "Build the nixos-19.09 branch of Simple NixOS MailServer"; + checkinterval = "60"; + enabled = "1"; + nixexprinput = "snm"; + nixexprpath = ".hydra/default.nix"; + schedulingshares = 100; + enableemail = false; + emailoverride = ""; + keepnr = 3; + hidden = false; + type = 0; + inputs = { + snm = { + value = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver nixos-19.09"; + type = "git"; + emailresponsible = false; + }; + }; + }; }; - log = { - pulls = prs; - jobsets = desc; - }; - -in -{ - jobsets = pkgs.runCommand "spec-jobsets.json" { } '' +in { + jobsets = pkgs.runCommand "spec-jobsets.json" {} '' cat >$out <tmp < ~/.config/nix/nix.conf - - proot -b ~/.nix:/nix /bin/sh -c "nix build -L .#optionsDoc && cp -v result docs/options.md" - -sphinx: - configuration: docs/conf.py - -formats: - - pdf - - epub - -python: - install: - - requirements: docs/requirements.txt diff --git a/README.md b/README.md index ef3042a..960e8c5 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,154 @@ # ![Simple Nixos MailServer][logo] - ![license](https://img.shields.io/badge/license-GPL3-brightgreen.svg) [![pipeline status](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/badges/master/pipeline.svg)](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/commits/master) + ## Release branches For each NixOS release, we publish a branch. You then have to use the SNM branch corresponding to your NixOS version. -* For NixOS 25.05 - * Use the [SNM branch `nixos-25.05`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-25.05) - * [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-25.05/) - * [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-25.05/release-notes.html#nixos-25-05) -* For NixOS 24.11 - * Use the [SNM branch `nixos-24.11`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-24.11) - * [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-24.11/) - * [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-24.11/release-notes.html#nixos-24-11) +* For NixOS 20.03 + - Use the [SNM branch `nixos-20.03`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-20.03) + - [Release notes](#nixos-2003) +* For NixOS 19.09 + - Use the [SNM branch `nixos-19.09`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-19.09) * For NixOS unstable - * Use the [SNM branch `master`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/master) - * [Documentation](https://nixos-mailserver.readthedocs.io/en/latest/) + - Use the [SNM branch `master`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/master) + - This branch is currently still supporting the NixOS release 20.03 + but we could remove this support on any NixOS unstable breaking + change. + +[Subscribe to SNM Announcement List](https://www.freelists.org/list/snm) +This is a very low volume list where new releases of SNM are announced, so you +can stay up to date with bug fixes and updates. All announcements are signed by +the gpg key with fingerprint + +``` +D9FE 4119 F082 6F15 93BD BD36 6162 DBA5 635E A16A +``` + ## Features - -* [x] Continous Integration Testing -* [x] Multiple Domains -* Postfix - * [x] SMTP on port 25 - * [x] Submission TLS on port 465 - * [x] Submission StartTLS on port 587 - * [x] LMTP with Dovecot -* Dovecot - * [x] Maildir folders - * [x] IMAP with TLS on port 993 - * [x] POP3 with TLS on port 995 - * [x] IMAP with StartTLS on port 143 - * [x] POP3 with StartTLS on port 110 -* Certificates - * [x] ACME - * [x] Custom certificates -* Spam Filtering - * [x] Via Rspamd -* Virus Scanning - * [x] Via ClamAV -* DKIM Signing - * [x] Via Rspamd -* User Management - * [x] Declarative user management - * [x] Declarative password management - * [x] LDAP users -* Sieve - * [x] Allow user defined sieve scripts - * [x] Moving mails from/to junk trains the Bayes filter - * [x] ManageSieve support -* User Aliases - * [x] Regular aliases - * [x] Catch all aliases +### v2.0 + * [x] Continous Integration Testing + * [x] Multiple Domains + * Postfix MTA + - [x] smtp on port 25 + - [x] submission port 587 + - [x] lmtp with dovecot + * Dovecot + - [x] maildir folders + - [x] imap starttls on port 143 + - [x] pop3 starttls on port 110 + * Certificates + - [x] manual certificates + - [x] on the fly creation + - [x] Let's Encrypt + * Spam Filtering + - [x] via rspamd + * Virus Scanning + - [x] via clamav + * DKIM Signing + - [x] via opendkim + * User Management + - [x] declarative user management + - [x] declarative password management + * Sieves + - [x] A simple standard script that moves spam + - [x] Allow user defined sieve scripts + - [x] ManageSieve support + * User Aliases + - [x] Regular aliases + - [x] Catch all aliases ### In the future -* Automatic client configuration - * [ ] [Autoconfig](https://web.archive.org/web/20210624004729/https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration) - * [ ] [Autodiscovery](https://learn.microsoft.com/en-us/exchange/architecture/client-access/autodiscover?view=exchserver-2019) - * [ ] [Mobileconfig](https://support.apple.com/guide/profile-manager/distribute-profiles-manually-pmdbd71ebc9/mac) -* DKIM Signing - * [ ] Allow per domain selectors - * [ ] Allow passing DKIM signing keys -* Improve the Forwarding Experience - * [ ] Support [ARC](https://en.wikipedia.org/wiki/Authenticated_Received_Chain) signing with [Rspamd](https://rspamd.com/doc/modules/arc.html) - * [ ] Support [SRS](https://en.wikipedia.org/wiki/Sender_Rewriting_Scheme) with [postsrsd](https://github.com/roehling/postsrsd) -* User management - * [ ] Allow local and LDAP user to coexist -* OpenID Connect - * Depends on relevant clients adding support, e.g. [Thunderbird](https://bugzilla.mozilla.org/show_bug.cgi?id=1602166) + * DKIM Signing + - [ ] Allow a per domain selector + +### Changelog and How to Stay Up-to-Date + +See the [mailing list archive](https://www.freelists.org/archive/snm/) + +### Quick Start + +```nix +{ config, pkgs, ... }: +{ + imports = [ + (builtins.fetchTarball { + # 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; + fqdn = "mail.example.com"; + domains = [ "example.com" "example2.com" ]; + loginAccounts = { + "user1@example.com" = { + hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/"; + + aliases = [ + "info@example.com" + "postmaster@example.com" + "postmaster@example2.com" + ]; + }; + }; + }; +} +``` + +For a complete list of options, see `default.nix`. -### Get in touch -* Matrix: [#nixos-mailserver:nixos.org](https://matrix.to/#/#nixos-mailserver:nixos.org) -* IRC: `#nixos-mailserver` on [Libera Chat](https://libera.chat/guides/connect) ## How to Set Up a 10/10 Mail Server Guide +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 + + + +[logo]: logo/logo.png diff --git a/default.nix b/default.nix index 9d97410..02a0348 100644 --- a/default.nix +++ b/default.nix @@ -1,3 +1,4 @@ + # nixos-mailserver: a simple mail server # Copyright (C) 2016-2018 Robin Raymond # @@ -14,111 +15,30 @@ # 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; - description = "Automatically open ports in the firewall."; - }; - fqdn = mkOption { type = types.str; example = "mx.example.com"; description = "The fully qualified domain name of the mail server."; }; - systemName = mkOption { - type = types.str; - default = "${cfg.systemDomain} mail system"; - defaultText = literalExpression "\${config.mailserver.systemDomain} mail system"; - example = "ACME Corp."; - description = '' - The sender name given in automated reports. - ''; - }; - - systemDomain = mkOption { - type = types.str; - default = - if (config.networking.domain != null && lib.elem config.networking.domain cfg.domains) then - config.networking.domain - else - lib.head cfg.domains; - defaultText = literalExpression '' - if config.networking.domain != null && lib.elem config.networking.domain cfg.domains then - config.networking.domain - else - lib.head cfg.domains - ''; - example = literalExpression "config.networking.domain"; - description = '' - The primary domain used for sending automated reports. - ''; - }; - 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 = [ ]; - description = '' - ({option}`mailserver.certificateScheme` == `acme-nginx`) - - Secondary domains and subdomains for which it is necessary to generate a certificate. - ''; - }; - messageSizeLimit = mkOption { type = types.int; example = 52428800; @@ -127,141 +47,121 @@ in }; loginAccounts = mkOption { - type = types.attrsOf ( - types.submodule ( - { name, ... }: - { - options = { - name = mkOption { - type = types.str; - example = "user1@example.com"; - description = "Username"; - }; + type = types.loaOf (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' - ``` + ``` + mkpasswd -m sha-512 "super secret password" + ``` - 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 `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' - ``` - ''; - }; + ``` + mkpasswd -m sha-512 "super secret password" + ``` + ''; + }; - 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/"; @@ -276,296 +176,14 @@ in follows ``` - nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' + mkpasswd -m sha-512 "super secret password" ``` ''; - default = { }; - }; - - ldap = { - enable = mkEnableOption "LDAP support"; - - uris = mkOption { - type = types.listOf types.str; - example = literalExpression '' - [ - "ldaps://ldap1.example.com" - "ldaps://ldap2.example.com" - ] - ''; - description = '' - URIs where your LDAP server can be reached - ''; - }; - - startTls = mkOption { - type = types.bool; - default = false; - description = '' - Whether to enable StartTLS upon connection to the server. - ''; - }; - - 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)"; - description = '' - Certifificate trust anchors used to verify the LDAP server certificate. - ''; - }; - - bind = { - dn = mkOption { - type = types.str; - example = "cn=mail,ou=accounts,dc=example,dc=com"; - description = '' - Distinguished name used by the mail server to do lookups - against the LDAP servers. - ''; - }; - - passwordFile = mkOption { - type = types.str; - example = "/run/my-secret"; - description = '' - A file containing the password required to authenticate against the LDAP servers. - ''; - }; - }; - - searchBase = mkOption { - type = types.str; - example = "ou=people,ou=accounts,dc=example,dc=com"; - description = '' - Base DN at below which to search for users accounts. - ''; - }; - - searchScope = mkOption { - type = types.enum [ - "sub" - "base" - "one" - ]; - default = "sub"; - description = '' - Search scope below which users accounts are looked for. - ''; - }; - - dovecot = { - userAttrs = mkOption { - type = types.nullOr types.str; - default = null; - description = '' - LDAP attributes to be retrieved during userdb lookups. - - See the users_attrs reference at - https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#user-attrs - in the Dovecot manual. - ''; - }; - - userFilter = mkOption { - type = types.str; - default = "mail=%{user}"; - example = "(&(objectClass=inetOrgPerson)(mail=%{user}))"; - description = '' - Filter for user lookups in Dovecot. - - See the user_filter reference at - https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#user-filter - in the Dovecot manual. - ''; - }; - - passAttrs = mkOption { - type = types.str; - default = "userPassword=password"; - description = '' - LDAP attributes to be retrieved during passdb lookups. - - See the pass_attrs reference at - https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#pass-attrs - in the Dovecot manual. - ''; - }; - - passFilter = mkOption { - type = types.nullOr types.str; - default = "mail=%{user}"; - example = "(&(objectClass=inetOrgPerson)(mail=%{user}))"; - description = '' - Filter for password lookups in Dovecot. - - See the pass_filter reference for - https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_settings_auth/#pass-filter - in the Dovecot manual. - ''; - }; - }; - - postfix = { - filter = mkOption { - type = types.str; - default = "mail=%s"; - example = "(&(objectClass=inetOrgPerson)(mail=%s))"; - description = '' - LDAP filter used to search for an account by mail, where - `%s` is a substitute for the address in - question. - ''; - }; - - uidAttribute = mkOption { - type = types.str; - default = "mail"; - example = "uid"; - description = '' - The LDAP attribute referencing the account name for a user. - ''; - }; - - mailAttribute = mkOption { - type = types.str; - default = "mail"; - description = '' - The LDAP attribute holding mail addresses for a user. - ''; - }; - }; - }; - - indexDir = mkOption { - type = types.nullOr types.str; - default = null; - description = '' - Folder to store search indices. If null, indices are stored - along with email, which could not necessarily be desirable, - especially when {option}`mailserver.fullTextSearch.enable` is `true` since - indices it creates are voluminous and do not need to be backed - up. - - Be careful when changing this option value since all indices - would be recreated at the new location (and clients would need - to resynchronize). - - Note the some variables can be used in the file path. See - https://doc.dovecot.org/2.3/configuration_manual/mail_location/#variables - for details. - ''; - example = "/var/lib/dovecot/indices"; - }; - - 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. - ''; - }; - - autoIndex = mkOption { - type = types.bool; - default = true; - description = "Enable automatic indexing of messages as they are received or modified."; - }; - autoIndexExclude = mkOption { - type = types.listOf types.str; - default = [ ]; - example = [ - "\\Trash" - "SomeFolder" - "Other/*" - ]; - description = '' - Mailboxes to exclude from automatic indexing. - ''; - }; - - enforced = mkOption { - type = types.enum [ - "yes" - "no" - "body" - ]; - default = "no"; - description = '' - Fail searches when no index is available. If set to - `body`, then only body searches (as opposed to - header) are affected. If set to `no`, searches may - fall back to a very slow brute force search. - ''; - }; - - 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 . - ''; - }; - - substringSearch = mkOption { - type = types.bool; - default = false; - description = '' - If enabled, allows substring searches. - See . - - Enabling this requires significant additional storage space. - ''; - }; - - 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. - . - ''; - }; + default = {}; }; 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 @@ -574,39 +192,20 @@ 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 = types.loaOf (mkOptionType { + name = "Login Account"; + check = (ele: + let accounts = builtins.attrNames cfg.loginAccounts; + in if (builtins.isList ele) + then (builtins.all (x: builtins.elem x accounts) ele) && (builtins.length ele > 0) + else (builtins.elem ele accounts)); + }); example = { "info@example.com" = "user1@example.com"; "postmaster@example.com" = "user1@example.com"; "abuse@example.com" = "user1@example.com"; - "multi@example.com" = [ - "user1@example.com" - "user2@example.com" - ]; + "multi@example.com" = [ "user1@example.com" "user2@example.com" ]; }; description = '' Virtual Aliases. A virtual alias `"info@example.com" = "user1@example.com"` means that @@ -619,51 +218,28 @@ in example all mails for `multi@example.com` will be forwarded to both `user1@example.com` and `user2@example.com`. ''; - default = { }; - }; - - forwards = mkOption { - type = with types; attrsOf (either (listOf str) str); - example = { - "user@example.com" = "user@elsewhere.com"; - }; - description = '' - To forward mails to an external address. For instance, - the value {`"user@example.com" = "user@elsewhere.com";}` - means that mails to `user@example.com` are forwarded to - `user@elsewhere.com`. The difference with the - {option}`mailserver.extraVirtualAliases` option is that `user@elsewhere.com` - 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 { @@ -672,7 +248,7 @@ in description = '' The unix UID of the virtual mail user. Be mindful that if this is changed, you will need to manually adjust the permissions of - `mailDirectory`. + mailDirectory. ''; }; @@ -711,15 +287,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. ''; }; @@ -732,7 +300,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. ''; }; @@ -741,74 +309,55 @@ in The mailboxes for dovecot. Depending on the mail client used it might be necessary to change some mailbox's name. ''; - default = { - Trash = { + default = [ + { + name = "Trash"; auto = "no"; specialUse = "Trash"; - }; - Junk = { + } + + { + name = "Junk"; auto = "subscribe"; specialUse = "Junk"; - }; - Drafts = { + } + + { + name = "Drafts"; auto = "subscribe"; specialUse = "Drafts"; - }; - Sent = { + } + + { + name = "Sent"; auto = "subscribe"; specialUse = "Sent"; - }; - }; + } + ]; }; - 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 = mkOption { + type = types.enum [ 1 2 3 ]; + default = 2; + description = '' + Certificate Files. There are three options for these. - 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) You specify locations and manually copy certificates there. + 2) You let the server create new (self signed) certificates on the fly. + 3) You let the server create a certificate via `Let's Encrypt`. Note that + this implies that a stripped down webserver has to be started. This also + implies that the FQDN must be set as an `A` record to point to the IP of + the server. In particular port 80 on the server will be opened. For details + on how to set up the domain records, see the guide in the readme. + ''; + }; certificateFile = mkOption { type = types.path; example = "/root/mail-server.crt"; description = '' - ({option}`mailserver.certificateScheme` == `manual`) - - Location of the certificate. + Scheme 1) + Location of the certificate ''; }; @@ -816,9 +365,8 @@ in type = types.path; example = "/root/mail-server.key"; description = '' - ({option}`mailserver.certificateScheme` == `manual`) - - Location of the key file. + Scheme 1) + Location of the key file ''; }; @@ -826,65 +374,32 @@ in type = types.path; default = "/var/certs"; description = '' - ({option}`mailserver.certificateScheme` == `selfsigned`) - - This is the folder where the self-signed certificate will be created. The name is - hardcoded to "cert-DOMAIN.pem" and "key-DOMAIN.pem" and the + Sceme 2) + This is the folder where the certificate will be created. The name is + hardcoded to "cert-.pem" and "key-.pem" and the certificate is valid for 10 years. ''; }; - acmeCertificateName = mkOption { - type = types.str; - default = cfg.fqdn; - defaultText = literalExpression "config.mailserver.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; description = '' - Whether to enable IMAP with STARTTLS on port 143. - ''; - }; + Whether to enable imap / pop3. Both variants are only supported in the + (sane) startTLS configuration. The ports are - imapMemoryLimit = mkOption { - type = types.int; - default = 256; - description = '' - The memory limit for the imap service, in megabytes. + 110 - Pop3 + 143 - IMAP + 587 - SMTP with login ''; }; enableImapSsl = mkOption { type = types.bool; - default = true; + default = false; description = '' - Whether to enable IMAP with TLS in wrapper-mode on port 993. - ''; - }; - - enableSubmission = mkOption { - type = types.bool; - default = true; - description = '' - Whether to enable SMTP with STARTTLS on port 587. - ''; - }; - - enableSubmissionSsl = mkOption { - type = types.bool; - default = true; - description = '' - Whether to enable SMTP with TLS in wrapper-mode on port 465. + Whether to enable IMAPS, setting this option to true will open port 993 + in the firewall. ''; }; @@ -892,7 +407,12 @@ in type = types.bool; default = false; description = '' - Whether to enable POP3 with STARTTLS on port on port 110. + Whether to enable POP3. Both variants are only supported in the (sane) + startTLS configuration. The ports are + + 110 - Pop3 + 143 - IMAP + 587 - SMTP with login ''; }; @@ -900,7 +420,8 @@ in type = types.bool; default = false; description = '' - Whether to enable POP3 with TLS in wrapper-mode on port 995. + Whether to enable POP3S, setting this option to true will open port 995 + in the firewall. ''; }; @@ -916,14 +437,6 @@ in ''; }; - sieveDirectory = mkOption { - type = types.path; - default = "/var/sieve"; - description = '' - Where to store the sieve scripts. - ''; - }; - virusScanning = mkOption { type = types.bool; default = false; @@ -945,7 +458,7 @@ in type = types.str; default = "mail"; description = '' - The DKIM selector. + ''; }; @@ -953,93 +466,33 @@ in type = types.path; default = "/var/dkim"; description = '' - The DKIM directory. - ''; - }; - dkimKeyType = mkOption { - type = types.enum [ - "rsa" - "ed25519" - ]; - default = "rsa"; - description = '' - The key type used for generating DKIM keys. ED25519 was introduced in RFC6376 (2018). - - 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. ''; }; dkimKeyBits = mkOption { - type = types.int; - default = 2048; + 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 number of bits than specified + here, then you should use a different selector (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. + ''; + }; + + debug = mkOption { + type = types.bool; + default = false; description = '' - How many bits in generated DKIM keys. RFC8301 suggests a minimum RSA key length of 2048 bit. - - 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. + Whether to enable verbose logging for mailserver related services. This + intended be used for development purposes only, you probably don't want + to enable this unless you're hacking on nixos-mailserver. ''; }; - dmarcReporting = { - enable = mkOption { - type = types.bool; - default = false; - description = '' - Whether to send out aggregated, daily DMARC reports in response to incoming - mail, when the sender domain defines a DMARC policy including the RUA tag. - - This is helpful for the mail ecosystem, because it allows third parties to - get notified about SPF/DKIM violations originating from their sender domains. - - See https://rspamd.com/doc/modules/dmarc.html#reporting - ''; - }; - - excludeDomains = mkOption { - type = types.listOf types.str; - default = [ ]; - description = '' - List of domains or eSLDs to be excluded from DMARC reports. - ''; - }; - }; - - debug = { - all = mkOption { - type = types.bool; - default = false; - description = '' - Whether to enable verbose logging for all mailserver related services. - This intended be used for development purposes only, you probably - don't want to enable this unless you're hacking on nixos-mailserver. - ''; - }; - - dovecot = mkOption { - type = types.bool; - default = cfg.debug.all; - defaultText = lib.literalExpression "config.mailserver.debug.all"; - description = '' - Whether to enable verbose logging for Dovecot. - ''; - }; - - rspamd = mkOption { - type = types.bool; - default = cfg.debug.all; - defaultText = lib.literalExpression "config.mailserver.debug.all"; - description = '' - Whether to enable verbose logging for Rspamd. - ''; - }; - }; - maxConnectionsPerUser = mkOption { type = types.int; default = 100; @@ -1058,48 +511,42 @@ in ''; }; - recipientDelimiter = mkOption { - type = types.str; - default = "+"; - description = '' - Configure the recipient delimiter. - ''; - }; - 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.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; description = '' - Path, IP address or hostname that Rspamd should use to contact Redis. + Address that rspamd should use to contact redis. The default value + is read from config.services.redis.bind. ''; }; port = mkOption { - type = with types; nullOr port; - default = null; - example = literalExpression "config.services.redis.servers.rspamd.port"; + type = types.port; + default = config.services.redis.port; description = '' - Port that Rspamd should use to contact Redis. + Port that rspamd should use to contact redis. The default value is + read from config.services.redis.port. ''; }; password = mkOption { type = types.nullOr types.str; - default = config.services.redis.servers.rspamd.requirePass; - defaultText = literalExpression "config.services.redis.servers.rspamd.requirePass"; + default = config.services.redis.requirePass; description = '' - Password that rspamd should use to contact redis, or null if not required. + Password that rspamd should use to contact redis, or null if not + required. The default value is read from + config.services.redis.requirePass. ''; }; }; @@ -1114,30 +561,15 @@ in ''; }; - sendingFqdn = mkOption { - type = types.str; - default = cfg.fqdn; - defaultText = literalMD "{option}`mailserver.fqdn`"; - example = "myserver.example.com"; + policydSPFExtraConfig = mkOption { + type = types.lines; + default = ""; + example = '' + skip_addresses = 127.0.0.0/8,::ffff:127.0.0.0/104,::1 + ''; description = '' - The fully qualified domain name of the mail server used to - identify with remote servers. - - If this server's IP serves purposes other than a mail server, - it may be desirable for the server to have a name other than - that to which the user will connect. For example, the user - might connect to mx.example.com, but the server's IP has - reverse DNS that resolves to myserver.example.com; in this - scenario, some mail servers may reject or penalize the - message. - - This setting allows the server to identify as - myserver.example.com when forwarding mail, independently of - {option}`mailserver.fqdn` (which, for SSL reasons, should generally be the name - to which the user connects). - - Set this to the name to which the sending IP's reverse DNS - resolves. + Extra configuration options for policyd-spf. This can be use to among + other things skip spf checking for some IP addresses. ''; }; @@ -1189,11 +621,10 @@ in stop program = "${pkgs.systemd}/bin/systemctl stop dovecot2" if failed host ${cfg.fqdn} port 993 type tcpssl sslauto protocol imap for 5 cycles then restart - check process rspamd with matching "rspamd: main process" + check process rspamd with pidfile /var/run/rspamd.pid 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)"; description = '' The configuration used for monitoring via monit. Use a mail address that you actively check and set it via 'set alert ...'. @@ -1210,8 +641,7 @@ in description = '' The location where borg saves the backups. This can be a local path or a remote location such as user@host:/path/to/repo. - It is exported and thus available as an environment variable to - {option}`mailserver.borgbackup.cmdPreexec` and {option}`mailserver.borgbackup.cmdPostexec`. + It is exported and thus available as an environment variable to cmdPreexec and cmdPostexec. ''; }; @@ -1235,15 +665,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."; }; @@ -1279,14 +701,13 @@ in default = "none"; description = '' The backup can be encrypted by choosing any other value than 'none'. - When using encryption the password/passphrase must be provided in `passphraseFile`. + When using encryption the password / passphrase must be provided in passphraseFile. ''; }; passphraseFile = mkOption { type = types.nullOr types.path; default = null; - description = "Path to a file containing the encryption password or passphrase."; }; }; @@ -1301,14 +722,13 @@ in locations = mkOption { type = types.listOf types.path; - default = [ cfg.mailDirectory ]; - defaultText = literalExpression "[ config.mailserver.mailDirectory ]"; + default = [cfg.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."; }; @@ -1323,10 +743,9 @@ in default = null; description = '' The command to be executed before each backup operation. - This is called prior to borg init in the same script that runs borg init and create and `cmdPostexec`. - ''; - example = '' - export BORG_RSH="ssh -i /path/to/private/key" + This is called prior to borg init in the same script that runs borg init and create and cmdPostexec. + Example: + export BORG_RSH="ssh -i /path/to/private/key" ''; }; @@ -1336,12 +755,33 @@ in description = '' The command to be executed after each backup operation. This is called after borg create completed successfully and in the same script that runs - `cmdPreexec`, borg init and create. + cmdPreexec, borg init and create. ''; }; }; + 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"; @@ -1388,9 +828,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 = '' @@ -1403,32 +843,6 @@ 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/rsnapshot.nix ./mail-server/clamav.nix @@ -1438,36 +852,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. - '') - (mkRenamedOptionModule [ "mailserver" "dmarcReporting" "domain" ] [ "mailserver" "systemDomain" ]) - (mkRenamedOptionModule - [ "mailserver" "dmarcReporting" "organizationName" ] - [ "mailserver" "systemName" ] - ) - (mkRemovedOptionModule [ "mailserver" "dmarcReporting" "localpart" ] '' - The localpart is now fixed at `noreply-dmarc` to simplify the configuration. - '') - (mkRemovedOptionModule [ "mailserver" "dmarcReporting" "email" ] '' - The address is now fixed at `noreply-dmarc@''${config.mailserver.systemDomain}` to simplify the configuration. - '') - (mkRemovedOptionModule [ "mailserver" "dmarcReporting" "fromName" ] '' - The name in the `FROM` field for DMARC report now uses the `mailserver.systemName`. - '') + ./mail-server/post-upgrade-check.nix ]; } diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d4bb2cb..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/add-roundcube.rst b/docs/add-roundcube.rst deleted file mode 100644 index 6b10d5b..0000000 --- a/docs/add-roundcube.rst +++ /dev/null @@ -1,32 +0,0 @@ -Add Roundcube, a webmail -======================== - -The NixOS module for roundcube nearly works out of the box with SNM. By -default, it sets up a nginx virtual host to serve the webmail, other web -servers may require more work. - -.. code:: nix - - { config, pkgs, lib, ... }: - - with lib; - - { - services.roundcube = { - enable = true; - # this is the url of the vhost, not necessarily the same as the fqdn of - # the mailserver - hostName = "webmail.example.com"; - extraConfig = '' - # starttls needed for authentication, so the fqdn required to match - # the certificate - $config['smtp_host'] = "tls://${config.mailserver.fqdn}"; - $config['smtp_user'] = "%u"; - $config['smtp_pass'] = "%p"; - ''; - }; - - services.nginx.enable = true; - - networking.firewall.allowedTCPPorts = [ 80 443 ]; - } 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/autodiscovery.rst b/docs/autodiscovery.rst deleted file mode 100644 index a630850..0000000 --- a/docs/autodiscovery.rst +++ /dev/null @@ -1,18 +0,0 @@ -Autodiscovery -============= - -`RFC6186 `_ allows supporting email clients to automatically discover SMTP / IMAP addresses -of the mailserver. For that, the following records are required: - -================= ==== ==== ======== ====== ==== ================= -Record TTL Type Priority Weight Port Value -================= ==== ==== ======== ====== ==== ================= -_submission._tcp 3600 SRV 5 0 587 mail.example.com. -_submissions._tcp 3600 SRV 5 0 465 mail.example.com. -_imap._tcp 3600 SRV 5 0 143 mail.example.com. -_imaps._tcp 3600 SRV 5 0 993 mail.example.com. -================= ==== ==== ======== ====== ==== ================= - -Please note that only a few MUAs currently implement this. For vendor-specific -discovery mechanisms `automx `_ can be used instead. - diff --git a/docs/backup-guide.rst b/docs/backup-guide.rst index 67d08d0..57acc2a 100644 --- a/docs/backup-guide.rst +++ b/docs/backup-guide.rst @@ -1,9 +1,9 @@ -Backup Guide -============ +A Complete Backup Guide +======================= -First off you should have a backup of your ``configuration.nix`` file -where you have the server config (but that is already in a git -repository right?) +This is really easy. First off you should have a backup of your +``configuration.nix`` file where you have the server config (but that is +already in a git repository right?) Next you need to backup ``/var/vmail`` or whatever you have specified for the option ``mailDirectory``. This is where all the mails reside. @@ -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..84eb68b 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 = '2020, NixOS Mailserver Contributors' +author = 'NixOS Mailserver Contributors' # -- General configuration --------------------------------------------------- @@ -27,33 +27,27 @@ 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"] - -myst_enable_extensions = [ - "colon_fence", - "linkify", +extensions = [ ] -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, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = [] +html_static_path = ['_static'] diff --git a/docs/faq.rst b/docs/faq.rst deleted file mode 100644 index ef306de..0000000 --- a/docs/faq.rst +++ /dev/null @@ -1,22 +0,0 @@ -FAQ -=== - -``catchAll`` users can't send email as user other than themself ---------------------------------------------------------------- - -To allow a ``catchAll`` user to send mail with the address used as -recipient, the option ``aliases`` has to be used instead of ``catchAll``. - -For instance, to allow ``user@example.com`` to catch all mails to the -domain ``example.com`` and send mails with any address of this domain: - - -.. code:: nix - - mailserver.loginAccounts = { - "user@example.com" = { - aliases = [ "@example.com" ]; - }; - }; - -See also `this discussion `__ for details. diff --git a/docs/flakes.rst b/docs/flakes.rst deleted file mode 100644 index f56ec96..0000000 --- a/docs/flakes.rst +++ /dev/null @@ -1,30 +0,0 @@ -Nix Flakes -========== - -If you're using `flakes `__, you can use -the following minimal ``flake.nix`` as an example: - -.. code:: nix - - { - description = "NixOS configuration"; - - inputs.simple-nixos-mailserver.url = "gitlab:simple-nixos-mailserver/nixos-mailserver/nixos-20.09"; - - outputs = { self, nixpkgs, simple-nixos-mailserver }: { - nixosConfigurations = { - hostname = nixpkgs.lib.nixosSystem { - system = "x86_64-linux"; - modules = [ - simple-nixos-mailserver.nixosModule - { - mailserver = { - enable = true; - # ... - }; - } - ]; - }; - }; - }; - } diff --git a/docs/fts.rst b/docs/fts.rst deleted file mode 100644 index bb2fe88..0000000 --- a/docs/fts.rst +++ /dev/null @@ -1,67 +0,0 @@ -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``. - -Enabling full text search -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To enable indexing for full text search here is an example configuration. - -.. code:: nix - - { - mailserver = { - # ... - fullTextSearch = { - enable = true; - # index new email as they arrive - autoIndex = true; - enforced = "body"; - }; - }; - } - - -The ``enforced`` parameter tells dovecot to fail any body search query that cannot -use an index. This prevents dovecot to fall back to the IO-intensive brute -force search. - -If you set ``autoIndex`` to ``false``, indices will be created when the IMAP client -issues a search query, so latency will be high. - -Resource requirements -~~~~~~~~~~~~~~~~~~~~~~~~ - -Indices created by the full text search feature can take more disk -space than the emails themselves. By default, they are kept in the -emails location. When enabling the full text search feature, it is -recommended to move indices in a different location, such as -(``/var/lib/dovecot/indices``) by using the option -``mailserver.indexDir``. - -.. warning:: - - When the value of the ``indexDir`` option is changed, all dovecot - indices needs to be recreated: clients would need to resynchronize. - -Indexation itself is rather resouces intensive, in CPU, and for emails with -large headers, in memory as well. Initial indexation of existing emails can take -hours. If the indexer worker is killed or segfaults during indexation, it can -be that it tried to allocate more memory than allowed. You can increase the memory -limit by eg ``mailserver.fullTextSearch.memoryLimit = 2000`` (in MiB). - -Mitigating resources requirements -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -You can: - -* exclude some headers from indexation with ``mailserver.fullTextSearch.headerExcludes`` -* disable expensive token normalisation in ``mailserver.fullTextSearch.filters`` -* 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/add-radicale.rst b/docs/howto-add-radicale.rst similarity index 69% rename from docs/add-radicale.rst rename to docs/howto-add-radicale.rst index cf98333..824e363 100644 --- a/docs/add-radicale.rst +++ b/docs/howto-add-radicale.rst @@ -1,12 +1,8 @@ -Add Radicale -============ +How to Add Radicale to SNM +========================== Configuration by @dotlambda -Starting with Radicale 3 (first introduced in NixOS 20.09) the traditional -crypt passwords are no longer supported. Instead bcrypt passwords -have to be used. These can still be generated using `mkpasswd -m bcrypt`. - .. code:: nix { config, pkgs, lib, ... }: @@ -24,13 +20,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 = crypt + ''; }; services.nginx = { diff --git a/docs/howto-develop.rst b/docs/howto-develop.rst index 700f9d0..173c4f3 100644 --- a/docs/howto-develop.rst +++ b/docs/howto-develop.rst @@ -1,107 +1,58 @@ -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. +How to Develop SNM +================== Run NixOS tests --------------- -To run the test suite, you need to enable `Nix Flakes -`__. - -You can then run the testsuite via +You can run the testsuite via :: - $ nix flake check -L - -Since Nix doesn't garantee your machine have enough resources to run -all test VMs in parallel, some tests can fail. You would then haev to -run tests manually. For instance: - -:: - - $ nix build .#hydraJobs.x86_64-linux.external-unstable -L - + $ nix-build tests -A extern.nixpkgs_20_03 + $ nix-build tests -A intern.nixpkgs_unstable + ... Contributing to the documentation --------------------------------- -The documentation is written in RST (except option documentation which is in CommonMark), -built with Sphinx and published by `Read the Docs `_. +The documentation is written in RST, build with Sphinx and published +by `Read the Docs `_. -For the syntax, see the `RST/Sphinx primer -`_. - -To build the documentation, you need to enable `Nix Flakes -`__. +For the syntax, see `RST/Sphinx Cheatsheet +`_. +The ``shell.nix`` provides all the tooling required to build the +documentation: :: - $ nix build .#documentation - $ xdg-open result/index.html + $ nix-shell + $ cd docs + $ make html + $ firefox ./_build/html/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 != null -> config.mailserver.stateVersion >= 1; - message = '' - Problem: The home directory for the foobar service is snafu. - Remediation: - - Stop the `foobar.service` - - Rename `/var/lib/foobaz` to `/var/lib/foobar` - - Increase the `mailserver.stateVersion` to 1. +:: - 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..9defd98 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,33 +6,19 @@ Welcome to NixOS Mailserver's documentation! ============================================ -.. image:: logo.png +.. image:: ../logo/logo.png :width: 400 :alt: SNM Logo .. toctree:: :maxdepth: 2 + quick-start setup-guide - advanced-configurations howto-develop - faq - release-notes - options - migrations - -.. toctree:: - :maxdepth: 1 - :caption: How-to - backup-guide - add-radicale - add-roundcube + howto-add-radicale rspamd-tuning - 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 6c186b0..0000000 --- a/docs/migrations.rst +++ /dev/null @@ -1,114 +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 ------------ - -#3 Dovecot mail directory migration -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The way the Dovecot home directory for login accounts were previously set up -resulted in shared home directories for all those users. This is not a -supported Dovecot configuration. - -To resolve this we migrated the home directory into the individual -`domain/localpart` subdirectory below the `mailserver.mailDirectory`. - -But since this now overlaps with the location of the Maildir, it must be -migrated into the `mail/` directory below the home directory. -And while the LDAP home directory is not affected we use this migration to -keep the Maildir configurations of LDAP users in sync with those of local -accounts. - -This is a big step forward, since we can now more cleanly colocate other -data directories, like sieve in the home directory, which in turn simplifies -backups. - -This migration is required for every configuration. - -For remediating this issue the following steps are required: - -1. Copy the `migration script `_ script to your mailserver - and make it executable: - - .. code-block:: bash - - wget https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/master/migrations/nixos-mailserver-migration-03.py - chmod +x nixos-mailserver-migration-03.py - -2. Stop the ``dovecot2.service``. - - .. code-block:: bash - - systemctl stop dovecot2.service - -3. Create a backup or snapshot of your ``mailserver.mailDirectory``, so you can restore - should anything go wrong. - -4. Run the migration script under your virtual mail user with the following arguments: - - - ``--layout default`` unless ``useFSLayout`` is enabled, then ``--layout folder`` - - The value of ``mailserver.mailDirectory``, which defaults to ``/var/vmail`` - - The script will not modify your data unless called with ``--execute``. - - Example: - - .. code-block:: bash - - sudo -u virtualMail ./nixos-mailserver-migration-03.py --layout default /var/vmail - -5. Review the commands. They should be - - - create a ``mail`` directory for each accounnt, - - move maildir contents from the parent directory into it, - - suggest removal of files that do not belong to the maildir - - - their removal is not mandatory and the script **will not** remove them when called with ``--execute`` - - review these items carefully if you want to remove them yourself - - - remove obsolete files from the old home directory location - -6. Rerun the command with ``--execute`` or run the commands manually. - -7. Update the ``mailserver.stateVersion`` to ``3``. - -#2 Dovecot LDAP home directory migration -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The Dovecot configuration for LDAP home directories previously did not respect -the ``mailserver.mailDirectory`` setting. - -This means that home directories were unconditionally located at -``/var/vmail/ldap/%{user}``. - -This migration is required if you both: - -* enabled the LDAP integration (``mailserver.ldap.enable``) -* and customized the default mail directory (``mailserver.mailDirectory != "/var/vmail"``) - -For remediating this issue the following steps are required: - -1. Stop ``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/quick-start.rst b/docs/quick-start.rst new file mode 100644 index 0000000..367ff13 --- /dev/null +++ b/docs/quick-start.rst @@ -0,0 +1,32 @@ +Quick Start +=========== + +.. code:: nix + + { config, pkgs, ... }: + { + imports = [ + (builtins.fetchTarball { + url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/v2.2.1/nixos-mailserver-v2.2.1.tar.gz"; + sha256 = "03d49v8qnid9g9rha0wg2z6vic06mhp0b049s3whccn1axvs2zzx"; + }) + ]; + + + mailserver = { + enable = true; + fqdn = "mail.example.com"; + domains = [ "example.com" "example2.com" ]; + loginAccounts = { + "user1@example.com" = { + hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/"; + + aliases = [ + "info@example.com" + "postmaster@example.com" + "postmaster@example2.com" + ]; + }; + }; + }; + } diff --git a/docs/release-notes.rst b/docs/release-notes.rst deleted file mode 100644 index 7a48dd4..0000000 --- a/docs/release-notes.rst +++ /dev/null @@ -1,123 +0,0 @@ -Release Notes -============= - -NixOS 25.11 ------------ - -- The ``systemName`` and ``systemDomain`` options have been introduced to have - reusable configurations for automated reports (DMARC, TLSRPT). They come with - reasonable defaults, but it is suggested to check and change them as needed. -- The default key length for new DKIM RSA keys was increased to 2048 bits as - recommended in `RFC 8301 3.2`_. - We recommend rotating existing keys, as the RFC advises that signatures from - 1024 bit keys should not be considered valid any longer. -- DMARC reports are now sent with the ``noreply-dmarc`` localpart from the - system domain. - -.. _RFC 8301 3.2: https://www.rfc-editor.org/rfc/rfc8301#section-3.2 - -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 - (`merge request `__) - -NixOS 22.05 ------------ - -- Make NixOS Mailserver options discoverable from search.nixos.org -- Add a roundcube setup guide in the documentation - -NixOS 21.11 ------------ - -- Switch default DKIM body policy from simple to relaxed - (`merge request `__) -- Ensure locally-delivered mails have the X-Original-To header - (`merge request `__) -- NixOS Mailserver options are detailed in the `documentation - `__ -- New options ``dkimBodyCanonicalization`` and - ``dkimHeaderCanonicalization`` -- New option ``certificateDomains`` to generate certificate for - additional domains (such as ``imap.example.com``) - -NixOS 21.05 ------------ - -- New `fullTextSearch` option to search in messages (based on Xapian) - (`Merge Request `__) -- Flake support - (`Merge Request `__) -- New `openFirewall` option defaulting to `true` -- We moved from Freenode to Libera Chat - -NixOS 20.09 ------------ - -- IMAP and Submission with TLS wrapped-mode are now enabled by default - on ports 993 and 465 respectively -- OpenDKIM is now sandboxed with Systemd -- New `forwards` option to forwards emails to external addresses - (`Merge Request `__) -- New `sendingFqdn` option to specify the fqdn of the machine sending - email (`Merge Request `__) -- Move the Gitlab wiki to `ReadTheDocs - `_ diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index c77dd1e..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -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..a119a34 100644 --- a/docs/rspamd-tuning.rst +++ b/docs/rspamd-tuning.rst @@ -1,19 +1,19 @@ -Tune spam filtering -=================== +How to tune spam filtering +========================== SNM comes with the `rspamd spam filtering system `_ enabled by default. Although its out-of-the-box performance is good, you can increase its efficiency by tuning its behaviour. -Auto-learning -~~~~~~~~~~~~~ +A) Auto-learning +~~~~~~~~~~~~~~~~ Moving spam email to the Junk folder (and false-positives out of it) will trigger an automatic training of the Bayesian filters, improving filtering of future emails. -Train from existing folders -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +B) Train from existing folders +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you kept previous spam, you can train the filter from it. Note that the `rspamd FAQ `_ @@ -24,17 +24,20 @@ 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 -~~~~~~~~~~~~~~~~~~ +C) Tune symbol weight +~~~~~~~~~~~~~~~~~~~~~ The ``X-Spamd-Result`` header is automatically added to your emails, detailing the scoring decisions. The `modules documentation `_ @@ -49,8 +52,8 @@ details the meaning of each symbol. You can tune the weight if a symbol if neede }''; }; -Tune action thresholds -~~~~~~~~~~~~~~~~~~~~~~ +D) Tune action thresholds +~~~~~~~~~~~~~~~~~~~~~~~~~ After scoring the message, rspamd decides on an action based on configurable thresholds. By default, rspamd will tell postfix to reject any message with a score higher than 15. @@ -68,8 +71,8 @@ this behaviour by tuning the configuration. For example: ''; -Access the rspamd web UI -~~~~~~~~~~~~~~~~~~~~~~~~ +E) Access the rspamd web UI +~~~~~~~~~~~~~~~~~~~~~~~~~~~ Rspamd comes with `a web interface `_ that displays statistics and history of past scans. **We do NOT recommend using it to change the configuration** diff --git a/docs/setup-guide.rst b/docs/setup-guide.rst index 4312373..35e3f5a 100644 --- a/docs/setup-guide.rst +++ b/docs/setup-guide.rst @@ -1,232 +1,234 @@ -Setup Guide -=========== +A Complete Setup Guide +====================== Mail servers can be a tricky thing to set up. This guide is supposed to run you through the most important steps to achieve a 10/10 score on -``_. +``mail-tester.com``. -What you need is: +What you need: -- a server running NixOS with a public IP -- a domain name. +- A server with a public IP (referred to as ``server-IP``) +- A Fully Qualified Domain Name (``FQDN``) where your server is + reachable, so that other servers can find yours. Common FQDN include + ``mx.example.com`` (where ``example.com`` is a domain you own) or + ``mail.example.com``. The domain is referred to as ``server-domain`` + (``example.com`` in the above example) and the ``FQDN`` is referred + to by ``server-FQDN`` (``mx.example.com`` above). +- A list of domains you want to your email server to serve. (Note that + this does not have to include ``server-domain``, but may of course). + These will be referred to as ``domains``. As an example, + ``domains = [ example1.com, example2.com ]``. -.. note:: - - In the following, we consider a server with the public IP ``1.2.3.4`` - and the domain ``example.com``. - -First, we will set the minimum DNS configuration to be able to deploy -an up and running mail server. Once the server is deployed, we could -then set all DNS entries required to send and receive mails on this -server. - -Setup DNS A/AAAA records for server -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Add DNS records 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 - -Note that it can take a while until a DNS entry is propagated. This -DNS entry is required for the Let's Encrypt certificate generation -(which is used in the below configuration example). - -Setup the server -~~~~~~~~~~~~~~~~ +A) Setup 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 ``default.nix``), 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 = 3; - fqdn = "mail.example.com"; - domains = [ "example.com" ]; + fqdn = ; + domains = [ ]; # A list of all login accounts. To create the password hashes, use - # nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' + # mkpasswd -m sha-512 "super secret password" loginAccounts = { - "user1@example.com" = { - hashedPasswordFile = "/a/file/containing/a/hashed/password"; - aliases = ["postmaster@example.com"]; - }; - "user2@example.com" = { ... }; + "user1@example.com" = { + hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/"; + + aliases = [ + "postmaster@example.com" + "postmaster@example2.com" + ]; + + # Make this user the catchAll address for domains example.com and + # example2.com + catchAll = [ + "example.com" + "example2.com" + ]; + }; + + "user2@example.com" = { ... }; + }; + + # Extra virtual aliases. These are email addresses that are forwarded to + # loginAccounts addresses. + extraVirtualAliases = { + # address = forward address; + "abuse@example.com" = "user1@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"; + certificateScheme = 3; + + # Enable IMAP and POP3 + enableImap = true; + enablePop3 = true; + enableImapSsl = true; + enablePop3Ssl = true; + + # Enable the ManageSieve protocol + enableManageSieve = true; + + # whether to scan inbound emails for viruses (note that this requires at least + # 1 Gb RAM for the server. Without virus scanning 256 MB RAM should be plenty) + virusScanning = false; }; - security.acme.acceptTerms = true; - security.acme.defaults.email = "security@example.com"; } -After a ``nixos-rebuild switch`` your server should be running all -mail components. +After a ``nixos-rebuild switch --upgrade`` your server should be good to +go. If you want to use ``nixops`` to deploy the server, look in the +subfolder ``nixops`` for some inspiration. -Setup all other DNS requirements -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +B) Setup everything else +~~~~~~~~~~~~~~~~~~~~~~~~ -Set rDNS (reverse DNS) entry for server -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Step 1: Set DNS entry for server +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Add a DNS record to the domain ``server-domain`` with the following +entries + +================ ===== ==== ======== ============= +Name (Subdomain) TTL Type Priority Value +================ ===== ==== ======== ============= +``server-FQDN`` 10800 A ``server-IP`` +================ ===== ==== ======== ============= + +This resolves DNS queries for ``server-FQDN`` to ``server-IP``. You can +test if your setting is correct by + +:: + + ping + 64 bytes from (): icmp_seq=1 ttl=46 time=21.3 ms + ... + +Note that it can take a while until a DNS entry is propagated. + +Step 2: 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: +DNS entries for the IP’s you own. Add an entry resolving ``server-IP`` +to ``server-FQDN`` -- 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. - -.. warning:: - - We don't recommend setting up a mail server if you are not able to - set a reverse DNS on your public IP because sent emails would be - mostly marked as spam. Note that many residential ISP providers - don't allow you to set a reverse DNS entry. - -You can check this with +You can test if your setting is correct by :: - $ 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. + host + .in-addr.arpa domain name pointer . Note that it can take a while until a DNS entry is propagated. -Set a ``MX`` record -^^^^^^^^^^^^^^^^^^^ +Step 3: Set ``MX`` Records +^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Add a ``MX`` record to the domain ``example.com``. - -================ ==== ======== ================= -Name (Subdomain) Type Priority Value -================ ==== ======== ================= -example.com MX 10 mail.example.com -================ ==== ======== ================= - -You can check this with +For every ``domain`` in ``domains`` do: \* Add a ``MX`` record to the +domain ``domain`` :: - $ nix-shell -p bind --command "host -t mx example.com" - example.com mail is handled by 10 mail.example.com. + | Name (Subdomain) | TTL | Type | Priority | Value | + | ---------------- | ----- | ---- | -------- | ----------------- | + | `domain` | | MX | 10 | `server-FQDN` | + +You can test this via + +:: + + dig -t MX + + ... + ;; ANSWER SECTION: + 10800 IN MX 10 + ... Note that it can take a while until a DNS entry is propagated. -Set a ``SPF`` record -^^^^^^^^^^^^^^^^^^^^ +Step 4: Set ``SPF`` Records +^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Add a `SPF `_ -record to the domain ``example.com``. - -================ ===== ==== ================================ -Name (Subdomain) TTL Type Value -================ ===== ==== ================================ -example.com 10800 TXT `v=spf1 a:mail.example.com -all` -================ ===== ==== ================================ - -You can check this with +For every ``domain`` in ``domains`` do: \* Add a ``SPF`` record to the +domain ``domain`` :: - $ nix-shell -p bind --command "host -t TXT example.com" - example.com descriptive text "v=spf1 a:mail.example.com -all" + | Name (Subdomain) | TTL | Type | Priority | Value | + | ---------------- | ----- | ---- | -------- | ----------------- | + | `domain` | 10800 | TXT | | `v=spf1 ip4: -all` | + +You can check this with ``dig -t TXT `` similar to the last +section. Note that ``SPF`` records are set as ``TXT`` records since +RFC1035. + +Note that it can take a while until a DNS entry is propagated. If you +want to use multiple servers for your email handling, don’t forget to +add all server IP’s to this list. + +Step 5: Set ``DKIM`` signature +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +In this section we assume that your ``dkimSelector`` is set to ``mail``. +If you have a different selector, replace all ``mail``\ ’s below +accordingly. + +For every ``domain`` in ``domains`` do: \* Go to your server and +navigate to the dkim key directory (by default ``/var/dkim``). There you +will find a public key for any domain in the ``domain.txt`` file. It +will look like +``mail._domainkey IN TXT "v=DKIM1; r=postmaster; g=*; k=rsa; p=" ; ----- DKIM mail for domain.tld`` +\* Add a ``DKIM`` record to the domain ``domain`` + +:: + + | Name (Subdomain) | TTL | Type | Priority | Value | + | ---------------- | ----- | ---- | -------- | ----------------- | + | mail._domainkey.`domain` | 10800 | TXT | | `v=DKIM1; p=` | + +You can check this with ``dig -t TXT mail._domainkey.`` similar +to the last section. Note that it can take a while until a DNS entry is propagated. -Set ``DKIM`` signature -^^^^^^^^^^^^^^^^^^^^^^ +Step 6: Set ``DMARC`` record +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -On your server, the ``rspamd`` 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 +For every ``domain`` in ``domains`` do: -:: +- Add a ``DMARC`` record to the domain ``domain`` - mail._domainkey IN TXT ( "v=DKIM1; k=rsa; " - "p=" ) ; ----- DKIM key mail for nixos.org + ==================== ===== ==== ======== ==================== + Name (Subdomain) TTL Type Priority Value + ==================== ===== ==== ======== ==================== + \_dmarc.\ ``domain`` 10800 TXT ``v=DMARC1; p=none`` + ==================== ===== ==== ======== ==================== -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=`` -=========================== ===== ==== ================================================ - -You can check this with - -:: - - $ nix-shell -p bind --command "host -t txt mail._domainkey.example.com" - mail._domainkey.example.com descriptive text "v=DKIM1;p=" +You can check this with ``dig -t TXT _dmarc.`` similar to the +last section. Note that it can take a while until a DNS entry is propagated. -Set a ``DMARC`` record -^^^^^^^^^^^^^^^^^^^^^^ - -Add a ``DMARC`` record to the domain ``example.com``. - -======================== ===== ==== ==================== -Name (Subdomain) TTL Type Value -======================== ===== ==== ==================== -_dmarc.example.com 10800 TXT ``v=DMARC1; p=none`` -======================== ===== ==== ==================== - -You can check this with - -:: - - $ nix-shell -p bind --command "host -t TXT _dmarc.example.com" - _dmarc.example.com descriptive text "v=DMARC1; p=none" - -Note that it can take a while until a DNS entry is propagated. - - -Test your Setup -~~~~~~~~~~~~~~~ +C) Test your Setup +~~~~~~~~~~~~~~~~~~ Write an email to your aunt (who has been waiting for your reply far too long), and sign up for some of the finest newsletters the Internet has. @@ -238,8 +240,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 deleted file mode 100644 index 74df222..0000000 --- a/flake.lock +++ /dev/null @@ -1,124 +0,0 @@ -{ - "nodes": { - "blobs": { - "flake": false, - "locked": { - "lastModified": 1604995301, - "narHash": "sha256-wcLzgLec6SGJA8fx1OEN1yV/Py5b+U5iyYpksUY/yLw=", - "owner": "simple-nixos-mailserver", - "repo": "blobs", - "rev": "2cccdf1ca48316f2cfd1c9a0017e8de5a7156265", - "type": "gitlab" - }, - "original": { - "owner": "simple-nixos-mailserver", - "repo": "blobs", - "type": "gitlab" - } - }, - "flake-compat": { - "flake": false, - "locked": { - "lastModified": 1747046372, - "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", - "owner": "edolstra", - "repo": "flake-compat", - "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", - "type": "github" - }, - "original": { - "owner": "edolstra", - "repo": "flake-compat", - "type": "github" - } - }, - "git-hooks": { - "inputs": { - "flake-compat": [ - "flake-compat" - ], - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1750779888, - "narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=", - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d", - "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": 1753939845, - "narHash": "sha256-K2ViRJfdVGE8tpJejs8Qpvvejks1+A4GQej/lBk5y7I=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "94def634a20494ee057c76998843c015909d6311", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-25_05": { - "locked": { - "lastModified": 1753749649, - "narHash": "sha256-+jkEZxs7bfOKfBIk430K+tK9IvXlwzqQQnppC2ZKFj4=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "1f08a4df998e21f4e8be8fb6fbf61d11a1a5076a", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-25.05", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "blobs": "blobs", - "flake-compat": "flake-compat", - "git-hooks": "git-hooks", - "nixpkgs": "nixpkgs", - "nixpkgs-25_05": "nixpkgs-25_05" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index da8ae84..0000000 --- a/flake.nix +++ /dev/null @@ -1,220 +0,0 @@ -{ - description = "A complete and Simple Nixos Mailserver"; - - 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"; - 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" - ]; - }; - } - ]; - }; - options = builtins.toFile "options.json" ( - builtins.toJSON ( - lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver") ( - lib.optionAttrSetToDocList eval.options - ) - ) - ); - in - pkgs.runCommand "options.md" { buildInputs = [ pkgs.python3Minimal ]; } '' - echo "Generating options.md from ${options}" - python ${./scripts/generate-options.py} ${options} > $out - echo $out - ''; - - documentation = pkgs.stdenv.mkDerivation { - name = "documentation"; - src = lib.sourceByRegex ./docs [ - "logo\\.png" - "conf\\.py" - "Makefile" - ".*\\.rst" - ]; - buildInputs = [ - (pkgs.python3.withPackages ( - p: with p; [ - sphinx - sphinx_rtd_theme - myst-parser - linkify-it-py - ] - )) - ]; - buildPhase = '' - cp ${optionsDoc} options.md - # Workaround for https://github.com/sphinx-doc/sphinx/issues/3451 - unset SOURCE_DATE_EPOCH - make html - ''; - installPhase = '' - cp -Tr _build/html $out - ''; - }; - - in - { - nixosModules = rec { - mailserver = mailserverModule; - default = mailserver; - }; - nixosModule = self.nixosModules.default; # compatibility - hydraJobs.${system} = allTests // { - inherit documentation; - inherit (self.checks.${system}) pre-commit; - }; - checks.${system} = allTests // { - pre-commit = git-hooks.lib.${system}.run { - src = ./.; - hooks = { - # docs - markdownlint = { - enable = true; - settings.configuration = { - # Max line length, doesn't seem to correclty account for lines containing links - # https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md - MD013 = false; - }; - }; - rstcheck = { - enable = true; - package = pkgs.rstcheckWithSphinx; - entry = lib.getExe pkgs.rstcheckWithSphinx; - files = "\\.rst$"; - }; - - # nix - deadnix.enable = true; - nixfmt-rfc-style.enable = true; - - # python - pyright.enable = true; - ruff = { - enable = true; - args = [ - "--extend-select" - "I" - ]; - }; - ruff-format.enable = true; - - # scripts - shellcheck.enable = true; - - # sieve - check-sieve = { - enable = true; - package = pkgs.check-sieve; - entry = lib.getExe pkgs.check-sieve; - files = "\\.sieve$"; - }; - }; - }; - }; - packages.${system} = { - inherit optionsDoc documentation; - }; - devShells.${system}.default = pkgs.mkShellNoCC { - inputsFrom = [ documentation ]; - packages = - with pkgs; - [ - glab - ] - ++ self.checks.${system}.pre-commit.enabledPackages; - shellHook = self.checks.${system}.pre-commit.shellHook; - }; - devShell.${system} = self.devShells.${system}.default; # compatibility - - formatter.${system} = pkgs.nixfmt-tree; - }; -} diff --git a/docs/logo.png b/logo/logo.png similarity index 100% rename from docs/logo.png rename to logo/logo.png diff --git a/mail-server/assertions.nix b/mail-server/assertions.nix deleted file mode 100644 index e0dab19..0000000 --- a/mail-server/assertions.nix +++ /dev/null @@ -1,58 +0,0 @@ -{ - 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 != null -> 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. - ''; - } - ] - ++ [ - { - assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 3; - message = '' - Issue: The dovecot mail location for all users has changed and need to be migrated. - - Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-mail-directory-migration for the required remediation steps. - ''; - } - ] - ++ lib.optionals (config.mailserver.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"; - } - ] - ); -} 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..3c5fb21 100644 --- a/mail-server/clamav.nix +++ b/mail-server/clamav.nix @@ -14,17 +14,19 @@ # 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; in { config = lib.mkIf (cfg.enable && cfg.virusScanning) { - services.clamav.daemon = { - enable = true; - settings.PhishingScanURLs = "no"; - }; + services.clamav.daemon.enable = true; services.clamav.updater.enable = true; + + services.clamav.daemon.extraConfig = '' + PhishingScanURLs no + ''; }; } + diff --git a/mail-server/common.nix b/mail-server/common.nix index 4247360..b20e4c7 100644 --- a/mail-server/common.nix +++ b/mail-server/common.nix @@ -14,79 +14,35 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - options, - 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 == 1 + then cfg.certificateFile + else if cfg.certificateScheme == 2 + then "${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem" + else if cfg.certificateScheme == 3 + then "/var/lib/acme/${cfg.fqdn}/fullchain.pem" + else throw "Error: Certificate Scheme must be in { 1, 2, 3 }"; # 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"; - - 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 - - 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} - ''; - - dovecotUnitName = if options.services.dovecot2 ? hasNewUnitName then "dovecot" else "dovecot2"; + keyPath = if cfg.certificateScheme == 1 + then cfg.keyFile + else if cfg.certificateScheme == 2 + then "${cfg.certificateDirectory}/key-${cfg.fqdn}.pem" + else if cfg.certificateScheme == 3 + then "/var/lib/acme/${cfg.fqdn}/key.pem" + else throw "Error: Certificate Scheme must be in { 1, 2, 3 }"; + 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; } diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index 69d2b6b..fa7a3c8 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -14,88 +14,43 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - options, - pkgs, - lib, - ... -}: +{ config, pkgs, lib, ... }: -with (import ./common.nix { - inherit - config - options - pkgs - lib - ; -}); +with (import ./common.nix { inherit config pkgs lib; }); let cfg = config.mailserver; passwdDir = "/run/dovecot2"; passwdFile = "${passwdDir}/passwd"; - userdbFile = "${passwdDir}/userdb"; - # This file contains the ldap bind password - ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext"; - 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 - ); maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs"; - maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8"; - # https://doc.dovecot.org/2.3/configuration_manual/home_directories_for_virtual_users/#ways-to-set-up-home-directory - # Mail directory below the home directory - dovecotMaildir = - "maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}" - + (lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/%{domain}/%{username}"); + # maildir in format "/${domain}/${user}" + dovecotMaildir = "maildir:${cfg.mailDirectory}/%d/%n${maildirLayoutAppendix}"; postfixCfg = config.services.postfix; + dovecot2Cfg = config.services.dovecot2; - ldapConfig = pkgs.writeTextFile { - name = "dovecot-ldap.conf.ext.template"; - text = '' - ldap_version = 3 - uris = ${lib.concatStringsSep " " cfg.ldap.uris} - ${lib.optionalString cfg.ldap.startTls '' - tls = yes - ''} - tls_require_cert = hard - tls_ca_cert_file = ${cfg.ldap.tlsCAFile} - dn = ${cfg.ldap.bind.dn} - sasl_bind = no - auth_bind = yes - base = ${cfg.ldap.searchBase} - scope = ${mkLdapSearchScope cfg.ldap.searchScope} - ${lib.optionalString (cfg.ldap.dovecot.userAttrs != null) '' - user_attrs = ${cfg.ldap.dovecot.userAttrs} - ''} - user_filter = ${cfg.ldap.dovecot.userFilter} - ${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") '' - pass_attrs = ${cfg.ldap.dovecot.passAttrs} - ''} - pass_filter = ${cfg.ldap.dovecot.passFilter} + 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 ''; }; - setPwdInLdapConfFile = appendLdapBindPwd { - name = "ldap-conf-file"; - file = ldapConfig; - prefix = ''dnpass = "''; - suffix = ''"''; - passwordFile = cfg.ldap.bind.passwordFile; - destination = ldapConfFile; - }; - genPasswdScript = pkgs.writeScript "generate-password-file" '' #!${pkgs.stdenv.shell} @@ -106,12 +61,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 @@ -119,250 +69,68 @@ 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}:${"$(cat ${passwordFiles."${name}"})"}:${builtins.toString cfg.vmailUID}:${builtins.toString cfg.vmailUID}::${cfg.mailDirectory}:/run/current-system/sw/bin/nologin:" + + (if lib.isString value.quota + then "userdb_quota_rule=*:storage=${value.quota}" + else "") + ) 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 - )} - EOF + chmod 600 ${passwdFile} ''; - - junkMailboxes = builtins.attrNames ( - lib.filterAttrs (_: 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); - in { - config = lib.mkIf cfg.enable { - assertions = [ - { - assertion = junkMailboxNumber == 1; - message = "nixos-mailserver requires exactly one dovecot mailbox with the 'special use' flag set to 'Junk' (${builtins.toString junkMailboxNumber} have been found)"; - } - ]; - - 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"; - + config = with cfg; lib.mkIf enable { services.dovecot2 = { enable = true; - enableImap = cfg.enableImap || cfg.enableImapSsl; - enablePop3 = cfg.enablePop3 || cfg.enablePop3Ssl; + enableImap = enableImap; + enablePop3 = enablePop3; 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" - ]; - protocols = lib.optional cfg.enableManageSieve "sieve"; + modules = [ pkgs.dovecot_pigeonhole ]; + protocols = [ "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" { - fileinto "${junkMailboxName}"; + fileinto "Junk"; 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.dovecot '' + ${lib.optionalString debug '' mail_debug = yes auth_debug = yes verbose_ssl = yes ''} - ${lib.optionalString (cfg.enableImap || cfg.enableImapSsl) '' - service imap-login { - inet_listener imap { - ${ - if cfg.enableImap then - '' - port = 143 - '' - else - '' - # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html - port = 0 - '' - } - } - inet_listener imaps { - ${ - if cfg.enableImapSsl then - '' - port = 993 - ssl = yes - '' - else - '' - # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html - port = 0 - '' - } - } - } - ''} - ${lib.optionalString (cfg.enablePop3 || cfg.enablePop3Ssl) '' - service pop3-login { - inet_listener pop3 { - ${ - if cfg.enablePop3 then - '' - port = 110 - '' - else - '' - # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html - port = 0 - '' - } - } - inet_listener pop3s { - ${ - if cfg.enablePop3Ssl then - '' - port = 995 - ssl = yes - '' - else - '' - # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html - port = 0 - '' - } - } - } - ''} - protocol imap { mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser} mail_plugins = $mail_plugins imap_sieve } - service imap { - vsz_limit = ${builtins.toString cfg.imapMemoryLimit} MB - } - protocol pop3 { mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser} } - mail_access_groups = ${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 { @@ -370,20 +138,9 @@ 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} + recipient_delimiter = + lmtp_save_to_detail_mailbox = ${cfg.lmtpSaveToDetailMailbox} protocol lmtp { @@ -397,33 +154,9 @@ in userdb { driver = passwd-file - args = ${userdbFile} - default_fields = \ - home=${cfg.mailDirectory}/%{domain}/%{username} \ - uid=${builtins.toString cfg.vmailUID} \ - gid=${builtins.toString cfg.vmailUID} + args = ${passwdFile} } - ${lib.optionalString cfg.ldap.enable '' - passdb { - driver = ldap - args = ${ldapConfFile} - } - - userdb { - driver = ldap - args = ${ldapConfFile} - default_fields = \ - home=${cfg.mailDirectory}/ldap/%{user} \ - uid=${toString cfg.vmailUID} \ - gid=${toString cfg.vmailUID} \ - mail=maildir:~/mail${maildirLayoutAppendix}${maildirUTF8FolderNames}${ - lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/ldap/%{user}" - } - - } - ''} - service auth { unix_listener auth { mode = 0660 @@ -439,10 +172,26 @@ 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:/var/sieve/%u/scripts;active=/var/sieve/%u/active.sieve + sieve_default = file:/var/sieve/%u/default.sieve + sieve_default_name = default + + # From elsewhere to Spam folder + imapsieve_mailbox1_name = Junk + imapsieve_mailbox1_causes = COPY + imapsieve_mailbox1_before = file:${stateDir}/imap_sieve/report-spam.sieve + + # From Spam folder to elsewhere + imapsieve_mailbox2_name = * + imapsieve_mailbox2_from = Junk + 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 } lda_mailbox_autosubscribe = yes @@ -450,16 +199,19 @@ in ''; }; - systemd.services.${dovecotUnitName} = { + systemd.services.dovecot2 = { preStart = '' ${genPasswdScript} - '' - + (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile); + 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' + ''; }; - systemd.services.postfix.restartTriggers = [ - genPasswdScript - ] - ++ (lib.optional cfg.ldap.enable [ setPwdInLdapConfFile ]); + systemd.services.postfix.restartTriggers = [ genPasswdScript ]; }; } 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..cc85202 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 == 2 then [ openssl ] else []); }; } diff --git a/mail-server/kresd.nix b/mail-server/kresd.nix index 3920534..1694eca 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; @@ -22,5 +22,7 @@ in { config = lib.mkIf (cfg.enable && cfg.localDnsResolver) { services.kresd.enable = true; + networking.nameservers = [ "127.0.0.1" ]; }; } + 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 a79aa37..056e411 100644 --- a/mail-server/networking.nix +++ b/mail-server/networking.nix @@ -14,26 +14,22 @@ # 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; in { - config = lib.mkIf (cfg.enable && cfg.openFirewall) { + config = with cfg; lib.mkIf enable { 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 587 ] + ++ lib.optional enableImap 143 + ++ lib.optional enableImapSsl 993 + ++ lib.optional enablePop3 110 + ++ lib.optional enablePop3Ssl 995 + ++ lib.optional enableManageSieve 4190 + ++ lib.optional (certificateScheme == 3) 80; }; }; } diff --git a/mail-server/nginx.nix b/mail-server/nginx.nix index 75ebc4c..bdead6c 100644 --- a/mail-server/nginx.nix +++ b/mail-server/nginx.nix @@ -14,43 +14,31 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - options, - pkgs, - lib, - ... -}: -with (import ./common.nix { - inherit - config - options - 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" - "${dovecotUnitName}.service" - ]; + config = lib.mkIf (cfg.enable && cfg.certificateScheme == 3) { + services.nginx = { + enable = true; + virtualHosts."${cfg.fqdn}" = { + serverName = cfg.fqdn; + forceSSL = true; + enableACME = true; + acmeRoot = acmeRoot; }; + }; + + security.acme.certs."${cfg.fqdn}".postRun = '' + systemctl reload nginx + systemctl reload postfix + systemctl reload dovecot2 + ''; + }; } diff --git a/mail-server/opendkim.nix b/mail-server/opendkim.nix new file mode 100644 index 0000000..d381519 --- /dev/null +++ b/mail-server/opendkim.nix @@ -0,0 +1,87 @@ +# 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}" ] || [ ! -f "${dkim_txt}" ] + 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}" + 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; + domains = "csl:${builtins.concatStringsSep "," cfg.domains}"; + configFile = pkgs.writeText "opendkim.conf" ('' + Canonicalization relaxed/simple + 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 8c0bccd..d3ca4fe 100644 --- a/mail-server/postfix.nix +++ b/mail-server/postfix.nix @@ -14,133 +14,67 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - options, - pkgs, - lib, - ... -}: +{ config, pkgs, lib, ... }: -with (import ./common.nix { - inherit - config - options - lib - pkgs - ; -}); +with (import ./common.nix { inherit config pkgs lib; }); let inherit (lib.strings) concatStringsSep; cfg = config.mailserver; - # 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; + # valiases_postfix :: [ String ] + valiases_postfix = lib.flatten (lib.mapAttrsToList + (name: value: + let to = name; + in map (from: "${from} ${to}") (value.aliases ++ lib.singleton name)) + cfg.loginAccounts); - # 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 - ) - ); + # catchAllPostfix :: [ String ] + catchAllPostfix = lib.flatten (lib.mapAttrsToList + (name: value: + let to = name; + in map (from: "@${from} ${to}") value.catchAll) + 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 - ) - ); + # extra_valiases_postfix :: [ String ] + extra_valiases_postfix = + (map + (from: + let to = cfg.extraVirtualAliases.${from}; + aliasList = (l: let aliasStr = builtins.foldl' (x: y: x + y + ", ") "" l; + in builtins.substring 0 (builtins.stringLength aliasStr - 2) aliasStr); + in if (builtins.isList to) then "${from} " + (aliasList to) + else "${from} ${to}") + (builtins.attrNames cfg.extraVirtualAliases)); - # all_valiases_postfix :: Map String [String] - 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; - - # extra_valiases_postfix :: Map String [String] - extra_valiases_postfix = attrsToLookupTable cfg.extraVirtualAliases; - - # forwards :: Map String [String] - forwards = attrsToLookupTable cfg.forwards; - - # lookupTableToString :: Map String [String] -> String - lookupTableToString = - attrs: - let - valueToString = value: lib.concatStringsSep ", " value; - in - lib.concatStringsSep "\n" ( - lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs - ); - - # 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; + # all_valiases_postfix :: [ String ] + all_valiases_postfix = valiases_postfix ++ extra_valiases_postfix; # 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_recipients_postfix = map (recipient: "${recipient} REJECT") cfg.rejectRecipients; + # valiases_file :: Path + valiases_file = builtins.toFile "valias" + (lib.concatStringsSep "\n" (all_valiases_postfix ++ + catchAllPostfix)); + + 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)); # 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); @@ -151,257 +85,159 @@ let # for details on how this file looks. By using the same file as valiases, # 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 - ); + vaccounts_file = builtins.toFile "vaccounts" (lib.concatStringsSep "\n" all_valiases_postfix); - submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" ( - '' - # Removes sensitive headers from mails handed in via the submission port. - # 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 + + (lib.optionalString cfg.debug '' + debugLevel = 4 + '')); 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"; - }; - - commonLdapConfig = '' - server_host = ${lib.concatStringsSep " " cfg.ldap.uris} - start_tls = ${if cfg.ldap.startTls then "yes" else "no"} - version = 3 - tls_ca_cert_file = ${cfg.ldap.tlsCAFile} - tls_require_cert = yes - - search_base = ${cfg.ldap.searchBase} - scope = ${cfg.ldap.searchScope} - - bind = yes - bind_dn = ${cfg.ldap.bind.dn} - ''; - - ldapSenderLoginMap = pkgs.writeText "ldap-sender-login-map.cf" '' - ${commonLdapConfig} - query_filter = ${cfg.ldap.postfix.filter} - result_attribute = ${cfg.ldap.postfix.mailAttribute} - ''; - ldapSenderLoginMapFile = "/run/postfix/ldap-sender-login-map.cf"; - appendPwdInSenderLoginMap = appendLdapBindPwd { - name = "ldap-sender-login-map"; - file = ldapSenderLoginMap; - prefix = "bind_pw = "; - passwordFile = cfg.ldap.bind.passwordFile; - destination = ldapSenderLoginMapFile; - }; - - ldapVirtualMailboxMap = pkgs.writeText "ldap-virtual-mailbox-map.cf" '' - ${commonLdapConfig} - query_filter = ${cfg.ldap.postfix.filter} - result_attribute = ${cfg.ldap.postfix.uidAttribute} - ''; - ldapVirtualMailboxMapFile = "/run/postfix/ldap-virtual-mailbox-map.cf"; - appendPwdInVirtualMailboxMap = appendLdapBindPwd { - name = "ldap-virtual-mailbox-map"; - file = ldapVirtualMailboxMap; - prefix = "bind_pw = "; - passwordFile = cfg.ldap.bind.passwordFile; - destination = ldapVirtualMailboxMapFile; - }; in { - config = lib.mkIf cfg.enable { - - systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable { - preStart = '' - ${appendPwdInVirtualMailboxMap} - ${appendPwdInSenderLoginMap} - ''; - restartTriggers = [ - appendPwdInVirtualMailboxMap - appendPwdInSenderLoginMap - ]; - }; + config = with cfg; lib.mkIf enable { services.postfix = { enable = true; + hostname = "${fqdn}"; + networksStyle = "host"; mapFiles."valias" = valiases_file; - mapFiles."regex_valias" = regex_valiases_file; mapFiles."vaccounts" = vaccounts_file; - mapFiles."regex_vaccounts" = regex_vaccounts_file; mapFiles."denied_recipients" = denied_recipients_file; mapFiles."reject_senders" = reject_senders_file; mapFiles."reject_recipients" = reject_recipients_file; - enableSubmission = cfg.enableSubmission; - enableSubmissions = cfg.enableSubmissionSsl; - virtual = lookupTableToString (mergeLookupTables [ - all_valiases_postfix - catchAllPostfix - forwards - ]); + sslCert = certificatePath; + sslKey = keyPath; + enableSubmission = true; + virtual = + (lib.concatStringsSep "\n" (all_valiases_postfix ++ catchAllPostfix)); config = { - myhostname = cfg.sendingFqdn; - mydestination = ""; # disable local mail delivery - recipient_delimiter = cfg.recipientDelimiter; - smtpd_banner = "${cfg.fqdn} ESMTP NO UCE"; + # Extra Config + mydestination = ""; + recipient_delimiter = "+"; + smtpd_banner = "${fqdn} ESMTP NO UCE"; disable_vrfy_command = true; - message_size_limit = cfg.messageSizeLimit; + 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"; virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp"; - # Avoid leakage of X-Original-To, X-Delivered-To headers between recipients - lmtp_destination_recipient_limit = "1"; - # sasl with dovecot smtpd_sasl_type = "dovecot"; smtpd_sasl_path = "/run/dovecot2/auth"; smtpd_sasl_auth_enable = true; smtpd_relay_restrictions = [ - "permit_mynetworks" - "permit_sasl_authenticated" - "reject_unauth_destination" + "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 = "SHA1, eNULL, aNULL"; - smtpd_tls_mandatory_exclude_ciphers = "SHA1, 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 = "SHA1, eNULL, aNULL"; - smtp_tls_mandatory_exclude_ciphers = "SHA1, 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 = + { + 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"; + 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 = submissionOptions; - submissionsOptions = submissionOptions; - masterConfig = { - "lmtp" = { - # Add headers when delivering, see http://www.postfix.org/smtp.8.html - # D => Delivered-To, O => X-Original-To, R => Return-Path - args = [ "flags=O" ]; + "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"; @@ -409,10 +245,7 @@ in 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 ab46750..61700c3 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,127 +22,52 @@ 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; - debug = cfg.debug.rspamd; + inherit debug; locals = { + "milter_headers.conf" = { text = '' + extended_spam_headers = yes; + ''; }; + "redis.conf" = { text = '' + servers = "${cfg.redis.address}:${toString cfg.redis.port}"; + '' + (lib.optionalString (cfg.redis.password != null) '' + password = "${cfg.redis.password}"; + ''); }; + "classifier-bayes.conf" = { text = '' + cache { + backend = "redis"; + } + ''; }; + "antivirus.conf" = lib.mkIf cfg.virusScanning { text = '' + clamav { + action = "reject"; + symbol = "CLAM_VIRUS"; + type = "clamav"; + log_clean = true; + servers = "/run/clamav/clamd.ctl"; + scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all + } + ''; }; + }; + + overrides = { "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) '' - 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 '' - reporting { - enabled = true; - email = "noreply-dmarc@${cfg.systemDomain}"; - domain = "${cfg.systemDomain}"; - org_name = "${cfg.systemName}"; - from_name = "${cfg.systemName}"; - msgid_from = "${cfg.systemDomain}"; - ${lib.optionalString (cfg.dmarcReporting.excludeDomains != [ ]) '' - exclude_domains = ${builtins.toJSON cfg.dmarcReporting.excludeDomains}; - ''} - }''} - ''; - }; - }; - overrides = { - "options.inc" = { - text = '' - local_addrs = [::1/128, 127.0.0.0/8] - ''; - }; }; workers.rspamd_proxy = { type = "rspamd_proxy"; - bindSockets = [ - { - socket = "/run/rspamd/rspamd-milter.sock"; - mode = "0664"; - } - ]; + bindSockets = [{ + socket = "/run/rspamd/rspamd-milter.sock"; + mode = "0664"; + }]; count = 1; # Do not spawn too many processes of this type extraConfig = '' milter = yes; # Enable milter mode @@ -162,13 +82,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 ''; @@ -176,102 +94,11 @@ in }; - services.redis.servers.rspamd.enable = lib.mkDefault cfg.redis.configureLocally; - - 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.enable = true; systemd.services.rspamd = { - requires = [ "redis-rspamd.service" ] ++ (lib.optional cfg.virusScanning "clamav-daemon.service"); - after = [ "redis-rspamd.service" ] ++ (lib.optional cfg.virusScanning "clamav-daemon.service"); - serviceConfig = lib.mkMerge [ - { - SupplementaryGroups = [ config.services.redis.servers.rspamd.group ]; - } - (lib.optionalAttrs cfg.dkimSigning { - ExecStartPre = map createDkimKeypair cfg.domains; - ReadWritePaths = [ cfg.dkimKeyDirectory ]; - }) - ]; - }; - - systemd.services.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable { - # Explicitly select yesterday's date to work around broken - # default behaviour when called without a date. - # https://github.com/rspamd/rspamd/issues/4062 - script = toString [ - (lib.getExe' pkgs.rspamd "rspamadm") - "dmarc_report" - "$(date -d 'yesterday' '+%Y%m%d')" - ]; - serviceConfig = { - User = "${config.services.rspamd.user}"; - Group = "${config.services.rspamd.group}"; - - AmbientCapabilities = [ ]; - CapabilityBoundingSet = ""; - DevicePolicy = "closed"; - IPAddressAllow = "localhost"; - LockPersonality = true; - NoNewPrivileges = true; - PrivateDevices = true; - PrivateMounts = true; - PrivateTmp = true; - PrivateUsers = true; - ProtectClock = true; - ProtectControlGroups = true; - ProtectHome = true; - ProtectHostname = true; - ProtectKernelLogs = true; - ProtectKernelModules = true; - ProtectKernelTunables = true; - ProtectProc = "invisible"; - ProcSubset = "pid"; - ProtectSystem = "strict"; - RemoveIPC = true; - RestrictAddressFamilies = [ - "AF_INET" - "AF_INET6" - "AF_UNIX" - ]; - RestrictNamespaces = true; - RestrictRealtime = true; - RestrictSUIDSGID = true; - SupplementaryGroups = lib.optionals cfg.redis.configureLocally [ - config.services.redis.servers.rspamd.group - ]; - SystemCallArchitectures = "native"; - SystemCallFilter = [ - "@system-service" - "~@privileged" - ]; - UMask = "0077"; - }; - }; - - systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable { - description = "Daily delivery of aggregated DMARC reports"; - wantedBy = [ - "timers.target" - ]; - timerConfig = { - OnCalendar = "daily"; - Persistent = true; - RandomizedDelaySec = 86400; - FixedRandomDelay = true; - }; + requires = [ "redis.service" ] ++ (lib.optional cfg.virusScanning "clamav-daemon.service"); + after = [ "redis.service" ] ++ (lib.optional cfg.virusScanning "clamav-daemon.service"); }; systemd.services.postfix = { @@ -282,3 +109,4 @@ in users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ]; }; } + diff --git a/mail-server/systemd.nix b/mail-server/systemd.nix index fb11a2d..378e07d 100644 --- a/mail-server/systemd.nix +++ b/mail-server/systemd.nix @@ -14,92 +14,72 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - options, - pkgs, - lib, - ... -}: - -with (import ./common.nix { - inherit - config - options - lib - pkgs - ; -}); +{ 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" ]; - + preliminarySelfsigned = config.security.acme.preliminarySelfsigned; + acmeWantsTarget = [ "acme-certificates.target" ] + ++ (lib.optional preliminarySelfsigned "acme-selfsigned-certificates.target"); + acmeAfterTarget = if preliminarySelfsigned + then [ "acme-selfsigned-certificates.target" ] + else [ "acme-certificates.target" ]; in { - config = lib.mkIf cfg.enable { - # Create self signed certificate - systemd.services.mailserver-selfsigned-certificate = - lib.mkIf (cfg.certificateScheme == "selfsigned") - { - after = [ "local-fs.target" ]; - script = '' - # Create certificates if they do not exist yet - dir="${cfg.certificateDirectory}" - fqdn="${cfg.fqdn}" - [[ $fqdn == /* ]] && fqdn=$(< "$fqdn") - key="$dir/key-${cfg.fqdn}.pem"; - cert="$dir/cert-${cfg.fqdn}.pem"; + config = with cfg; lib.mkIf enable { + # Add target for when certificates are available + systemd.targets."mailserver-certificates" = { + wants = lib.mkIf (cfg.certificateScheme == 3) acmeWantsTarget; + after = lib.mkIf (cfg.certificateScheme == 3) acmeAfterTarget; + }; - 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 self signed certificate + systemd.services.mailserver-selfsigned-certificate = lib.mkIf (cfg.certificateScheme == 2) { + wantedBy = [ "mailserver-certificates.target" ]; + after = [ "local-fs.target" ]; + before = [ "mailserver-certificates.target" ]; + script = '' + # Create certificates if they do not exist yet + dir="${cfg.certificateDirectory}" + fqdn="${cfg.fqdn}" + case $fqdn in /*) fqdn=$(cat "$fqdn");; esac + 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; + }; + }; # Create maildir folder before dovecot startup - systemd.services.${dovecotUnitName} = { - 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} - ''; + systemd.services.dovecot2 = { + after = [ "mailserver-certificates.target" ]; + wants = [ "mailserver-certificates.target" ]; + preStart = '' + # Create mail directory and set permissions. See + # . + mkdir -p "${mailDirectory}" + chgrp "${vmailGroupName}" "${mailDirectory}" + chmod 02770 "${mailDirectory}" + ''; }; # Postfix requires dovecot lmtp socket, dovecot auth socket and certificate to work systemd.services.postfix = { - wants = certificatesDeps; - after = [ - "${dovecotUnitName}.service" - ] - ++ lib.optional cfg.dkimSigning "rspamd.service" - ++ certificatesDeps; - requires = [ "${dovecotUnitName}.service" ] ++ lib.optional cfg.dkimSigning "rspamd.service"; + after = [ "dovecot2.service" "mailserver-certificates.target" ] + ++ (lib.optional cfg.dkimSigning "opendkim.service"); + wants = [ "mailserver-certificates.target" ]; + requires = [ "dovecot2.service" ] + ++ (lib.optional cfg.dkimSigning "opendkim.service"); }; }; } diff --git a/mail-server/users.nix b/mail-server/users.nix index b08e3b5..3ab31d5 100644 --- a/mail-server/users.nix +++ b/mail-server/users.nix @@ -14,100 +14,74 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - options, - pkgs, - lib, - ... -}: - -with (import ./common.nix { - inherit - config - options - lib - pkgs - ; -}); +{ config, pkgs, lib, ... }: with config.mailserver; let vmail_user = { name = vmailUserName; - isSystemUser = true; + isNormalUser = false; uid = vmailUID; home = mailDirectory; createHome = true; group = vmailGroupName; }; + virtualMailUsersActivationScript = pkgs.writeScript "activate-virtual-mail-users" '' #!${pkgs.stdenv.shell} set -euo pipefail - # Prevent world-readable paths, even temporarily. - umask 007 - # Create directory to store user sieve scripts if it doesn't exist - if (! test -d "${sieveDirectory}"); then - mkdir "${sieveDirectory}" - chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}" - chmod 770 "${sieveDirectory}" + if (! test -d "/var/sieve"); then + mkdir "/var/sieve" + chown "${vmailUserName}:${vmailGroupName}" "/var/sieve" + chmod 770 "/var/sieve" fi # Copy user's sieve script to the correct location (if it exists). If it # is null, remove the file. - ${lib.concatMapStringsSep "\n" ( - { name, sieveScript }: - if lib.isString sieveScript then - '' - if (! test -d "${sieveDirectory}/${name}"); then - mkdir -p "${sieveDirectory}/${name}" - chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}" - chmod 770 "${sieveDirectory}/${name}" - fi - cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve" - ${sieveScript} - EOF - chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve" - '' - else - '' - if (test -f "${sieveDirectory}/${name}/default.sieve"); then - rm "${sieveDirectory}/${name}/default.sieve" - fi - if (test -f "${sieveDirectory}/${name}.svbin"); then - rm "${sieveDirectory}/${name}/default.svbin" - fi - '' - ) (map (user: { inherit (user) name sieveScript; }) (lib.attrValues loginAccounts))} + ${lib.concatMapStringsSep "\n" ({ name, sieveScript }: + if lib.isString sieveScript then '' + if (! test -d "/var/sieve/${name}"); then + mkdir -p "/var/sieve/${name}" + chown "${vmailUserName}:${vmailGroupName}" "/var/sieve/${name}" + chmod 770 "/var/sieve/${name}" + fi + cat << 'EOF' > "/var/sieve/${name}/default.sieve" + ${sieveScript} + EOF + chown "${vmailUserName}:${vmailGroupName}" "/var/sieve/${name}/default.sieve" + '' else '' + if (test -f "/var/sieve/${name}/default.sieve"); then + rm "/var/sieve/${name}/default.sieve" + fi + if (test -f "/var/sieve/${name}.svbin"); then + rm "/var/sieve/${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 @@ -117,7 +91,7 @@ in systemd.services.activate-virtual-mail-users = { wantedBy = [ "multi-user.target" ]; - before = [ "${dovecotUnitName}.service" ]; + before = [ "dovecot2.service" ]; serviceConfig = { ExecStart = virtualMailUsersActivationScript; }; diff --git a/migrations/nixos-mailserver-migration-03.py b/migrations/nixos-mailserver-migration-03.py deleted file mode 100644 index ead6df1..0000000 --- a/migrations/nixos-mailserver-migration-03.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env nix-shell -#!nix-shell -i python3 -p python3 - -import argparse -import os -import shutil -import sys -from enum import Enum -from pathlib import Path -from pwd import getpwnam - - -class FolderLayout(Enum): - Default = 1 - Folder = 2 - - -def check_user(vmail_root: Path): - owner = vmail_root.owner() - owner_uid = getpwnam(owner).pw_uid - - if os.geteuid() == owner_uid: - return - - try: - print( - f"Trying to switch effective user id to {owner_uid} ({owner})", - file=sys.stderr, - ) - os.seteuid(owner_uid) - return - except PermissionError: - print( - f"Failed switching to virtual mail user. Please run this script under it, for example by using `sudo -u {owner}`)", - file=sys.stderr, - ) - sys.exit(1) - - -def is_maildir_related(path: Path, layout: FolderLayout) -> bool: - if path.name in [ - "subscriptions", - # https://doc.dovecot.org/2.3/admin_manual/mailbox_formats/maildir/#imap-uid-mapping - "dovecot-uidlist", - # https://doc.dovecot.org/2.3/admin_manual/mailbox_formats/maildir/#imap-keywords - "dovecot-keywords", - ]: - return True - if not path.is_dir(): - return False - if path.name in ["cur", "new", "tmp"]: - return True - if layout is FolderLayout.Default and path.name.startswith("."): - return True - if layout is FolderLayout.Folder: - if path.name in ["mail"]: - return False - return True - - return False - - -def mkdir(dst: Path, dry_run: bool = True): - print(f'mkdir "{dst}"') - if not dry_run: - # u+rwx, setgid - dst.mkdir(mode=0o2700) - - -def move(src: Path, dst: Path, dry_run: bool = True): - print(f'mv "{src}" "{dst}"') - if not dry_run: - src.rename(dst) - - -def delete(dst: Path, dry_run: bool = True): - if not dst.exists(): - return - - if dst.is_dir(): - print(f'rm --recursive "{dst}"') - if not dry_run: - shutil.rmtree(dst) - else: - print(f'rm "{dst}"') - if not dry_run: - dst.unlink() - - -def main(vmail_root: Path, layout: FolderLayout, dry_run: bool = True): - maildirs = {path.parent for path in vmail_root.glob("*/*/cur")} - maybe_delete = [] - - # The old maildir will be the new home directory - for homedir in maildirs: - maildir = homedir / "mail" - mkdir(maildir, dry_run) - - for path in homedir.iterdir(): - if is_maildir_related(path, layout): - move(path, maildir / path.name, dry_run) - else: - maybe_delete.append(path) - - # Files that are part of the previous home directory, but now obsolete - for path in [ - vmail_root / ".dovecot.lda-dupes", - vmail_root / ".dovecot.lda-dupes.locks", - ]: - delete(path, dry_run) - - # The remaining files are likely obsolete, but should still be checked with care - for path in maybe_delete: - print(f"# rm {str(path)}") - - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description=""" - NixOS Mailserver Migration #3: Dovecot mail directory migration - (https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-mail-directory-migration) - """ - ) - parser.add_argument( - "vmail_root", type=Path, help="Path to the `mailserver.mailDirectory`" - ) - parser.add_argument( - "--layout", - choices=["default", "folder"], - required=True, - help="Folder layout: 'default' unless `mailserver.useFsLayout` was enabled, then'folder'", - ) - parser.add_argument( - "--execute", action="store_true", help="Actually perform changes" - ) - - args = parser.parse_args() - - layout = FolderLayout.Default if args.layout == "default" else FolderLayout.Folder - - check_user(args.vmail_root) - main(args.vmail_root, layout, not args.execute) diff --git a/nix/sources.json b/nix/sources.json new file mode 100644 index 0000000..b7f1f42 --- /dev/null +++ b/nix/sources.json @@ -0,0 +1,26 @@ +{ + "nixpkgs-20.03": { + "branch": "nixos-20.03", + "description": "A read-only mirror of NixOS/nixpkgs tracking the released channels. Send issues and PRs to", + "homepage": "https://github.com/NixOS/nixpkgs", + "owner": "NixOS", + "repo": "nixpkgs-channels", + "rev": "aaa66d8d887c73f643246ac1a684fcb1521543b8", + "sha256": "0qvrhc7hv8h4yqa4jh64y6v5j3nza53ivkbq6j72g434c3yp2h50", + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs-channels/archive/aaa66d8d887c73f643246ac1a684fcb1521543b8.tar.gz", + "url_template": "https://github.com///archive/.tar.gz" + }, + "nixpkgs-unstable": { + "branch": "nixos-unstable", + "description": "A read-only mirror of NixOS/nixpkgs tracking the released channels. Send issues and PRs to", + "homepage": "https://github.com/NixOS/nixpkgs", + "owner": "NixOS", + "repo": "nixpkgs-channels", + "rev": "c71518e75bf067fb639d44264fdd8cf80f53d75a", + "sha256": "0hwa79prsqgvfwd3ah54nl0wh73q215z7np4k6y0pd6zr3m17vxs", + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs-channels/archive/c71518e75bf067fb639d44264fdd8cf80f53d75a.tar.gz", + "url_template": "https://github.com///archive/.tar.gz" + } +} diff --git a/nix/sources.nix b/nix/sources.nix new file mode 100644 index 0000000..8a725cb --- /dev/null +++ b/nix/sources.nix @@ -0,0 +1,134 @@ +# This file has been generated by Niv. + +let + + # + # The fetchers. fetch_ fetches specs of type . + # + + fetch_file = pkgs: spec: + if spec.builtin or true then + builtins_fetchurl { inherit (spec) url sha256; } + else + pkgs.fetchurl { inherit (spec) url sha256; }; + + fetch_tarball = pkgs: spec: + if spec.builtin or true then + builtins_fetchTarball { inherit (spec) url sha256; } + else + pkgs.fetchzip { inherit (spec) url sha256; }; + + fetch_git = spec: + builtins.fetchGit { url = spec.repo; inherit (spec) rev ref; }; + + fetch_builtin-tarball = spec: + builtins.trace + '' + WARNING: + The niv type "builtin-tarball" will soon be deprecated. You should + instead use `builtin = true`. + + $ niv modify -a type=tarball -a builtin=true + '' + builtins_fetchTarball { inherit (spec) url sha256; }; + + fetch_builtin-url = spec: + builtins.trace + '' + WARNING: + The niv type "builtin-url" will soon be deprecated. You should + instead use `builtin = true`. + + $ niv modify -a type=file -a builtin=true + '' + (builtins_fetchurl { inherit (spec) url sha256; }); + + # + # Various helpers + # + + # The set of packages used when specs are fetched using non-builtins. + mkPkgs = sources: + let + sourcesNixpkgs = + import (builtins_fetchTarball { inherit (sources.nixpkgs) url sha256; }) {}; + hasNixpkgsPath = builtins.any (x: x.prefix == "nixpkgs") builtins.nixPath; + hasThisAsNixpkgsPath = == ./.; + in + if builtins.hasAttr "nixpkgs" sources + then sourcesNixpkgs + else if hasNixpkgsPath && ! hasThisAsNixpkgsPath then + import {} + else + abort + '' + Please specify either (through -I or NIX_PATH=nixpkgs=...) or + add a package called "nixpkgs" to your sources.json. + ''; + + # The actual fetching function. + fetch = pkgs: name: spec: + + if ! builtins.hasAttr "type" spec then + abort "ERROR: niv spec ${name} does not have a 'type' attribute" + else if spec.type == "file" then fetch_file pkgs spec + else if spec.type == "tarball" then fetch_tarball pkgs spec + else if spec.type == "git" then fetch_git spec + else if spec.type == "builtin-tarball" then fetch_builtin-tarball spec + else if spec.type == "builtin-url" then fetch_builtin-url spec + else + abort "ERROR: niv spec ${name} has unknown type ${builtins.toJSON spec.type}"; + + # Ports of functions for older nix versions + + # a Nix version of mapAttrs if the built-in doesn't exist + mapAttrs = builtins.mapAttrs or ( + f: set: with builtins; + listToAttrs (map (attr: { name = attr; value = f attr set.${attr}; }) (attrNames set)) + ); + + # fetchTarball version that is compatible between all the versions of Nix + builtins_fetchTarball = { url, sha256 }@attrs: + let + inherit (builtins) lessThan nixVersion fetchTarball; + in + if lessThan nixVersion "1.12" then + fetchTarball { inherit url; } + else + fetchTarball attrs; + + # fetchurl version that is compatible between all the versions of Nix + builtins_fetchurl = { url, sha256 }@attrs: + let + inherit (builtins) lessThan nixVersion fetchurl; + in + if lessThan nixVersion "1.12" then + fetchurl { inherit url; } + else + fetchurl attrs; + + # Create the final "sources" from the config + mkSources = config: + mapAttrs ( + name: spec: + if builtins.hasAttr "outPath" spec + then abort + "The values in sources.json should not have an 'outPath' attribute" + else + spec // { outPath = fetch config.pkgs name spec; } + ) config.sources; + + # The "config" used by the fetchers + mkConfig = + { sourcesFile ? ./sources.json + , sources ? builtins.fromJSON (builtins.readFile sourcesFile) + , pkgs ? mkPkgs sources + }: rec { + # The sources, i.e. the attribute set of spec name to spec + inherit sources; + + # The "pkgs" (evaluated nixpkgs) to use for e.g. non-builtin fetchers + inherit pkgs; + }; +in +mkSources (mkConfig {}) // { __functor = _: settings: mkSources (mkConfig settings); } diff --git a/nixops/single-server.nix b/nixops/single-server.nix new file mode 100644 index 0000000..115c593 --- /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/pyproject.toml b/pyproject.toml deleted file mode 100644 index f290152..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[tool.ruff.lint] -extend-select = ["ISC"] - -[tool.ruff.lint.flake8-implicit-str-concat] -allow-multiline = false diff --git a/scripts/generate-options.py b/scripts/generate-options.py deleted file mode 100644 index e78e262..0000000 --- a/scripts/generate-options.py +++ /dev/null @@ -1,109 +0,0 @@ -import json -import sys -from textwrap import indent -from typing import Any, Mapping - -header = """ -# Mailserver options - -## `mailserver` - -""" - -template = """ -`````{{option}} {key} -{description} - -{type} -{default} -{example} -````` -""" - -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", -] - - -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']}") - 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"), - ) - ) - - -print(header) -for opt in options: - if any([opt["name"].startswith(c) for c in groups]): - continue - print_option(opt) - -for c in groups: - print(f"## `{c}`\n") - for opt in options: - if opt["name"].startswith(c): - print_option(opt) diff --git a/scripts/mail-check.py b/scripts/mail-check.py deleted file mode 100644 index b0f65ff..0000000 --- a/scripts/mail-check.py +++ /dev/null @@ -1,267 +0,0 @@ -import argparse -import email -import email.utils -import imaplib -import smtplib -import time -import uuid -from datetime import datetime, timedelta -from typing import cast - -RETRY = 100 - - -def _send_mail( - smtp_host, - smtp_port, - smtp_username, - from_addr, - from_pwd, - to_addr, - subject, - starttls, - ssl, -): - print(f"Sending mail with subject '{subject}'") - message = "\n".join( - [ - f"From: {from_addr}", - f"To: {to_addr}", - f"Subject: {subject}", - f"Message-ID: {uuid.uuid4()}@mail-check.py", - f"Date: {email.utils.formatdate()}", - "", - "This validates our mail server can send to Gmail :/", - ] - ) - - retry = RETRY - smtp_class = smtplib.SMTP_SSL if ssl else smtplib.SMTP - while True: - try: - with smtp_class(smtp_host, port=smtp_port) as smtp: - try: - if starttls: - smtp.starttls() - if from_pwd is not None: - smtp.login(smtp_username or from_addr, from_pwd) - - smtp.sendmail(from_addr, [to_addr], message) - return - 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') - print(e) - else: - raise - except OSError as e: - if e.errno in [16, -2]: - print("OSError exception message: ", e) - else: - raise - - if retry > 0: - retry = retry - 1 - time.sleep(1) - print("Retrying") - else: - 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(f"Reading mail from {imap_username}") - - message = None - - obj = imaplib.IMAP4_SSL(imap_host, imap_port) - obj.login(imap_username, to_pwd) - obj.select() - - today = datetime.today() - cutoff = today - timedelta(days=1) - 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""]: - 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}" - ) - - # FIXME: we only consider the first matching message... - uid = uids[0] - _, raw = obj.fetch(uid, "(RFC822)") - if delete: - 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") - 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}") - break - - if message is None: - print( - f"Error: no message with subject '{subject}' has been found in INBOX of {imap_username}" - ) - exit(1) - - if ignore_dkim_spf: - return - - # gmail set this standardized header - if "ARC-Authentication-Results" in message: - if "dkim=pass" in message["ARC-Authentication-Results"]: - 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"]: - print("SPF ok") - else: - print("Error: no SPF validation found in message:") - print(message.as_string()) - exit(3) - else: - 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 != "": - imap_username = args.imap_username - else: - imap_username = args.to_addr - - subject = f"{uuid.uuid4()}" - - _send_mail( - smtp_host=args.smtp_host, - smtp_port=args.smtp_port, - smtp_username=args.smtp_username, - from_addr=args.from_addr, - from_pwd=src_pwd, - to_addr=args.to_addr, - subject=subject, - starttls=args.smtp_starttls, - ssl=args.smtp_ssl, - ) - - _read_mail( - imap_host=args.imap_host, - imap_port=args.imap_port, - imap_username=imap_username, - to_pwd=dst_pwd, - subject=subject, - ignore_dkim_spf=args.ignore_dkim_spf, - ) - - -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, - ) - - -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-ssl", action="store_true") -parser_send_and_read.add_argument( - "--smtp-username", - type=str, - default="", - help="username used for smtp login. If not specified, the from-addr value is used", -) -parser_send_and_read.add_argument("--from-addr", type=str) -parser_send_and_read.add_argument("--imap-host", required=True, type=str) -parser_send_and_read.add_argument("--imap-port", type=str, default=993) -parser_send_and_read.add_argument("--to-addr", type=str, required=True) -parser_send_and_read.add_argument( - "--imap-username", - type=str, - default="", - help="username used for imap login. If not specified, the to-addr value is used", -) -parser_send_and_read.add_argument("--src-password-file", type=argparse.FileType("r")) -parser_send_and_read.add_argument( - "--dst-password-file", required=True, type=argparse.FileType("r") -) -parser_send_and_read.add_argument( - "--ignore-dkim-spf", - action="store_true", - help="to ignore the dkim and spf verification on the read mail", -) -parser_send_and_read.set_defaults(func=send_and_read) - -parser_read = subparsers.add_parser( - "read", - description="Search for an email with a subject containing 'subject' in the INBOX.", -) -parser_read.add_argument("--imap-host", type=str, default="localhost") -parser_read.add_argument("--imap-port", type=str, default=993) -parser_read.add_argument("--imap-username", required=True, type=str) -parser_read.add_argument("--imap-password", required=True, type=str) -parser_read.add_argument( - "--ignore-dkim-spf", - action="store_true", - help="to ignore the dkim and spf verification on the read mail", -) -parser_read.add_argument( - "--show-body", action="store_true", help="print mail text/plain payload" -) -parser_read.add_argument("subject", type=str) -parser_read.set_defaults(func=read) - -args = parser.parse_args() -args.func(args) diff --git a/shell.nix b/shell.nix index 493783d..2c06231 100644 --- a/shell.nix +++ b/shell.nix @@ -1,9 +1,11 @@ -(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 +let + nixpkgs = (import ./nix/sources.nix).nixpkgs-unstable; + pkgs = import nixpkgs {}; +in +pkgs.mkShell { + buildInputs = with pkgs; [ + (python3.withPackages(p: [p.sphinx p.sphinx_rtd_theme])) + niv + jq clamav + ]; +} diff --git a/tests/clamav.nix b/tests/clamav.nix index 209e91e..05cb30c 100644 --- a/tests/clamav.nix +++ b/tests/clamav.nix @@ -14,115 +14,109 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - lib, - blobs, - ... -}: +{ pkgs ? import {}}: -{ - name = "clamav"; +import (pkgs.path + "/nixos/tests/make-test.nix") { nodes = { - server = - { pkgs, ... }: - { - imports = [ - ../default.nix - ./lib/config.nix - ]; - - 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" ]; - }; - "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, ... }: + server = { config, pkgs, lib, ... }: let - serverIP = nodes.server.networking.primaryIPAddress; - clientIP = nodes.client.networking.primaryIPAddress; + clamav-db-files = pkgs.stdenv.mkDerivation rec { + name = "clamav-db-files"; + src = lib.cleanSource ./clamav; + dontUnpack = true; + installPhase = '' + mkdir $out + cp -R $src/*.cvd $out/ + ''; + }; + in + { + imports = [ + ../default.nix + ./lib/config.nix + ]; + + virtualisation.memorySize = 1500; + + 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 ${clamav-db-files}/main.cvd /var/lib/clamav/ + cp ${clamav-db-files}/daily.cvd /var/lib/clamav/ + cp ${clamav-db-files}/bytecode.cvd /var/lib/clamav/ + chown clamav:clamav /var/lib/clamav/* + ''; + + serviceConfig = { + Type = "oneshot"; + PrivateTmp = "yes"; + PrivateDevices = "yes"; + }; + }; + + mailserver = { + enable = true; + debug = 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, 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 +190,56 @@ ''; }; }; - }; + }; - testScript = '' - start_all() + testScript = { nodes, ... }: + '' + startAll; - server.wait_for_unit("multi-user.target") - client.wait_for_unit("multi-user.target") + $server->waitForUnit("multi-user.target"); + $client->waitForUnit("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->waitUntilSucceeds("timeout 1 ${nodes.server.pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ \$? -eq 124 ]"); + $server->waitUntilSucceeds("timeout 1 ${nodes.server.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->waitUntilFails('[ "$(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' - ) + subtest "virus scan file", sub { + $server->succeed("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" ]') + subtest "virus scan email", sub { + $client->succeed("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->waitUntilFails('[ "$(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") - ''; + subtest "no warnings or errors", sub { + $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/clamav/.gitattributes b/tests/clamav/.gitattributes new file mode 100644 index 0000000..054ac19 --- /dev/null +++ b/tests/clamav/.gitattributes @@ -0,0 +1 @@ +*cvd filter=lfs diff=lfs merge=lfs -text diff --git a/tests/clamav/.gitignore b/tests/clamav/.gitignore new file mode 100644 index 0000000..c5797d6 --- /dev/null +++ b/tests/clamav/.gitignore @@ -0,0 +1 @@ +mirrors.dat diff --git a/tests/clamav/bytecode.cvd b/tests/clamav/bytecode.cvd new file mode 100644 index 0000000..d3d8682 Binary files /dev/null and b/tests/clamav/bytecode.cvd differ diff --git a/tests/clamav/daily.cvd b/tests/clamav/daily.cvd new file mode 100644 index 0000000..e3869e3 Binary files /dev/null and b/tests/clamav/daily.cvd differ diff --git a/tests/clamav/freshclam.conf b/tests/clamav/freshclam.conf new file mode 100644 index 0000000..3d9ca5f --- /dev/null +++ b/tests/clamav/freshclam.conf @@ -0,0 +1 @@ +DatabaseMirror database.clamav.net diff --git a/tests/clamav/hashes.json b/tests/clamav/hashes.json new file mode 100644 index 0000000..4bcec9e --- /dev/null +++ b/tests/clamav/hashes.json @@ -0,0 +1,5 @@ +{ + "bytecode.cvd": "633d4f0a2054249e23df12db5a9e76bcaac23cadaef5ee8f644986f600d8d81e", + "daily.cvd": "0b6798b54e490be168b873d39ebda41ff4a027720aed855f879779b88982838f", + "main.cvd": "9694933f37148ec39c1f2ef7b97211ded9b03b140bb48a5eeb27270120844b24" +} diff --git a/tests/clamav/main.cvd b/tests/clamav/main.cvd new file mode 100644 index 0000000..ac4d384 Binary files /dev/null and b/tests/clamav/main.cvd differ diff --git a/tests/clamav/update-clamav-database.sh b/tests/clamav/update-clamav-database.sh new file mode 100755 index 0000000..91f1ce1 --- /dev/null +++ b/tests/clamav/update-clamav-database.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +set -e + +cd "$(dirname "${0}")" + +rm ./*.cvd hashes.json || : + +freshclam --datadir=. --config-file=freshclam.conf +(for i in ./*.cvd; + do echo '{}' | + jq --arg path "$(basename "${i}")" \ + --arg sha256sum "$(sha256sum "${i}" | awk '{ print $1; }')" \ + '.[$path] = $sha256sum'; done) | + jq -s add > hashes.json diff --git a/tests/default.nix b/tests/default.nix new file mode 100644 index 0000000..11b3843 --- /dev/null +++ b/tests/default.nix @@ -0,0 +1,46 @@ +# Generate an attribute sets containing all tests for all releaeses +# It looks like: +# - extern.nixpkgs_20.03 +# - extern.nixpkgs_unstable +# - intern.nixpkgs_20.03 +# - intern.nixpkgs_unstable + +with builtins; + +let + sources = import ../nix/sources.nix; + + releases = listToAttrs (map genRelease releaseNames); + + genRelease = name: { + name = name; + value = import sources."${name}" {}; + }; + + genTest = testName: release: + let + pkgs = releases."${release}"; + test = pkgs.callPackage (./. + "/${testName}.nix") { }; + in { + "name"= builtins.replaceStrings ["." "-"] ["_" "_"] release; + "value"= test { inherit pkgs; }; + }; + + releaseNames = [ + "nixpkgs-20.03" + "nixpkgs-unstable" + ]; + + testNames = [ + "intern" + "extern" + "clamav" + ]; + + # Generate an attribute set containing one test per releases + genTests = testName: { + name = testName; + value = listToAttrs (map (genTest testName) (builtins.attrNames releases)); + }; + +in listToAttrs (map genTests testNames) diff --git a/tests/extern.nix b/tests/extern.nix new file mode 100644 index 0000000..343c458 --- /dev/null +++ b/tests/extern.nix @@ -0,0 +1,418 @@ +# 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 + +{ pkgs ? import {}}: + +import (pkgs.path + "/nixos/tests/make-test.nix") { + + nodes = { + server = { config, pkgs, ... }: + { + imports = [ + ../default.nix + ./lib/config.nix + ]; + + 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; + + 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; + }; + }; + 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}' "$@" + ''; + check-mail-id = pkgs.writeScriptBin "check-mail-id" '' + #!${pkgs.stdenv.shell} + echo grep '^Message-ID:.*@mail.example.com>$' "$@" >&2 + exec grep '^Message-ID:.*@mail.example.com>$' "$@" + ''; + test-imap-spam = pkgs.writeScriptBin "imap-mark-spam" '' + #!${pkgs.python3.interpreter} + import imaplib + + with imaplib.IMAP4_SSL('${serverIP}') as imap: + imap.login('user1@example.com', 'user1') + imap.select() + status, [response] = imap.search(None, 'ALL') + msg_ids = response.decode("utf-8").split(' ') + print(msg_ids) + assert status == 'OK' + assert len(msg_ids) == 1 + + imap.copy(','.join(msg_ids), 'Junk') + for num in msg_ids: + imap.store(num, '+FLAGS', '\\Deleted') + imap.expunge() + + imap.select('Junk') + status, [response] = imap.search(None, 'ALL') + msg_ids = response.decode("utf-8").split(' ') + print(msg_ids) + assert status == 'OK' + assert len(msg_ids) == 1 + + imap.close() + ''; + test-imap-ham = pkgs.writeScriptBin "imap-mark-ham" '' + #!${pkgs.python3.interpreter} + import imaplib + + with imaplib.IMAP4_SSL('${serverIP}') as imap: + imap.login('user1@example.com', 'user1') + imap.select('Junk') + status, [response] = imap.search(None, 'ALL') + msg_ids = response.decode("utf-8").split(' ') + print(msg_ids) + assert status == 'OK' + assert len(msg_ids) == 1 + + imap.copy(','.join(msg_ids), 'INBOX') + for num in msg_ids: + imap.store(num, '+FLAGS', '\\Deleted') + imap.expunge() + + imap.select('INBOX') + status, [response] = imap.search(None, 'ALL') + msg_ids = response.decode("utf-8").split(' ') + print(msg_ids) + assert status == 'OK' + assert len(msg_ids) == 1 + + imap.close() + ''; + in { + imports = [ + ./lib/config.nix + ]; + environment.systemPackages = with pkgs; [ + fetchmail msmtp procmail findutils grep-ip check-mail-id test-imap-spam test-imap-ham + ]; + environment.etc = { + "root/.fetchmailrc" = { + text = '' + 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 + ''; + mode = "0700"; + }; + "root/.procmailrc" = { + text = "DEFAULT=$HOME/mail"; + }; + "root/.msmtprc" = { + text = '' + account test + host ${serverIP} + port 587 + from user2@example.com + user user2@example.com + password user2 + + account test2 + host ${serverIP} + port 587 + from user@example2.com + user user@example2.com + password user2 + + account test3 + host ${serverIP} + port 587 + from chuck@example.com + user user2@example.com + password user2 + + account test4 + host ${serverIP} + port 587 + from postmaster@example.com + user user1@example.com + password user1 + + account test5 + host ${serverIP} + port 587 + from single-alias@example.com + user user1@example.com + password user1 + ''; + }; + "root/email1".text = '' + Message-ID: <12345qwerty@host.local.network> + From: User2 + To: User1 + Cc: + Bcc: + Subject: This is a test Email from user2 to user1 + Reply-To: + + Hello User1, + + how are you doing today? + ''; + "root/email2".text = '' + Message-ID: <232323abc@host.local.network> + From: User + To: User1 + Cc: + Bcc: + Subject: This is a test Email from user@example2.com to user1 + Reply-To: + + Hello User1, + + how are you doing today? + + XOXO User1 + ''; + "root/email3".text = '' + Message-ID: + From: Postmaster + To: Chuck + Cc: + Bcc: + Subject: This is a test Email from postmaster\@example.com to chuck + Reply-To: + + Hello Chuck, + + I think I may have misconfigured the mail server + XOXO Postmaster + ''; + "root/email4".text = '' + Message-ID: + From: Single Alias + To: User1 + Cc: + Bcc: + Subject: This is a test Email from single-alias\@example.com to user1 + Reply-To: + + Hello User1, + + how are you doing today? + + XOXO User1 aka Single Alias + ''; + "root/email5".text = '' + Message-ID: <789asdf@host.local.network> + From: User2 + To: Multi Alias + Cc: + Bcc: + Subject: This is a test Email from user2\@example.com to multi-alias + Reply-To: + + Hello Multi Alias, + + how are we doing today? + + XOXO User1 + ''; + }; + }; + }; + + testScript = { nodes, ... }: + '' + startAll; + + $server->waitForUnit("multi-user.target"); + $client->waitForUnit("multi-user.target"); + + # TODO put this blocking into the systemd units? + $server->waitUntilSucceeds("timeout 1 ${nodes.server.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"); + + subtest "imap retrieving mail", sub { + # fetchmail returns EXIT_CODE 1 when no new mail + $client->succeed("fetchmail --nosslcertck -v || [ \$? -eq 1 ] >&2"); + }; + + subtest "submission port send mail", sub { + # 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->waitUntilFails('[ "$(postqueue -p)" != "Mail queue is empty" ]'); + }; + + subtest "imap retrieving mail 2", sub { + $client->execute("rm ~/mail/*"); + # fetchmail returns EXIT_CODE 0 when it retrieves mail + $client->succeed("fetchmail --nosslcertck -v >&2"); + }; + + subtest "remove sensitive information on submission port", sub { + $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/*"); + }; + + subtest "have correct fqdn as sender", sub { + $client->succeed("grep 'Received: from mail.example.com' ~/mail/*"); + }; + + subtest "dkim has user-specified size", sub { + $server->succeed("openssl rsa -in /var/dkim/example.com.mail.key -text -noout | grep 'Private-Key: (1535 bit'"); + }; + + subtest "dkim singing, multiple domains", sub { + $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->waitUntilFails('[ "$(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/*"); + }; + + subtest "aliases", sub { + $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->waitUntilFails('[ "$(postqueue -p)" != "Mail queue is empty" ]'); + # fetchmail returns EXIT_CODE 0 when it retrieves mail + $client->succeed("fetchmail --nosslcertck -v"); + }; + + subtest "catchAlls", sub { + $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->waitUntilFails('[ "$(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->waitUntilFails('[ "$(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"); + }; + + subtest "extraVirtualAliases", sub { + $client->execute("rm ~/mail/*"); + # send email from single-alias to user1 + $client->succeed("msmtp -a test5 --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email4 >&2"); + $server->waitUntilFails('[ "$(postqueue -p)" != "Mail queue is empty" ]'); + # fetchmail returns EXIT_CODE 0 when it retrieves mail + $client->succeed("fetchmail --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->waitUntilFails('[ "$(postqueue -p)" != "Mail queue is empty" ]'); + # fetchmail returns EXIT_CODE 0 when it retrieves mail + $client->succeed("fetchmail --nosslcertck -v"); + }; + + subtest "quota", sub { + $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->waitUntilFails('[ "$(postqueue -p)" != "Mail queue is empty" ]'); + # fetchmail returns EXIT_CODE 0 when it retrieves mail + $client->fail("fetchmail --nosslcertck -v"); + + }; + + subtest "imap sieve junk trainer", sub { + # 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->waitUntilFails('[ "$(postqueue -p)" != "Mail queue is empty" ]'); + + $client->succeed("imap-mark-spam >&2"); + $server->waitUntilSucceeds("journalctl -u dovecot2 | grep -i sa-learn-spam.sh >&2"); + $client->succeed("imap-mark-ham >&2"); + $server->waitUntilSucceeds("journalctl -u dovecot2 | grep -i sa-learn-ham.sh >&2"); + }; + + subtest "no warnings or errors", sub { + $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 deleted file mode 100644 index 0f47acb..0000000 --- a/tests/external.nix +++ /dev/null @@ -1,528 +0,0 @@ -# nixos-mailserver: a simple mail server -# Copyright (C) 2016-2018 Robin Raymond -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see - -{ - 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.dovecot = true; # enabled for sieve script logging - fqdn = "mail.example.com"; - domains = [ - "example.com" - "example2.com" - ]; - rewriteMessageId = true; - dkimKeyBits = 1535; - dmarcReporting.enable = true; - - loginAccounts = { - "user1@example.com" = { - hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/"; - aliases = [ "postmaster@example.com" ]; - catchAll = [ "example.com" ]; - }; - "user2@example.com" = { - hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0"; - aliases = [ "chuck@example.com" ]; - }; - "user@example2.com" = { - hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0"; - }; - "lowquota@example.com" = { - hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0"; - quota = "1B"; - }; - }; - - extraVirtualAliases = { - "single-alias@example.com" = "user1@example.com"; - "multi-alias@example.com" = [ - "user1@example.com" - "user2@example.com" - ]; - }; - - enableImap = true; - enableImapSsl = true; - fullTextSearch = { - enable = true; - autoIndex = true; - # special use depends on https://github.com/NixOS/nixpkgs/pull/93201 - autoIndexExclude = [ - (if (pkgs.lib.versionAtLeast pkgs.lib.version "21") then "\\Junk" else "Junk") - ]; - enforced = "yes"; - }; - }; - }; - client = - { nodes, pkgs, ... }: - let - serverIP = nodes.server.networking.primaryIPAddress; - clientIP = nodes.client.networking.primaryIPAddress; - grep-ip = pkgs.writeScriptBin "grep-ip" '' - #!${pkgs.stdenv.shell} - echo grep '${clientIP}' "$@" >&2 - exec grep '${clientIP}' "$@" - ''; - check-mail-id = pkgs.writeScriptBin "check-mail-id" '' - #!${pkgs.stdenv.shell} - echo grep '^Message-ID:.*@mail.example.com>$' "$@" >&2 - exec grep '^Message-ID:.*@mail.example.com>$' "$@" - ''; - test-imap-spam = pkgs.writeScriptBin "imap-mark-spam" '' - #!${pkgs.python3.interpreter} - import imaplib - - with imaplib.IMAP4_SSL('${serverIP}') as imap: - imap.login('user1@example.com', 'user1') - imap.select() - status, [response] = imap.search(None, 'ALL') - msg_ids = response.decode("utf-8").split(' ') - print(msg_ids) - assert status == 'OK' - assert len(msg_ids) == 1 - - imap.copy(','.join(msg_ids), 'Junk') - for num in msg_ids: - imap.store(num, '+FLAGS', '\\Deleted') - imap.expunge() - - imap.select('Junk') - status, [response] = imap.search(None, 'ALL') - msg_ids = response.decode("utf-8").split(' ') - print(msg_ids) - assert status == 'OK' - assert len(msg_ids) == 1 - - imap.close() - ''; - test-imap-ham = pkgs.writeScriptBin "imap-mark-ham" '' - #!${pkgs.python3.interpreter} - import imaplib - - with imaplib.IMAP4_SSL('${serverIP}') as imap: - imap.login('user1@example.com', 'user1') - imap.select('Junk') - status, [response] = imap.search(None, 'ALL') - msg_ids = response.decode("utf-8").split(' ') - print(msg_ids) - assert status == 'OK' - assert len(msg_ids) == 1 - - imap.copy(','.join(msg_ids), 'INBOX') - for num in msg_ids: - imap.store(num, '+FLAGS', '\\Deleted') - imap.expunge() - - imap.select('INBOX') - status, [response] = imap.search(None, 'ALL') - msg_ids = response.decode("utf-8").split(' ') - print(msg_ids) - assert status == 'OK' - assert len(msg_ids) == 1 - - imap.close() - ''; - search = pkgs.writeScriptBin "search" '' - #!${pkgs.python3.interpreter} - import imaplib - import sys - - [_, mailbox, needle] = sys.argv - - with imaplib.IMAP4_SSL('${serverIP}') as imap: - imap.login('user1@example.com', 'user1') - imap.select(mailbox) - status, [response] = imap.search(None, 'BODY', repr(needle)) - msg_ids = [ i for i in response.decode("utf-8").split(' ') if i ] - print(msg_ids) - assert status == 'OK' - assert len(msg_ids) == 1 - status, response = imap.fetch(msg_ids[0], '(RFC822)') - assert status == "OK" - assert needle in repr(response) - imap.close() - ''; - in - { - imports = [ - ./lib/config.nix - ]; - environment.systemPackages = with pkgs; [ - 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 - ''; - mode = "0700"; - }; - "root/.fetchmailRcLowQuota" = { - text = '' - poll ${serverIP} with proto IMAP - user 'lowquota@example.com' there with password 'user2' is 'root' here - mda procmail - ''; - mode = "0700"; - }; - "root/.procmailrc" = { - text = "DEFAULT=$HOME/mail"; - }; - "root/.msmtprc" = { - text = '' - account test - host ${serverIP} - port 587 - from user2@example.com - user user2@example.com - password user2 - - account test2 - host ${serverIP} - port 587 - from user@example2.com - user user@example2.com - password user2 - - account test3 - host ${serverIP} - port 587 - from chuck@example.com - user user2@example.com - password user2 - - account test4 - host ${serverIP} - port 587 - from postmaster@example.com - user user1@example.com - password user1 - - account test5 - host ${serverIP} - port 587 - from single-alias@example.com - user user1@example.com - password user1 - ''; - }; - "root/email1".text = '' - Message-ID: <12345qwerty@host.local.network> - From: User2 - To: User1 - Cc: - Bcc: - Subject: This is a test Email from user2 to user1 - Reply-To: - - Hello User1, - - how are you doing today? - ''; - "root/email2".text = '' - Message-ID: <232323abc@host.local.network> - From: User - To: User1 - Cc: - Bcc: - Subject: This is a test Email from user@example2.com to user1 - Reply-To: - - Hello User1, - - how are you doing today? - - XOXO User1 - ''; - "root/email3".text = '' - Message-ID: - From: Postmaster - To: Chuck - Cc: - Bcc: - Subject: This is a test Email from postmaster@example.com to chuck - Reply-To: - - Hello Chuck, - - I think I may have misconfigured the mail server - XOXO Postmaster - ''; - "root/email4".text = '' - Message-ID: - From: Single Alias - To: User1 - Cc: - Bcc: - Subject: This is a test Email from single-alias@example.com to user1 - Reply-To: - - Hello User1, - - how are you doing today? - - XOXO User1 aka Single Alias - ''; - "root/email5".text = '' - Message-ID: <789asdf@host.local.network> - From: User2 - To: Multi Alias - Cc: - Bcc: - Subject: This is a test Email from user2@example.com to multi-alias - Reply-To: - - Hello Multi Alias, - - how are we doing today? - - XOXO User1 - ''; - "root/email6".text = '' - Message-ID: <123457qwerty@host.local.network> - From: User2 - To: User1 - Cc: - Bcc: - Subject: This is a test Email from user2 to user1 - Reply-To: - - Hello User1, - - this email contains the needle: - 576a4565b70f5a4c1a0925cabdb587a6 - ''; - "root/email7".text = '' - Message-ID: <1234578qwerty@host.local.network> - From: User2 - To: User1 - Cc: - Bcc: - Subject: This is a test Email from user2 to user1 - Reply-To: - - Hello User1, - - this email does not contain the needle :( - ''; - }; - }; - }; - - testScript = '' - start_all() - - server.wait_for_unit("multi-user.target") - client.wait_for_unit("multi-user.target") - - # TODO put this blocking into the systemd units? - server.wait_until_succeeds( - "set +e; timeout 1 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") - - 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("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("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 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("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") - - 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") - - 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") - - 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" ]') - - client.succeed("imap-mark-spam >&2") - server.wait_until_succeeds("journalctl -u dovecot -u dovecot2 | grep -i rspamd-learn-spam.sh >&2") - client.succeed("imap-mark-ham >&2") - server.wait_until_succeeds("journalctl -u dovecot -u dovecot2 | grep -i rspamd-learn-ham.sh >&2") - - with subtest("full text search and indexation"): - # send 2 email from user2 to user1 - client.succeed( - "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email6 >&2" - ) - 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 dovecot -u dovecot2 | grep 'fts-flatcurve(INBOX): Query ' >&2") - # check that Junk is not indexed - server.fail("journalctl -u dovecot -u dovecot2 | grep 'fts-flatcurve(JUNK): Indexing ' >&2") - - with subtest("dmarc reporting"): - server.systemctl("start rspamd-dmarc-reporter.service") - - 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 dovecot -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 dovecot -u dovecot2 | \ - grep -v 'Expunged message reappeared, giving a new UID' | \ - grep -v 'Time moved forwards' | \ - grep -i warning >&2" - ) - ''; -} diff --git a/tests/intern.nix b/tests/intern.nix new file mode 100644 index 0000000..f96643c --- /dev/null +++ b/tests/intern.nix @@ -0,0 +1,89 @@ +# 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 + +{ pkgs ? import {}}: + +let + sendMail = pkgs.writeTextFile { + "name" = "send-mail-to-send-only-account"; + "text" = '' + EHLO mail.example.com + MAIL FROM: none@example.com + RCPT TO: send-only@example.com + QUIT + ''; + }; + + hashPassword = password: pkgs.runCommand + "password-${password}-hashed" + { buildInputs = [ pkgs.mkpasswd ]; } '' + mkpasswd -m sha-512 ${password} > $out + ''; + +in +import (pkgs.path + "/nixos/tests/make-test.nix") { + + machine = + { config, pkgs, ... }: + { + imports = [ + ./../default.nix + ./lib/config.nix + ]; + + virtualisation.memorySize = 1024; + + mailserver = { + enable = true; + fqdn = "mail.example.com"; + domains = [ "example.com" ]; + + loginAccounts = { + "user1@example.com" = { + hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/"; + }; + "send-only@example.com" = { + hashedPasswordFile = hashPassword "send-only"; + sendOnly = true; + }; + }; + + vmailGroupName = "vmail"; + vmailUID = 5000; + }; + }; + + testScript = + '' + $machine->start; + $machine->waitForUnit("multi-user.target"); + + subtest "vmail gid is set correctly", sub { + $machine->succeed("getent group vmail | grep 5000"); + }; + + subtest "mail to send only accounts is rejected", sub { + $machine->waitForOpenPort(25); + # TODO put this blocking into the systemd units? + $machine->waitUntilSucceeds("timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ \$? -eq 124 ]"); + $machine->succeed("cat ${sendMail} | ${pkgs.netcat-gnu}/bin/nc localhost 25 | grep -q 'This account cannot receive emails'" ); + }; + + subtest "rspamd controller serves web ui", sub { + $machine->succeed("${pkgs.curl}/bin/curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q ''" ); + }; + ''; +} diff --git a/tests/internal.nix b/tests/internal.nix deleted file mode 100644 index 29d0880..0000000 --- a/tests/internal.nix +++ /dev/null @@ -1,227 +0,0 @@ -# nixos-mailserver: a simple mail server -# Copyright (C) 2016-2018 Robin Raymond -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see - -{ - pkgs, - ... -}: - -let - sendMail = pkgs.writeTextFile { - "name" = "send-mail-to-send-only-account"; - "text" = '' - EHLO mail.example.com - MAIL FROM: none@example.com - RCPT TO: send-only@example.com - QUIT - ''; - }; - - 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 -{ - name = "internal"; - - nodes = { - machine = - { pkgs, ... }: - { - imports = [ - ./../default.nix - ./lib/config.nix - ]; - - virtualisation.memorySize = 1024; - - environment.systemPackages = [ - (pkgs.writeScriptBin "mail-check" '' - ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ - '') - ] - ++ (with pkgs; [ - curl - openssl - netcat - ]); - - mailserver = { - enable = true; - fqdn = "mail.example.com"; - domains = [ - "example.com" - "domain.com" - ]; - localDnsResolver = false; - - loginAccounts = { - "user1@example.com" = { - hashedPasswordFile = hashedPasswordFile; - }; - "user2@example.com" = { - hashedPasswordFile = hashedPasswordFile; - aliasesRegexp = [ ''/^user2.*@domain\.com$/'' ]; - }; - "send-only@example.com" = { - hashedPasswordFile = hashPassword "send-only"; - sendOnly = true; - }; - }; - 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; - indexDir = "/var/lib/dovecot/indices"; - - enableImap = false; - }; - }; - }; - testScript = - { - nodes, - ... - }: - '' - machine.start() - machine.wait_for_unit("multi-user.target") - - # Regression test for https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues/205 - with subtest("mail forwarded can are locally kept"): - # A mail sent to user2@example.com via explicit TLS is in the user1@example.com mailbox - machine.succeed( - " ".join( - [ - "mail-check send-and-read", - "--smtp-port 587", - "--smtp-starttls", - "--smtp-host localhost", - "--imap-host localhost", - "--imap-username user1@example.com", - "--from-addr user1@example.com", - "--to-addr user2@example.com", - "--src-password-file ${passwordFile}", - "--dst-password-file ${passwordFile}", - "--ignore-dkim-spf", - ] - ) - ) - # A mail sent to user2@example.com via implicit TLS is in the user2@example.com mailbox - machine.succeed( - " ".join( - [ - "mail-check send-and-read", - "--smtp-port 465", - "--smtp-ssl", - "--smtp-host localhost", - "--imap-host localhost", - "--imap-username user2@example.com", - "--from-addr user1@example.com", - "--to-addr user2@example.com", - "--src-password-file ${passwordFile}", - "--dst-password-file ${passwordFile}", - "--ignore-dkim-spf", - ] - ) - ) - - with subtest("regex email alias are received"): - # A mail sent to user2-regex-alias@domain.com via explicit TLS is in the user2@example.com mailbox - machine.succeed( - " ".join( - [ - "mail-check send-and-read", - "--smtp-port 587", - "--smtp-starttls", - "--smtp-host localhost", - "--imap-host localhost", - "--imap-username user2@example.com", - "--from-addr user1@example.com", - "--to-addr user2-regex-alias@domain.com", - "--src-password-file ${passwordFile}", - "--dst-password-file ${passwordFile}", - "--ignore-dkim-spf", - ] - ) - ) - - with subtest("user can send from regex email alias"): - # A mail sent to user1@example.com from user2-regex-alias@domain.com by - # user2@example.com via implicit TLS is in the user1@example.com mailbox - machine.succeed( - " ".join( - [ - "mail-check send-and-read", - "--smtp-port 465", - "--smtp-ssl", - "--smtp-host localhost", - "--imap-host localhost", - "--smtp-username user2@example.com", - "--from-addr user2-regex-alias@domain.com", - "--to-addr user1@example.com", - "--src-password-file ${passwordFile}", - "--dst-password-file ${passwordFile}", - "--ignore-dkim-spf", - ] - ) - ) - - with subtest("vmail gid is set correctly"): - machine.succeed("getent group vmail | grep 5000") - - with subtest("Check dovecot maildir and index locations"): - # If these paths change we need a migration - machine.succeed("doveadm user -f home user1@example.com | grep ${nodes.machine.mailserver.mailDirectory}/example.com/user1") - machine.succeed("doveadm user -f mail user1@example.com | grep 'maildir:~/mail:INDEX=${nodes.machine.mailserver.indexDir}/example.com/user1'") - - with subtest("mail to send only accounts is rejected"): - machine.wait_for_open_port(25) - # TODO put this blocking into the systemd units - machine.wait_until_succeeds( - "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" - ) - machine.succeed( - "cat ${sendMail} | nc localhost 25 | grep -q '554 5.5.0 Error'" - ) - - with subtest("rspamd controller serves web ui"): - machine.succeed( - "set +o pipefail; curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q ''" - ) - - 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'" - ) - ''; -} diff --git a/tests/ldap.nix b/tests/ldap.nix deleted file mode 100644 index 4d0675a..0000000 --- a/tests/ldap.nix +++ /dev/null @@ -1,231 +0,0 @@ -let - bindPassword = "unsafegibberish"; - alicePassword = "testalice"; - bobPassword = "testbob"; -in -{ - name = "ldap"; - - nodes = { - machine = - { pkgs, ... }: - { - imports = [ - ./../default.nix - ./lib/config.nix - ]; - - virtualisation.memorySize = 1024; - - services.openssh = { - enable = true; - settings.PermitRootLogin = "yes"; - }; - - environment.systemPackages = [ - (pkgs.writeScriptBin "mail-check" '' - ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ - '') - ]; - - 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"; - }; - }; - }; - }; - declarativeContents."dc=example" = '' - dn: dc=example - objectClass: domain - dc: example - - dn: cn=mail,dc=example - objectClass: organizationalRole - objectClass: simpleSecurityObject - objectClass: top - cn: mail - userPassword: ${bindPassword} - - dn: ou=users,dc=example - objectClass: organizationalUnit - ou: users - - dn: cn=alice,ou=users,dc=example - objectClass: inetOrgPerson - cn: alice - sn: Foo - mail: alice@example.com - userPassword: ${alicePassword} - - dn: cn=bob,ou=users,dc=example - objectClass: inetOrgPerson - cn: bob - sn: Bar - mail: bob@example.com - userPassword: ${bobPassword} - ''; - }; - - mailserver = { - enable = true; - fqdn = "mail.example.com"; - domains = [ "example.com" ]; - localDnsResolver = false; - indexDir = "/var/lib/dovecot/indices"; - - ldap = { - enable = true; - uris = [ - "ldap://" - ]; - bind = { - dn = "cn=mail,dc=example"; - passwordFile = "/etc/bind-password"; - }; - searchBase = "ou=users,dc=example"; - searchScope = "sub"; - }; - - forwards = { - "bob_fw@example.com" = "bob@example.com"; - }; - - vmailGroupName = "vmail"; - vmailUID = 5000; - - enableImap = false; - }; - }; - }; - testScript = - { - nodes, - ... - }: - '' - import sys - import re - - machine.start() - machine.wait_for_unit("multi-user.target") - - # This function retrieves the ldap table file from a postconf - # command. - # A key lookup is achived and the returned value is compared - # to the expected value. - def test_lookup(postconf_cmdline, key, expected): - conf = machine.succeed(postconf_cmdline).rstrip() - ldap_table_path = re.match('.* =.*ldap:(.*)', conf).group(1) - value = machine.succeed(f"postmap -q {key} ldap:{ldap_table_path}").rstrip() - try: - assert value == expected - except AssertionError: - print(f"Expected {conf} lookup for key '{key}' to return '{expected}, but got '{value}'", file=sys.stderr) - raise - - with subtest("Test postmap lookups"): - test_lookup("postconf virtual_mailbox_maps", "alice@example.com", "alice@example.com") - test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "alice@example.com", "alice@example.com") - - test_lookup("postconf virtual_mailbox_maps", "bob@example.com", "bob@example.com") - test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "bob@example.com", "bob@example.com") - - with subtest("Test doveadm lookups"): - machine.succeed("doveadm user -u alice@example.com") - machine.succeed("doveadm user -u bob@example.com") - - with subtest("Files containing secrets are only readable by root"): - machine.succeed("ls -l /run/postfix/*.cf | grep -e '-rw------- 1 root root'") - machine.succeed("ls -l /run/dovecot2/dovecot-ldap.conf.ext | grep -e '-rw------- 1 root root'") - - with subtest("Test account/mail address binding via explicit TLS"): - machine.fail(" ".join([ - "mail-check send-and-read", - "--smtp-port 587", - "--smtp-starttls", - "--smtp-host localhost", - "--smtp-username alice@example.com", - "--imap-host localhost", - "--imap-username bob@example.com", - "--from-addr bob@example.com", - "--to-addr aliceb@example.com", - "--src-password-file <(echo '${alicePassword}')", - "--dst-password-file <(echo '${bobPassword}')", - "--ignore-dkim-spf" - ])) - machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user alice@example.com'") - - with subtest("Test mail delivery via implicit TLS"): - machine.succeed(" ".join([ - "mail-check send-and-read", - "--smtp-port 465", - "--smtp-ssl", - "--smtp-host localhost", - "--smtp-username alice@example.com", - "--imap-host localhost", - "--imap-username bob@example.com", - "--from-addr alice@example.com", - "--to-addr bob@example.com", - "--src-password-file <(echo '${alicePassword}')", - "--dst-password-file <(echo '${bobPassword}')", - "--ignore-dkim-spf" - ])) - - with subtest("Test mail forwarding via explicit TLS works"): - machine.succeed(" ".join([ - "mail-check send-and-read", - "--smtp-port 587", - "--smtp-starttls", - "--smtp-host localhost", - "--smtp-username alice@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 via implicit TLS from forwarded address"): - machine.fail(" ".join([ - "mail-check send-and-read", - "--smtp-port 465", - "--smtp-ssl", - "--smtp-host localhost", - "--smtp-username bob@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'") - - with subtest("Check dovecot mail and index locations"): - # If these paths change we need a migration - machine.succeed("doveadm user -f home bob@example.com | grep ${nodes.machine.mailserver.mailDirectory}/ldap/bob@example.com") - machine.succeed("doveadm user -f mail bob@example.com | grep 'maildir:~/mail:INDEX=${nodes.machine.mailserver.indexDir}/ldap/bob@example.com'") - ''; -} diff --git a/tests/lib/config.nix b/tests/lib/config.nix index 199e1b8..2cdc9d2 100644 --- a/tests/lib/config.nix +++ b/tests/lib/config.nix @@ -1,25 +1,3 @@ { - lib, - ... -}: - -{ - # Testing eval failures that result from stateVersion assertion is out of scope - mailserver.stateVersion = 999; - - # Enable second CPU core - virtualisation.cores = lib.mkDefault 2; - - services.rspamd = { - # Don't make tests block on DNS requests that will never succeed - locals."options.inc".text = '' - dns { - nameservers = ["127.0.0.1"]; - timeout = 0.0s; - retransmits = 0; - } - ''; - # Relax `local_addrs` definition to default for tests, so mail doesn't get flagged as spam - overrides."options.inc".enable = false; - }; + security.dhparams.defaultBitSize = 1024; # minimum size required by dovecot } diff --git a/tests/minimal.nix b/tests/minimal.nix new file mode 100644 index 0000000..7327f55 --- /dev/null +++ b/tests/minimal.nix @@ -0,0 +1,31 @@ +# 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 + +import { + + machine = + { config, pkgs, ... }: + { + imports = [ + ./../default.nix + ]; + }; + + testScript = + '' + $machine->waitForUnit("multi-user.target"); + ''; +} diff --git a/tests/multiple.nix b/tests/multiple.nix deleted file mode 100644 index 8ba2920..0000000 --- a/tests/multiple.nix +++ /dev/null @@ -1,113 +0,0 @@ -# This tests is used to test features requiring several mail domains. - -{ - pkgs, - ... -}: - -let - hashPassword = - password: - pkgs.runCommand "password-${password}-hashed" - { - buildInputs = [ pkgs.mkpasswd ]; - inherit password; - } - '' - mkpasswd -sm bcrypt <<<"$password" > $out - ''; - - password = pkgs.writeText "password" "password"; - - domainGenerator = - domain: - { pkgs, ... }: - { - imports = [ - ../default.nix - ./lib/config.nix - ]; - environment.systemPackages = with pkgs; [ netcat ]; - virtualisation.memorySize = 1024; - mailserver = { - enable = true; - fqdn = "mail.${domain}"; - domains = [ domain ]; - localDnsResolver = false; - loginAccounts = { - "user@${domain}" = { - hashedPasswordFile = hashPassword "password"; - }; - }; - enableImap = true; - enableImapSsl = true; - }; - services.dnsmasq = { - enable = true; - settings.mx-host = [ - "domain1.com,domain1,10" - "domain2.com,domain2,10" - ]; - }; - }; - -in - -{ - 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" - ]; - }; - }; - domain2 = domainGenerator "domain2.com"; - client = - { pkgs, ... }: - { - environment.systemPackages = [ - (pkgs.writeScriptBin "mail-check" '' - ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ - '') - ]; - }; - }; - testScript = '' - start_all() - - domain1.wait_for_unit("multi-user.target") - domain2.wait_for_unit("multi-user.target") - - # 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 ]" - ) - domain2.wait_until_succeeds( - "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" - ) - - # user@domain1.com sends a mail to user@domain2.com via explicit TLS - client.succeed( - "mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf" - ) - - # Send a mail to the address forwarded via implicit TLS and check it is in the recipient mailbox - client.succeed( - "mail-check send-and-read --smtp-port 465 --smtp-ssl --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr non-local@domain1.com --imap-username user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf" - ) - ''; -} 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