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..b74daa0 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} 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 diff --git a/.hydra/declarative-jobsets.nix b/.hydra/declarative-jobsets.nix index 6877235..29750a4 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-20.09" = { + description = "Build the nixos-20.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-20.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..561b55b 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,162 @@ # ![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.09 + - Use the [SNM branch `nixos-20.09`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-20.09) + - [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-20.09/) + - [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-20.09/release-notes.html#nixos-20-09) +* 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 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) + - [Documentation](https://nixos-mailserver.readthedocs.io/en/latest/) + - This branch is currently supporting the NixOS release 20.09 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 tls on port 465 + - [x] submission starttls on port 587 + - [x] lmtp with dovecot + * Dovecot + - [x] maildir folders + - [x] imap with tls on port 993 + - [x] pop3 with tls on port 995 + - [x] imap with starttls on port 143 + - [x] pop3 with starttls on port 110 + * Certificates + - [x] manual certificates + - [x] on the fly creation + - [x] Let's Encrypt + * Spam Filtering + - [x] via rspamd + * Virus Scanning + - [x] via clamav + * DKIM Signing + - [x] via opendkim + * User Management + - [x] declarative user management + - [x] declarative password management + * Sieves + - [x] A simple standard script that moves spam + - [x] Allow user defined sieve scripts + - [x] ManageSieve support + * User Aliases + - [x] Regular aliases + - [x] Catch all aliases ### 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 ### 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) +- Subscribe to the [mailing list](https://www.freelists.org/archive/snm/) +- Join the Freenode IRC channel `#nixos-mailserver` + +### Quick Start + +```nix + { config, pkgs, ... }: + let release = "nixos-20.09"; + in { + imports = [ + (builtins.fetchTarball { + url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/${release}/nixos-mailserver-${release}.tar.gz"; + # This hash needs to be updated + sha256 = "0000000000000000000000000000000000000000000000000000"; + }) + ]; + + mailserver = { + enable = true; + fqdn = "mail.example.com"; + domains = [ "example.com" "example2.com" ]; + loginAccounts = { + "user1@example.com" = { + # nix run nixpkgs.apacheHttpd -c htpasswd -nbB "" "super secret password" | cut -d: -f2 > /hashed/password/file/location + hashedPasswordFile = "/hashed/password/file/location"; + + aliases = [ + "info@example.com" + "postmaster@example.com" + "postmaster@example2.com" + ]; + }; + }; + }; + } +``` + +For a complete list of options, see `default.nix`. + + ## How to Set Up a 10/10 Mail Server Guide +Check out the [Complete Setup Guide](https://nixos-mailserver.readthedocs.io/en/latest/setup-guide.html) in the project's documentation. -Check out the [Setup Guide](https://nixos-mailserver.readthedocs.io/en/latest/setup-guide.html) in the project's documentation. +## How to Backup -For a complete list of options, [see in readthedocs](https://nixos-mailserver.readthedocs.io/en/latest/options.html). +Checkout the [Complete Backup Guide](https://nixos-mailserver.readthedocs.io/en/latest/backup-guide.html). Backups are easy with `SNM`. ## Development -See the [How to Develop SNM](https://nixos-mailserver.readthedocs.io/en/latest/howto-develop.html) documentation page. +See the [How to Develop SNM](https://nixos-mailserver.readthedocs.io/en/latest/howto-develop.html) wiki page. + +## Release notes + +### nixos-20.03 + +- Rspamd is upgraded to 2.0 which deprecates the SQLite Bayes + backend. We then moved to the Redis backend (the default since + Rspamd 2.0). If you don't want to relearn the Redis backend from the + scratch, we could manually run + + rspamadm statconvert --spam-db /var/lib/rspamd/bayes.spam.sqlite --ham-db /var/lib/rspamd/bayes.ham.sqlite -h 127.0.0.1:6379 --symbol-ham BAYES_HAM --symbol-spam BAYES_SPAM + + See the [Rspamd migration + notes](https://rspamd.com/doc/migration.html#migration-to-rspamd-20) + and [this SNM Merge + Request](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/164) + for details. ## Contributors - See the [contributor tab](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/graphs/master) ### Alternative Implementations - -* [NixCloud Webservices](https://github.com/nixcloud/nixcloud-webservices) + * [NixCloud Webservices](https://github.com/nixcloud/nixcloud-webservices) ### Credits - -* send mail graphic by [tnp_dreamingmao](https://thenounproject.com/dreamingmao) + * send mail graphic by [tnp_dreamingmao](https://thenounproject.com/dreamingmao) from [TheNounProject](https://thenounproject.com/) is licensed under [CC BY 3.0](http://creativecommons.org/~/3.0/) -* Logo made with [Logomakr.com](https://logomakr.com) + * Logo made with [Logomakr.com](https://logomakr.com) -[logo]: docs/logo.png + + + +[logo]: logo/logo.png diff --git a/default.nix b/default.nix index 3f31420..8905c5a 100644 --- a/default.nix +++ b/default.nix @@ -14,49 +14,17 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - lib, - pkgs, - ... -}: +{ config, lib, pkgs, ... }: + +with lib; let - inherit (lib) - literalExpression - literalMD - mkDefault - mkEnableOption - mkOption - mkOptionType - mkRemovedOptionModule - mkRenamedOptionModule - types - warn - ; - cfg = config.mailserver; in { options.mailserver = { enable = mkEnableOption "nixos-mailserver"; - stateVersion = mkOption { - type = types.nullOr types.ints.positive; - default = null; - description = '' - Tracking stateful version changes as an incrementing number. - - When a new release comes out we may require manual migration steps to - be completed, before the new version can be put into production. - - If your `stateVersion` is too low one or multiple assertions may - trigger to give you instructions on what migrations steps are required - to continue. Increase the `stateVersion` as instructed by the assertion - message. - ''; - }; - openFirewall = mkOption { type = types.bool; default = true; @@ -72,24 +40,10 @@ in domains = mkOption { type = types.listOf types.str; example = [ "example.com" ]; - default = [ ]; + default = []; description = "The domains that this mail server serves."; }; - certificateDomains = mkOption { - type = types.listOf types.str; - example = [ - "imap.example.com" - "pop3.example.com" - ]; - default = [ ]; - 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; @@ -98,141 +52,121 @@ in }; loginAccounts = mkOption { - type = types.attrsOf ( - types.submodule ( - { name, ... }: - { - options = { - name = mkOption { - type = types.str; - example = "user1@example.com"; - description = "Username"; - }; + type = types.attrsOf (types.submodule ({ name, ... }: { + options = { + name = mkOption { + type = types.str; + example = "user1@example.com"; + description = "Username"; + }; - hashedPassword = mkOption { - type = with types; nullOr str; - default = null; - example = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/"; - description = '' - The user's hashed password. Use `mkpasswd` as follows + hashedPassword = mkOption { + type = with types; nullOr str; + default = null; + example = "$6$evQJs5CFQyPAW09S$Cn99Y8.QjZ2IBnSu4qf1vBxDRWkaIZWOtmu1Ddsm3.H3CFpeVc0JU4llIq8HQXgeatvYhh5O33eWG3TSpjzu6/"; + description = '' + The user's hashed password. Use `htpasswd` as follows - ``` - nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' - ``` + ``` + nix run nixpkgs.apacheHttpd -c htpasswd -nbB "" "super secret password" | cut -d: -f2 + ``` - 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 `htpasswd` as follows - ``` - nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' - ``` - ''; - }; + ``` + nix run nixpkgs.apacheHttpd -c htpasswd -nbB "" "super secret password" | cut -d: -f2 + ``` + ''; + }; - 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/"; @@ -243,169 +177,14 @@ in }; description = '' The login account of the domain. Every account is mapped to a unix user, - e.g. `user1@example.com`. To generate the passwords use `mkpasswd` as + e.g. `user1@example.com`. To generate the passwords use `htpasswd` as follows ``` - nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' + nix run nixpkgs.apacheHttpd -c htpasswd -nbB "" "super secret password" | cut -d: -f2 ``` ''; - 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/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/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/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/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. - ''; - }; - }; + default = {}; }; indexDir = mkOption { @@ -414,7 +193,7 @@ in 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 + especially when the fullTextSearch option is enable since indices it creates are voluminous and do not need to be backed up. @@ -426,25 +205,11 @@ in https://doc.dovecot.org/configuration_manual/mail_location/#variables for details. ''; - example = "/var/lib/dovecot/indices"; + example = "/var/lib/docecot/indices/%d/%n"; }; fullTextSearch = { - enable = mkEnableOption '' - Full text search indexing with Xapian through the fts_flatcurve plugin. - This has significant performance and disk space cost. - ''; - memoryLimit = mkOption { - type = types.nullOr types.int; - default = null; - example = 2000; - description = '' - Memory limit for the indexer process, in MiB. - If null, leaves the default (which is rather low), - and if 0, no limit. - ''; - }; - + enable = mkEnableOption "Full text search indexing with xapian. This has significant performance and disk space cost."; autoIndex = mkOption { type = types.bool; default = true; @@ -453,90 +218,69 @@ in autoIndexExclude = mkOption { type = types.listOf types.str; default = [ ]; - example = [ - "\\Trash" - "SomeFolder" - "Other/*" - ]; + example = [ "\\Trash" "SomeFolder" "Other/*" ]; description = '' Mailboxes to exclude from automatic indexing. ''; }; + indexAttachments = mkOption { + type = types.bool; + default = false; + description = "Also index text-only attachements. Binary attachements are never indexed."; + }; + enforced = mkOption { - type = types.enum [ - "yes" - "no" - "body" - ]; + type = types.enum [ "yes" "no" "body" ]; default = "no"; description = '' Fail searches when no index is available. If set to - `body`, then only body searches (as opposed to - header) are affected. If set to `no`, searches may + 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 . - ''; + minSize = mkOption { + type = types.int; + default = 2; + description = "Size of the smallest n-gram to index."; + }; + maxSize = mkOption { + type = types.int; + default = 20; + description = "Size of the largest n-gram to index."; + }; + memoryLimit = mkOption { + type = types.nullOr types.int; + default = null; + example = 2000; + description = "Memory limit for the indexer process, in MiB. If null, leaves the default (which is rather low), and if 0, no limit."; }; - substringSearch = mkOption { - type = types.bool; - default = false; - description = '' - If enabled, allows substring searches. - See . + maintenance = { + enable = mkOption { + type = types.bool; + default = true; + description = "Regularly optmize indices, as recommended by upstream."; + }; - Enabling this requires significant additional storage space. - ''; - }; + onCalendar = mkOption { + type = types.str; + default = "daily"; + description = "When to run the maintenance job. See systemd.time(7) for more information about the format."; + }; - headerExcludes = mkOption { - type = types.listOf types.str; - default = [ - "Received" - "DKIM-*" - "X-*" - "Comments" - ]; - description = '' - The list of headers to exclude. - See . - ''; - }; - - filters = mkOption { - type = types.listOf types.str; - default = [ - "normalizer-icu" - "snowball" - "stopwords" - ]; - description = '' - The list of filters to apply. - . - ''; + randomizedDelaySec = mkOption { + type = types.int; + default = 1000; + description = "Run the maintenance job not exactly at the time specified with onCalendar, but plus or minus this many seconds."; + }; }; }; lmtpSaveToDetailMailbox = mkOption { - type = types.enum [ - "yes" - "no" - ]; + type = types.enum ["yes" "no"]; default = "yes"; description = '' If an email address is delimited by a "+", should it be filed into a @@ -545,39 +289,18 @@ in ''; }; - lmtpMemoryLimit = mkOption { - type = types.int; - default = 256; - description = '' - The memory limit for the LMTP service, in megabytes. - ''; - }; - - quotaStatusMemoryLimit = mkOption { - type = types.int; - default = 256; - description = '' - The memory limit for the quota-status service, in megabytes. - ''; - }; - extraVirtualAliases = mkOption { - type = - let - loginAccount = mkOptionType { - name = "Login Account"; - }; - in - with types; - attrsOf (either loginAccount (nonEmptyListOf loginAccount)); + type = let + loginAccount = mkOptionType { + name = "Login Account"; + check = (account: builtins.elem account (builtins.attrNames cfg.loginAccounts)); + }; + in with types; attrsOf (either loginAccount (nonEmptyListOf loginAccount)); example = { "info@example.com" = "user1@example.com"; "postmaster@example.com" = "user1@example.com"; "abuse@example.com" = "user1@example.com"; - "multi@example.com" = [ - "user1@example.com" - "user2@example.com" - ]; + "multi@example.com" = [ "user1@example.com" "user2@example.com" ]; }; description = '' Virtual Aliases. A virtual alias `"info@example.com" = "user1@example.com"` means that @@ -590,11 +313,12 @@ in example all mails for `multi@example.com` will be forwarded to both `user1@example.com` and `user2@example.com`. ''; - default = { }; + default = {}; }; forwards = mkOption { type = with types; attrsOf (either (listOf str) str); + default = {}; example = { "user@example.com" = "user@elsewhere.com"; }; @@ -603,38 +327,32 @@ in 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` + `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 { @@ -643,7 +361,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. ''; }; @@ -682,15 +400,7 @@ in - /var/vmail/example.com/user/.folder.subfolder/ (default layout) - /var/vmail/example.com/user/folder/subfolder/ (FS layout) - See https://doc.dovecot.org/main/core/config/mailbox_formats/maildir.html#maildir-mailbox-format for details. - ''; - }; - - useUTF8FolderNames = mkOption { - type = types.bool; - default = false; - description = '' - Store mailbox names on disk using UTF-8 instead of modified UTF-7 (mUTF-7). + See https://wiki2.dovecot.org/MailboxFormat/Maildir for details. ''; }; @@ -703,7 +413,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. ''; }; @@ -712,74 +422,51 @@ in The mailboxes for dovecot. Depending on the mail client used it might be necessary to change some mailbox's name. ''; - default = { - Trash = { - auto = "no"; - specialUse = "Trash"; + default = let + defMailBoxes = { + Trash = { + auto = "no"; + specialUse = "Trash"; + }; + Junk = { + auto = "subscribe"; + specialUse = "Junk"; + }; + Drafts = { + auto = "subscribe"; + specialUse = "Drafts"; + }; + Sent = { + auto = "subscribe"; + specialUse = "Sent"; + }; }; - Junk = { - auto = "subscribe"; - specialUse = "Junk"; - }; - Drafts = { - auto = "subscribe"; - specialUse = "Drafts"; - }; - Sent = { - auto = "subscribe"; - specialUse = "Sent"; - }; - }; + in if (versionAtLeast version "20.09pre") then defMailBoxes + else (flip mapAttrsToList defMailBoxes (name: options: { inherit name; } // options)); }; - 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 ''; }; @@ -787,9 +474,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 ''; }; @@ -797,27 +483,13 @@ 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 + Scheme 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; - example = "example.com"; - description = '' - ({option}`mailserver.certificateScheme` == `acme`) - - When the `acme` `certificateScheme` is selected, you can use this option - to override the default certificate name. This is useful if you've - generated a wildcard certificate, for example. - ''; - }; - enableImap = mkOption { type = types.bool; default = true; @@ -826,14 +498,6 @@ in ''; }; - imapMemoryLimit = mkOption { - type = types.int; - default = 256; - description = '' - The memory limit for the imap service, in megabytes. - ''; - }; - enableImapSsl = mkOption { type = types.bool; default = true; @@ -915,7 +579,7 @@ in type = types.str; default = "mail"; description = '' - The DKIM selector. + ''; }; @@ -923,106 +587,21 @@ 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 = 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 ({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. - ''; - }; - - dmarcReporting = { - enable = mkOption { - type = types.bool; - default = false; + type = types.int; + default = 1024; 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. + How many bits in generated DKIM keys. RFC6376 advises minimum 1024-bit keys. - 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 + 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. ''; - }; - - localpart = mkOption { - type = types.str; - default = "dmarc-noreply"; - example = "dmarc-report"; - description = '' - The local part of the email address used for outgoing DMARC reports. - ''; - }; - - domain = mkOption { - type = types.enum cfg.domains; - example = "example.com"; - description = '' - The domain from which outgoing DMARC reports are served. - ''; - }; - - email = mkOption { - type = types.str; - default = with cfg.dmarcReporting; "${localpart}@${domain}"; - defaultText = literalExpression ''"''${localpart}@''${domain}"''; - readOnly = true; - description = '' - The email address used for outgoing DMARC reports. Read-only. - ''; - }; - - organizationName = mkOption { - type = types.str; - example = "ACME Corp."; - description = '' - The name of your organization used in the `org_name` attribute in - DMARC reports. - ''; - }; - - fromName = mkOption { - type = types.str; - default = cfg.dmarcReporting.organizationName; - defaultText = literalMD "{option}`mailserver.dmarcReporting.organizationName`"; - description = '' - The sender name for DMARC reports. Defaults to the organization name. - ''; - }; - - excludeDomains = mkOption { - type = types.listOf types.str; - default = [ ]; - description = '' - List of domains or eSLDs to be excluded from DMARC reports. - ''; - }; }; debug = mkOption { @@ -1062,39 +641,41 @@ in }; redis = { - configureLocally = mkOption { - type = types.bool; - default = true; - description = '' - Whether to provision a local Redis instance. - ''; - }; - address = mkOption { type = types.str; # read the default from nixos' redis module - default = config.services.redis.servers.rspamd.unixSocket; - defaultText = literalExpression "config.services.redis.servers.rspamd.unixSocket"; + default = let + cf = config.services.redis.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. ''; }; }; @@ -1112,7 +693,7 @@ in sendingFqdn = mkOption { type = types.str; default = cfg.fqdn; - defaultText = literalMD "{option}`mailserver.fqdn`"; + defaultText = "config.mailserver.fqdn"; example = "myserver.example.com"; description = '' The fully qualified domain name of the mail server used to @@ -1128,7 +709,7 @@ in 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 + `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 @@ -1136,6 +717,18 @@ in ''; }; + policydSPFExtraConfig = mkOption { + type = types.lines; + default = ""; + example = '' + skip_addresses = 127.0.0.0/8,::ffff:127.0.0.0/104,::1 + ''; + description = '' + Extra configuration options for policyd-spf. This can be use to among + other things skip spf checking for some IP addresses. + ''; + }; + monitoring = { enable = mkEnableOption "monitoring via monit"; @@ -1184,11 +777,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 ...'. @@ -1205,8 +797,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. ''; }; @@ -1230,15 +821,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."; }; @@ -1274,14 +857,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."; }; }; @@ -1296,14 +878,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."; }; @@ -1318,10 +899,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" ''; }; @@ -1331,12 +911,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"; @@ -1383,9 +984,9 @@ in cronIntervals = mkOption { type = types.attrsOf types.str; default = { - # minute, hour, day-in-month, month, weekday (0 = sunday) + # minute, hour, day-in-month, month, weekday (0 = sunday) hourly = " 0 * * * *"; # Every full hour - daily = "30 3 * * *"; # Every day at 3:30 + daily = "30 3 * * *"; # Every day at 3:30 weekly = " 0 5 * * 0"; # Every sunday at 5:00 AM }; description = '' @@ -1398,33 +999,8 @@ in }; imports = [ - (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "enable" ] '' - This option is not needed for fts-flatcurve - '') - (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "onCalendar" ] '' - This option is not needed for fts-flatcurve - '') - (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maintenance" "randomizedDelaySec" ] '' - This option is not needed for fts-flatcurve - '') - (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "minSize" ] '' - This option is not supported by fts-flatcurve - '') - (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "maxSize" ] '' - This option is not needed since fts-xapian 1.8.3 - '') - (mkRemovedOptionModule [ "mailserver" "fullTextSearch" "indexAttachments" ] '' - Text attachments are always indexed since fts-xapian 1.4.8 - '') - (mkRenamedOptionModule - [ "mailserver" "rebootAfterKernelUpgrade" "enable" ] - [ "system" "autoUpgrade" "allowReboot" ] - ) - (mkRemovedOptionModule [ "mailserver" "rebootAfterKernelUpgrade" "method" ] '' - Use `system.autoUpgrade` instead. - '') - ./mail-server/assertions.nix ./mail-server/borgbackup.nix + ./mail-server/debug.nix ./mail-server/rsnapshot.nix ./mail-server/clamav.nix ./mail-server/monit.nix @@ -1433,22 +1009,11 @@ in ./mail-server/networking.nix ./mail-server/systemd.nix ./mail-server/dovecot.nix + ./mail-server/opendkim.nix ./mail-server/postfix.nix ./mail-server/rspamd.nix ./mail-server/nginx.nix ./mail-server/kresd.nix - (mkRemovedOptionModule [ "mailserver" "policydSPFExtraConfig" ] '' - SPF checking has been migrated to Rspamd, which makes this config redundant. Please look into the rspamd config to migrate your settings. - It may be that they are redundant and are already configured in rspamd like for skip_addresses. - '') - (mkRemovedOptionModule [ "mailserver" "dkimHeaderCanonicalization" ] '' - DKIM signing has been migrated to Rspamd, which always uses relaxed canonicalization. - '') - (mkRemovedOptionModule [ "mailserver" "dkimBodyCanonicalization" ] '' - DKIM signing has been migrated to Rspamd, which always uses relaxed canonicalization. - '') - (mkRemovedOptionModule [ "mailserver" "smtpdForbidBareNewline" ] '' - The workaround for the SMTP Smuggling attack is default enabled in Postfix >3.9. Use `services.postfix.config.smtpd_forbid_bare_newline` if you need to deviate from its default. - '') + ./mail-server/post-upgrade-check.nix ]; } diff --git a/docs/add-radicale.rst b/docs/add-radicale.rst index cf98333..3f29434 100644 --- a/docs/add-radicale.rst +++ b/docs/add-radicale.rst @@ -4,8 +4,8 @@ Add Radicale Configuration by @dotlambda Starting with Radicale 3 (first introduced in NixOS 20.09) the traditional -crypt passwords are no longer supported. Instead bcrypt passwords -have to be used. These can still be generated using `mkpasswd -m bcrypt`. +crypt passwords, as generated by `mkpasswd`, are no longer supported. Instead +bcrypt passwords have to be used which can be generated using `htpasswd`. .. code:: nix @@ -24,13 +24,12 @@ have to be used. These can still be generated using `mkpasswd -m bcrypt`. in { services.radicale = { enable = true; - settings = { - auth = { - type = "htpasswd"; - htpasswd_filename = "${htpasswd}"; - htpasswd_encryption = "bcrypt"; - }; - }; + config = '' + [auth] + type = htpasswd + htpasswd_filename = ${htpasswd} + htpasswd_encryption = bcrypt + ''; }; services.nginx = { diff --git a/docs/add-roundcube.rst b/docs/add-roundcube.rst 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..ef7a848 100644 --- a/docs/backup-guide.rst +++ b/docs/backup-guide.rst @@ -14,13 +14,6 @@ forget to ``chown`` them to ``virtualMail:virtualMail`` if you copy them back (or whatever you specified as ``vmailUserName``, and ``vmailGoupName``). -If you enabled ``enableManageSieve`` then you also may want to backup -``/var/sieve`` or whatever you have specified as ``sieveDirectory``. -The same considerations regarding file ownership apply as for the -Maildir. - -To backup spam and ham training data, backup ``/var/lib/redis-rspamd``. - Finally you can (optionally) make a backup of ``/var/dkim`` (or whatever you specified as ``dkimKeyDirectory``). If you should lose those don’t worry, new ones will be created on the fly. But you will need to repeat diff --git a/docs/conf.py b/docs/conf.py index 7bc771b..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/flakes.rst b/docs/flakes.rst index f56ec96..254a02a 100644 --- a/docs/flakes.rst +++ b/docs/flakes.rst @@ -1,7 +1,7 @@ Nix Flakes ========== -If you're using `flakes `__, you can use +If you're using `flakes `__, you can use the following minimal ``flake.nix`` as an example: .. code:: nix diff --git a/docs/fts.rst b/docs/fts.rst index bb2fe88..aed2cba 100644 --- a/docs/fts.rst +++ b/docs/fts.rst @@ -4,7 +4,7 @@ Full text search By default, when your IMAP client searches for an email containing some text in its *body*, dovecot will read all your email sequentially. This is very slow and IO intensive. To speed body searches up, it is possible to -*index* emails with a plugin to dovecot, ``fts_flatcurve``. +*index* emails with a plugin to dovecot, ``fts_xapian``. Enabling full text search ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -20,6 +20,8 @@ To enable indexing for full text search here is an example configuration. enable = true; # index new email as they arrive autoIndex = true; + # this only applies to plain text attachments, binary attachments are never indexed + indexAttachments = true; enforced = "body"; }; }; @@ -40,7 +42,7 @@ 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 +(``/var/lib/docecot/indices/%d/%n``) by using the option ``mailserver.indexDir``. .. warning:: @@ -59,8 +61,8 @@ Mitigating resources requirements You can: -* exclude some headers from indexation with ``mailserver.fullTextSearch.headerExcludes`` -* disable expensive token normalisation in ``mailserver.fullTextSearch.filters`` +* disable indexation of attachements ``mailserver.fullTextSearch.indexAttachments = false`` +* reduce the size of ngrams to be indexed ``mailserver.fullTextSearch.minSize`` and ``maxSize`` * disable automatic indexation for some folders with ``mailserver.fullTextSearch.autoIndexExclude``. Folders can be specified by name (``"Trash"``), by special use (``"\\Junk"``) or with a wildcard. diff --git a/docs/howto-develop.rst b/docs/howto-develop.rst index dbf3024..57afee3 100644 --- a/docs/howto-develop.rst +++ b/docs/howto-develop.rst @@ -4,104 +4,60 @@ Contribute or troubleshoot To report an issue, please go to ``_. -If you have questions, feel free to reach out: - -* Matrix: `#nixos-mailserver:nixos.org `__ -* IRC: `#nixos-mailserver `__ on `Libera Chat `__ - -All our workflows rely on Nix being configured with `Flakes `__. - -Development Shell ------------------ - -We provide a `flake.nix` devshell that automatically sets up pre-commit hooks, -which allows for fast feedback cycles when making changes to the repository. - - -:: - - $ nix develop - - -We recommend setting up `direnv `__ to automatically -attach to the development environment when entering the project directories. +You can also chat with us on the Freenode IRC channel ``#nixos-mailserver``. Run NixOS tests --------------- -To run the test suite, you need to enable `Nix Flakes -`__. - -You can then run the testsuite via +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 external.nixpkgs_20_03 + $ nix-build tests -A internal.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 >= 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..3ef9d5e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,7 @@ Welcome to NixOS Mailserver's documentation! ============================================ -.. image:: logo.png +.. image:: ../logo/logo.png :width: 400 :alt: SNM Logo @@ -14,12 +14,9 @@ Welcome to NixOS Mailserver's documentation! :maxdepth: 2 setup-guide - advanced-configurations howto-develop faq release-notes - options - migrations .. toctree:: :maxdepth: 1 @@ -27,12 +24,9 @@ Welcome to NixOS Mailserver's documentation! backup-guide add-radicale - add-roundcube 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 daef17e..0000000 --- a/docs/migrations.rst +++ /dev/null @@ -1,45 +0,0 @@ -Migrations -========== - -With mail server configuration best practices changing over time we might need -to make changes that require you to complete manual migration steps before you -can deploy a new version of NixOS mailserver. - -The initial `mailserver.stateVersion` value should be copied from the setup -guide that you used to initially set up your mail server. If in doubt you can -always initialize it at `1` and walk through all assertions, that might apply -to your setup. - -NixOS 25.11 ------------ - -#2 Dovecot LDAP home directory migration -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The Dovecot configuration for LDAP home directories previously did not respect -the ``mailserver.mailDirectory`` setting. - -This means that home directories were unconditionally located at -``/var/vmail/ldap/%{user}``. - -This migration is required if you both: - -* enabled the LDAP integration (``mailserver.ldap.enable``) -* and customized the default mail directory (``mailserver.mailDirectory != "/var/vmail"``) - -For remediating this issue the following steps are required: - -1. Stop ``dovecot2.service``. -2. Move ``/var/vmail/ldap`` below your ``m̀ailserver.mailDirectory``. -3. Update the ``mailserver.stateVersion`` to ``2``. - -#1 Initialization -^^^^^^^^^^^^^^^^^ - -This option was introduced in the NixOS 25.11 release cycle, in which case you -can safely initialize its value at `1`. - -.. code-block:: nix - - mailserver.stateVersion = 1; - diff --git a/docs/release-notes.rst b/docs/release-notes.rst index 7e1429f..561a40c 100644 --- a/docs/release-notes.rst +++ b/docs/release-notes.rst @@ -1,99 +1,6 @@ Release Notes ============= -NixOS 25.05 ------------ - -- OpenDKIM has been removed and DKIM signing is now handled by Rspamd, which only supports ``relaxed`` canoncalizaliaton. - (`merge request `__) -- Rspamd now connects to Redis over its Unix Domain Socket by default - (`merge request `__) - - - If you need to revert TCP connections, configure ``mailserver.redis.address`` to reference the value of ``config.services.redis.servers.rspamd.bind``. -- The integration with policyd-spf was removed and SPF handling is now fully based on Rspamd scoring. - (`merge request `__) -- Switch to the more efficient `fts-flatcurve` indexer for full text search - (`merge request `__). - - This makes use of a new index, which will be automatically re-generated the - next time a folder is searched. - The operation is now quick enough to be performed "just-in-time". - Alternatively, all indices can be immediately re-generated for all users and - folders by running - - .. code-block:: bash - - doveadm fts rescan -u '*' && doveadm index -u '*' -q '*' - - The previous index (which is not automatically discarded to allow rollbacks) - can be cleaned up by removing all the `xapian-indexes` directories within - ``mailserver.indexDir``. -- Individual domains can now be excluded from DMARC Reporting through ``mailserver.dmarcReporting.excludedDomains``. - (`merge request `__) -- Configuring ``mailserver.forwards`` is now possible when the setup relies on LDAP. - (`merge request `__) -- Support for TLS 1.1 was disabled in accordance with `Mozilla's recommendations `_. - (`merge request `__) - -NixOS 24.11 ------------ - -- No new feature, only bug fixes and documentation improvements - -NixOS 24.05 ------------ - -- Add new option ``acmeCertificateName`` which can be used to support - wildcard certificates - -NixOS 23.11 ------------ - -- Add basic support for LDAP users -- Add support for regex (PCRE) aliases - -NixOS 23.05 ------------ - -- Existing ACME certificates can be reused without configuring NGINX -- Certificate scheme is no longer a number, but a meaningful string instead - -NixOS 22.11 ------------ - -- Allow Rspamd to send DMARC reporting - (`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 ----------- 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..049858d 100644 --- a/docs/rspamd-tuning.rst +++ b/docs/rspamd-tuning.rst @@ -24,14 +24,17 @@ You can run the training in a root shell as follows: .. code:: bash + # Path to the controller socket + export RSOCK="/var/run/rspamd/worker-controller.sock" + # Learn the Junk folder as spam - rspamc learn_spam /var/vmail/$DOMAIN/$USER/.Junk/cur/ + rspamc -h $RSOCK learn_spam /var/vmail/$DOMAIN/$USER/.Junk/cur/ # Learn the INBOX as ham - rspamc learn_ham /var/vmail/$DOMAIN/$USER/cur/ + rspamc -h $RSOCK learn_ham /var/vmail/$DOMAIN/$USER/cur/ # Check that training was successful - rspamc stat | grep learned + rspamc -h $RSOCK stat | grep learned Tune symbol weight ~~~~~~~~~~~~~~~~~~ diff --git a/docs/setup-guide.rst b/docs/setup-guide.rst index 6c07a1e..c2c3ca0 100644 --- a/docs/setup-guide.rst +++ b/docs/setup-guide.rst @@ -20,30 +20,25 @@ an up and running mail server. Once the server is deployed, we could then set all DNS entries required to send and receive mails on this server. -Setup DNS A/AAAA records for server -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Setup DNS A record for server +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Add DNS records to the domain ``example.com`` with the following +Add a DNS record to the domain ``example.com`` with the following entries ==================== ===== ==== ============= Name (Subdomain) TTL Type Value ==================== ===== ==== ============= ``mail.example.com`` 10800 A ``1.2.3.4`` -``mail.example.com`` 10800 AAAA ``2001::1`` ==================== ===== ==== ============= -If your server does not have an IPv6 address, you must skip the `AAAA` record. - You can check this with :: - $ nix-shell -p bind --command "host -t A mail.example.com" - mail.example.com has address 1.2.3.4 - - $ nix-shell -p bind --command "host -t AAAA mail.example.com" - mail.example.com has address 2001::1 + $ ping mail.example.com + 64 bytes from mail.example.com (1.2.3.4): icmp_seq=1 ttl=46 time=21.3 ms + ... Note that it can take a while until a DNS entry is propagated. This DNS entry is required for the Let's Encrypt certificate generation @@ -53,45 +48,41 @@ Setup the server ~~~~~~~~~~~~~~~~ The following describes a server setup that is fairly complete. Even -though there are more possible options (see the `NixOS Mailserver -options documentation `_), these should be the most -common ones. +though there are more possible options (see the ``default.nix`` file), +these should be the most common ones. .. code:: nix - { config, pkgs, ... }: { + { config, pkgs, ... }: + { imports = [ (builtins.fetchTarball { - # Pick a release version you are interested in and set its hash, e.g. - url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/nixos-25.05/nixos-mailserver-nixos-25.05.tar.gz"; - # To get the sha256 of the nixos-mailserver tarball, we can use the nix-prefetch-url command: - # release="nixos-25.05"; nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/${release}/nixos-mailserver-${release}.tar.gz" --unpack + # Pick a commit from the branch you are interested in + url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/A-COMMIT-ID/nixos-mailserver-A-COMMIT-ID.tar.gz"; + # And set its hash sha256 = "0000000000000000000000000000000000000000000000000000"; }) ]; mailserver = { enable = true; - stateVersion = 2; fqdn = "mail.example.com"; domains = [ "example.com" ]; # A list of all login accounts. To create the password hashes, use - # nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt' + # nix run nixpkgs.apacheHttpd -c htpasswd -nbB "" "super secret password" | cut -d: -f2 loginAccounts = { - "user1@example.com" = { - hashedPasswordFile = "/a/file/containing/a/hashed/password"; - aliases = ["postmaster@example.com"]; - }; - "user2@example.com" = { ... }; + "user1@example.com" = { + hashedPasswordFile = "/a/file/containing/a/hashed/password"; + aliases = ["postmaster@example.com"]; + }; + "user2@example.com" = { ... }; }; # Use Let's Encrypt certificates. Note that this needs to set up a stripped # down nginx and opens port 80. - certificateScheme = "acme-nginx"; + certificateScheme = 3; }; - security.acme.acceptTerms = true; - security.acme.defaults.email = "security@example.com"; } After a ``nixos-rebuild switch`` your server should be running all @@ -104,18 +95,8 @@ Set rDNS (reverse DNS) entry for server ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Wherever you have rented your server, you should be able to set reverse -DNS entries for the IP’s you own: - -- Add an entry resolving IPv4 address ``1.2.3.4`` to ``mail.example.com``. -- Add an entry resolving IPv6 ``2001::1`` to ``mail.example.com``. Again, this - must be skipped if your server does not have an IPv6 address. - -.. 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. +DNS entries for the IP’s you own. Add an entry resolving ``1.2.3.4`` +to ``mail.example.com`` You can check this with @@ -124,9 +105,6 @@ You can check this with $ nix-shell -p bind --command "host 1.2.3.4" 4.3.2.1.in-addr.arpa domain name pointer mail.example.com. - $ nix-shell -p bind --command "host 2001::1" - 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.0.2.ip6.arpa domain name pointer mail.example.com. - Note that it can take a while until a DNS entry is propagated. Set a ``MX`` record @@ -153,7 +131,7 @@ Note that it can take a while until a DNS entry is propagated. Set a ``SPF`` record ^^^^^^^^^^^^^^^^^^^^ -Add a `SPF `_ +Add a `SPF `_ record to the domain ``example.com``. ================ ===== ==== ================================ @@ -174,26 +152,25 @@ Note that it can take a while until a DNS entry is propagated. Set ``DKIM`` signature ^^^^^^^^^^^^^^^^^^^^^^ -On your server, the ``rspamd`` systemd service generated a file +On your server, the ``opendkim`` systemd service generated a file containing your DKIM public key in the file ``/var/dkim/example.com.mail.txt``. The content of this file looks like :: - mail._domainkey IN TXT ( "v=DKIM1; k=rsa; " - "p=" ) ; ----- DKIM key mail for nixos.org + mail._domainkey IN TXT "v=DKIM1; k=rsa; s=email; p=" ; ----- DKIM mail for domain.tld where ``really-long-key`` is your public key. Based on the content of this file, we can add a ``DKIM`` record to the domain ``example.com``. -=========================== ===== ==== ================================================ +=========================== ===== ==== ============================== Name (Subdomain) TTL Type Value -=========================== ===== ==== ================================================ -mail._domainkey.example.com 10800 TXT ``v=DKIM1; k=rsa; s=email; p=`` -=========================== ===== ==== ================================================ +=========================== ===== ==== ============================== +mail._domainkey.example.com 10800 TXT ``v=DKIM1; p=`` +=========================== ===== ==== ============================== You can check this with @@ -238,8 +215,3 @@ Besides that, you can send an email to score, and let `mxtoolbox.com `__ take a look at your setup, but if you followed the steps closely then everything should be awesome! - -Next steps (optional) -~~~~~~~~~~~~~~~~~~~~~ - -Take a look through our `Advanced Configurations `_. diff --git a/flake.lock b/flake.lock index c7979e2..a9abdbc 100644 --- a/flake.lock +++ b/flake.lock @@ -1,121 +1,39 @@ { "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": 1749636823, - "narHash": "sha256-WUaIlOlPLyPgz9be7fqWJA5iG6rHcGRtLERSCfUDne4=", - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "623c56286de5a3193aa38891a6991b28f9bab056", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, - "gitignore": { - "inputs": { - "nixpkgs": [ - "git-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1709087332, - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1749285348, - "narHash": "sha256-frdhQvPbmDYaScPFiCnfdh3B/Vh81Uuoo0w5TkWmmjU=", + "lastModified": 1607522989, + "narHash": "sha256-o/jWhOSAlaK7y2M57OIriRt6whuVVocS/T0mG7fd1TI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3e3afe5174c561dee0df6f2c2b2236990146329f", + "rev": "e9158eca70ae59e73fae23be5d13d3fa0cfc78b4", "type": "github" }, "original": { - "owner": "NixOS", + "id": "nixpkgs", "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs-25_05": { - "locked": { - "lastModified": 1749727998, - "narHash": "sha256-mHv/yeUbmL91/TvV95p+mBVahm9mdQMJoqaTVTALaFw=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "fd487183437963a59ba763c0cc4f27e3447dd6dd", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-25.05", - "repo": "nixpkgs", - "type": "github" + "type": "indirect" } }, "root": { "inputs": { - "blobs": "blobs", - "flake-compat": "flake-compat", - "git-hooks": "git-hooks", "nixpkgs": "nixpkgs", - "nixpkgs-25_05": "nixpkgs-25_05" + "utils": "utils" + } + }, + "utils": { + "locked": { + "lastModified": 1605370193, + "narHash": "sha256-YyMTf3URDL/otKdKgtoMChu4vfVL3vCMkRqpGifhUn0=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "5021eac20303a61fafe17224c087f5519baed54d", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index 18757c5..3d8cfbe 100644 --- a/flake.nix +++ b/flake.nix @@ -2,223 +2,25 @@ 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; - }; + utils.url = "github:numtide/flake-utils"; + nixpkgs.url = "flake:nixpkgs/nixos-unstable"; }; - 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}; - } + outputs = { self, utils, nixpkgs }: { + nixosModules.mailserver = import ./.; + nixosModule = self.nixosModules.mailserver; + } // utils.lib.eachDefaultSystem (system: let + pkgs = nixpkgs.legacyPackages.${system}; + in { + devShell = pkgs.mkShell { + buildInputs = with pkgs; [ + (python3.withPackages (p: with p; [ + sphinx + sphinx_rtd_theme + ])) + jq + clamav ]; - testNames = [ - "clamav" - "external" - "internal" - "ldap" - "multiple" - ]; - - genTest = - testName: release: - let - pkgs = release.pkgs; - nixos-lib = import (release.nixpkgs + "/nixos/lib") { - inherit (pkgs) lib; - }; - in - { - name = "${testName}-${builtins.replaceStrings [ "." ] [ "_" ] release.name}"; - value = nixos-lib.runTest { - hostPkgs = pkgs; - imports = [ ./tests/${testName}.nix ]; - _module.args = { inherit blobs; }; - extraBaseModules.imports = [ ./default.nix ]; - }; - }; - - # Generate an attribute set such as - # { - # external-unstable = ; - # external-21_05 = ; - # ... - # } - allTests = lib.listToAttrs (lib.flatten (map (t: map (r: genTest t r) releases) testNames)); - - mailserverModule = import ./.; - - # Generate a MarkDown file describing the options of the NixOS mailserver module - optionsDoc = - let - eval = lib.evalModules { - modules = [ - mailserverModule - { - _module.check = false; - mailserver = { - fqdn = "mx.example.com"; - domains = [ - "example.com" - ]; - dmarcReporting = { - organizationName = "Example Corp"; - domain = "example.com"; - }; - }; - } - ]; - }; - options = builtins.toFile "options.json" ( - builtins.toJSON ( - lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver") ( - lib.optionAttrSetToDocList eval.options - ) - ) - ); - in - pkgs.runCommand "options.md" { buildInputs = [ pkgs.python3Minimal ]; } '' - echo "Generating options.md from ${options}" - python ${./scripts/generate-options.py} ${options} > $out - echo $out - ''; - - documentation = pkgs.stdenv.mkDerivation { - name = "documentation"; - src = lib.sourceByRegex ./docs [ - "logo\\.png" - "conf\\.py" - "Makefile" - ".*\\.rst" - ]; - buildInputs = [ - (pkgs.python3.withPackages ( - p: with p; [ - sphinx - sphinx_rtd_theme - myst-parser - linkify-it-py - ] - )) - ]; - buildPhase = '' - cp ${optionsDoc} options.md - # Workaround for https://github.com/sphinx-doc/sphinx/issues/3451 - unset SOURCE_DATE_EPOCH - make html - ''; - installPhase = '' - cp -Tr _build/html $out - ''; - }; - - 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 1c7d3f9..0000000 --- a/mail-server/assertions.nix +++ /dev/null @@ -1,48 +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 >= 2; - message = '' - Issue: The dovecot homedir for LDAP users was previously not respecting `mailserver.mailDirectory`. - Remediation: - - Stop the `dovecot2.service` - - Move `/var/vmail/ldap` below your `mailserver.mailDirectory` - - Increase the `stateVersion` to 2. - - Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-home-directory-migration for more information. - ''; - } - ] - ++ lib.optionals (config.mailserver.certificateScheme != "acme") [ - { - assertion = config.mailserver.acmeCertificateName == config.mailserver.fqdn; - message = "When the certificate scheme is not 'acme' (mailserver.certificateScheme != \"acme\"), it is not possible to define mailserver.acmeCertificateName"; - } - ] - ); -} 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..a73d4e5 100644 --- a/mail-server/clamav.nix +++ b/mail-server/clamav.nix @@ -14,17 +14,29 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ config, lib, ... }: +{ config, pkgs, lib, options, ... }: let cfg = config.mailserver; + clamHasSettings = options.services.clamav.daemon ? settings; in +with lib; { config = lib.mkIf (cfg.enable && cfg.virusScanning) { + + # Remove extraConfig and settings conditional after 20.09 support is removed + services.clamav.daemon = { enable = true; + } // (if clamHasSettings then { settings.PhishingScanURLs = "no"; - }; + } else { + extraConfig = '' + PhishingScanURLs no + ''; + }); + services.clamav.updater.enable = true; }; } + diff --git a/mail-server/common.nix b/mail-server/common.nix index cb044b6..2a264a7 100644 --- a/mail-server/common.nix +++ b/mail-server/common.nix @@ -14,76 +14,35 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, -}: +{ config, pkgs, lib }: let cfg = config.mailserver; in { # cert :: PATH - certificatePath = - if cfg.certificateScheme == "manual" then - cfg.certificateFile - else if cfg.certificateScheme == "selfsigned" then - "${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem" - else if cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx" then - "${config.security.acme.certs.${cfg.acmeCertificateName}.directory}/fullchain.pem" - else - throw "unknown certificate scheme"; + certificatePath = if cfg.certificateScheme == 1 + then cfg.certificateFile + else if cfg.certificateScheme == 2 + then "${cfg.certificateDirectory}/cert-${cfg.fqdn}.pem" + else if cfg.certificateScheme == 3 + then "${config.security.acme.certs.${cfg.fqdn}.directory}/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} - ''; + 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 "${config.security.acme.certs.${cfg.fqdn}.directory}/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/debug.nix b/mail-server/debug.nix new file mode 100644 index 0000000..8107515 --- /dev/null +++ b/mail-server/debug.nix @@ -0,0 +1,4 @@ +{ config, lib, ... }: +{ + mailserver.policydSPFExtraConfig = lib.mkIf config.mailserver.debug "debugLevel = 4"; +} diff --git a/mail-server/dovecot.nix b/mail-server/dovecot.nix index d2da51b..3781bbd 100644 --- a/mail-server/dovecot.nix +++ b/mail-server/dovecot.nix @@ -14,12 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: +{ config, pkgs, lib, ... }: with (import ./common.nix { inherit config pkgs lib; }); @@ -28,65 +23,40 @@ let 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 - ); + + bool2int = x: if x then "1" else "0"; maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs"; - maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8"; # maildir in format "/${domain}/${user}" dovecotMaildir = - "maildir:${cfg.mailDirectory}/%{domain}/%{username}${maildirLayoutAppendix}${maildirUTF8FolderNames}" - + (lib.optionalString (cfg.indexDir != null) ":INDEX=${cfg.indexDir}/%{domain}/%{username}"); + "maildir:${cfg.mailDirectory}/%d/%n${maildirLayoutAppendix}" + + (lib.optionalString (cfg.indexDir != null) + ":INDEX=${cfg.indexDir}/%d/%n" + ); postfixCfg = config.services.postfix; + dovecot2Cfg = config.services.dovecot2; - 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} @@ -97,12 +67,7 @@ let chmod 755 "${passwdDir}" fi - # Prevent world-readable password files, even temporarily. - umask 077 - - for f in ${ - builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.loginAccounts) - }; do + for f in ${builtins.toString (lib.mapAttrsToList (name: value: passwordFiles."${name}") cfg.loginAccounts)}; do if [ ! -f "$f" ]; then echo "Expected password hash file $f does not exist!" exit 1 @@ -110,159 +75,51 @@ let done cat < ${passwdFile} - ${lib.concatStringsSep "\n" ( - lib.mapAttrsToList ( - name: _: "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::" - ) cfg.loginAccounts - )} + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: + "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}:${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 || enableImapSsl; + enablePop3 = enablePop3 || enablePop3Ssl; enablePAM = false; enableQuota = true; - mailGroup = cfg.vmailGroupName; - mailUser = cfg.vmailUserName; + mailGroup = vmailGroupName; + mailUser = vmailUserName; mailLocation = dovecotMaildir; sslServerCert = certificatePath; sslServerKey = keyPath; - enableDHE = lib.mkDefault false; enableLmtp = true; - mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [ - "fts" - "fts_flatcurve" - ]; + modules = [ pkgs.dovecot_pigeonhole ] ++ (lib.optional cfg.fullTextSearch.enable pkgs.dovecot_fts_xapian ); + mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [ "fts" "fts_xapian" ]; protocols = lib.optional cfg.enableManageSieve "sieve"; - pluginSettings = { - sieve = "file:${cfg.sieveDirectory}/%{user}/scripts;active=${cfg.sieveDirectory}/%{user}/active.sieve"; - sieve_default = "file:${cfg.sieveDirectory}/%{user}/default.sieve"; - sieve_default_name = "default"; - } // (lib.optionalAttrs cfg.fullTextSearch.enable ftsPluginSettings); - - sieve = { - extensions = [ - "fileinto" - ]; - - scripts.after = builtins.toFile "spam.sieve" '' + sieveScripts = { + after = builtins.toFile "spam.sieve" '' require "fileinto"; if header :is "X-Spam" "Yes" { - 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 '' + ${lib.optionalString debug '' mail_debug = yes auth_debug = yes verbose_ssl = yes @@ -271,62 +128,42 @@ in ${lib.optionalString (cfg.enableImap || cfg.enableImapSsl) '' service imap-login { inet_listener imap { - ${ - if cfg.enableImap then - '' - port = 143 - '' - else - '' - # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html - port = 0 - '' - } + ${if cfg.enableImap then '' + port = 143 + '' else '' + # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html + port = 0 + ''} } inet_listener imaps { - ${ - if cfg.enableImapSsl then - '' - port = 993 - ssl = yes - '' - else - '' - # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html - port = 0 - '' - } + ${if cfg.enableImapSsl then '' + port = 993 + ssl = yes + '' else '' + # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html + port = 0 + ''} } } ''} ${lib.optionalString (cfg.enablePop3 || cfg.enablePop3Ssl) '' service pop3-login { inet_listener pop3 { - ${ - if cfg.enablePop3 then - '' - port = 110 - '' - else - '' - # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html - port = 0 - '' - } + ${if cfg.enablePop3 then '' + port = 110 + '' else '' + # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html + port = 0 + ''} } inet_listener pop3s { - ${ - if cfg.enablePop3Ssl then - '' - port = 995 - ssl = yes - '' - else - '' - # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html - port = 0 - '' - } + ${if cfg.enablePop3Ssl then '' + port = 995 + ssl = yes + '' else '' + # see https://dovecot.org/pipermail/dovecot/2010-March/047479.html + port = 0 + ''} } } ''} @@ -336,21 +173,14 @@ in mail_plugins = $mail_plugins imap_sieve } - service imap { - vsz_limit = ${builtins.toString cfg.imapMemoryLimit} MB - } - protocol pop3 { mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser} } - mail_access_groups = ${cfg.vmailGroupName} - - # https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7 + mail_access_groups = ${vmailGroupName} ssl = required ssl_min_protocol = TLSv1.2 - ssl_prefer_server_ciphers = no - ssl_curve_list = X25519:prime256v1:secp384r1 + ssl_prefer_server_ciphers = yes service lmtp { unix_listener dovecot-lmtp { @@ -358,17 +188,6 @@ in mode = 0600 user = ${postfixCfg.user} } - vsz_limit = ${builtins.toString cfg.lmtpMemoryLimit} MB - } - - service quota-status { - inet_listener { - port = 0 - } - unix_listener quota-status { - user = postfix - } - vsz_limit = ${builtins.toString cfg.quotaStatusMemoryLimit} MB } recipient_delimiter = ${cfg.recipientDelimiter} @@ -385,23 +204,9 @@ in userdb { driver = passwd-file - args = ${userdbFile} - default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory} + 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} - } - ''} - service auth { unix_listener auth { mode = 0660 @@ -417,27 +222,90 @@ in inbox = yes } - service indexer-worker { - ${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) '' - vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit * 1024 * 1024)} - ''} + plugin { + sieve_plugins = sieve_imapsieve sieve_extprograms + sieve = file:${cfg.sieveDirectory}/%u/scripts;active=${cfg.sieveDirectory}/%u/active.sieve + sieve_default = file:${cfg.sieveDirectory}/%u/default.sieve + sieve_default_name = default + + # From elsewhere to Spam folder + imapsieve_mailbox1_name = 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 } + ${lib.optionalString (cfg.fullTextSearch.enable != null) '' + plugin { + plugin = fts fts_xapian + fts = xapian + fts_xapian = partial=${toString cfg.fullTextSearch.minSize} full=${toString cfg.fullTextSearch.maxSize} attachments=${bool2int cfg.fullTextSearch.indexAttachments} verbose=${bool2int cfg.debug} + + fts_autoindex = ${if cfg.fullTextSearch.autoIndex then "yes" else "no"} + + ${lib.strings.concatImapStringsSep "\n" (n: x: "fts_autoindex_exclude${if n==1 then "" else toString n} = ${x}") cfg.fullTextSearch.autoIndexExclude} + + fts_enforced = ${cfg.fullTextSearch.enforced} + } + + ${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) '' + service indexer-worker { + vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit*1024*1024)} + } + ''} + ''} + lda_mailbox_autosubscribe = yes lda_mailbox_autocreate = yes ''; }; systemd.services.dovecot2 = { - preStart = - '' - ${genPasswdScript} - '' - + (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile); + preStart = '' + ${genPasswdScript} + rm -rf '${stateDir}/imap_sieve' + mkdir '${stateDir}/imap_sieve' + cp -p "${./dovecot/imap_sieve}"/*.sieve '${stateDir}/imap_sieve/' + for k in "${stateDir}/imap_sieve"/*.sieve ; do + ${pkgs.dovecot_pigeonhole}/bin/sievec "$k" + done + chown -R '${dovecot2Cfg.mailUser}:${dovecot2Cfg.mailGroup}' '${stateDir}/imap_sieve' + ''; }; - systemd.services.postfix.restartTriggers = [ - genPasswdScript - ] ++ (lib.optional cfg.ldap.enable [ setPwdInLdapConfFile ]); + systemd.services.postfix.restartTriggers = [ genPasswdScript ]; + + systemd.services.dovecot-fts-xapian-optimize = lib.mkIf (cfg.fullTextSearch.enable && cfg.fullTextSearch.maintenance.enable) { + description = "Optimize dovecot indices for fts_xapian"; + requisite = [ "dovecot2.service" ]; + after = [ "dovecot2.service" ]; + startAt = cfg.fullTextSearch.maintenance.onCalendar; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${pkgs.dovecot}/bin/doveadm fts optimize -A"; + PrivateDevices = true; + PrivateNetwork = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectSystem = true; + PrivateTmp = true; + }; + }; + systemd.timers.dovecot-fts-xapian-optimize = lib.mkIf (cfg.fullTextSearch.enable && cfg.fullTextSearch.maintenance.enable && cfg.fullTextSearch.maintenance.randomizedDelaySec != 0) { + timerConfig = { + RandomizedDelaySec = cfg.fullTextSearch.maintenance.randomizedDelaySec; + }; + }; }; } diff --git a/mail-server/dovecot/imap_sieve/report-ham.sieve b/mail-server/dovecot/imap_sieve/report-ham.sieve index 720be7a..a9d30cf 100644 --- a/mail-server/dovecot/imap_sieve/report-ham.sieve +++ b/mail-server/dovecot/imap_sieve/report-ham.sieve @@ -12,4 +12,4 @@ if environment :matches "imap.user" "*" { set "username" "${1}"; } -pipe :copy "rspamd-learn-ham.sh" [ "${username}" ]; +pipe :copy "sa-learn-ham.sh" [ "${username}" ]; diff --git a/mail-server/dovecot/imap_sieve/report-spam.sieve b/mail-server/dovecot/imap_sieve/report-spam.sieve index 4681aac..4024b7a 100644 --- a/mail-server/dovecot/imap_sieve/report-spam.sieve +++ b/mail-server/dovecot/imap_sieve/report-spam.sieve @@ -4,4 +4,4 @@ if environment :matches "imap.user" "*" { set "username" "${1}"; } -pipe :copy "rspamd-learn-spam.sh" [ "${username}" ]; +pipe :copy "sa-learn-spam.sh" [ "${username}" ]; \ No newline at end of file diff --git a/mail-server/dovecot/pipe_bin/sa-learn-ham.sh b/mail-server/dovecot/pipe_bin/sa-learn-ham.sh new file mode 100755 index 0000000..76fc4ed --- /dev/null +++ b/mail-server/dovecot/pipe_bin/sa-learn-ham.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -o errexit +exec rspamc -h /run/rspamd/worker-controller.sock learn_ham \ No newline at end of file diff --git a/mail-server/dovecot/pipe_bin/sa-learn-spam.sh b/mail-server/dovecot/pipe_bin/sa-learn-spam.sh new file mode 100755 index 0000000..2a2f766 --- /dev/null +++ b/mail-server/dovecot/pipe_bin/sa-learn-spam.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -o errexit +exec rspamc -h /run/rspamd/worker-controller.sock learn_spam \ No newline at end of file diff --git a/mail-server/environment.nix b/mail-server/environment.nix index 462cb05..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 f560ec0..e8a222e 100644 --- a/mail-server/networking.nix +++ b/mail-server/networking.nix @@ -14,25 +14,24 @@ # 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 && openFirewall) { networking.firewall = { - allowedTCPPorts = - [ 25 ] - ++ lib.optional cfg.enableSubmission 587 - ++ lib.optional cfg.enableSubmissionSsl 465 - ++ lib.optional cfg.enableImap 143 - ++ lib.optional cfg.enableImapSsl 993 - ++ lib.optional cfg.enablePop3 110 - ++ lib.optional cfg.enablePop3Ssl 995 - ++ lib.optional cfg.enableManageSieve 4190 - ++ lib.optional (cfg.certificateScheme == "acme-nginx") 80; + allowedTCPPorts = [ 25 ] + ++ lib.optional enableSubmission 587 + ++ lib.optional enableSubmissionSsl 465 + ++ lib.optional enableImap 143 + ++ lib.optional enableImapSsl 993 + ++ lib.optional enablePop3 110 + ++ lib.optional enablePop3Ssl 995 + ++ lib.optional enableManageSieve 4190 + ++ lib.optional (certificateScheme == 3) 80; }; }; } diff --git a/mail-server/nginx.nix b/mail-server/nginx.nix index 27de2fe..bdead6c 100644 --- a/mail-server/nginx.nix +++ b/mail-server/nginx.nix @@ -14,35 +14,31 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: -with (import ./common.nix { inherit config lib pkgs; }); +{ config, pkgs, lib, ... }: + +with (import ./common.nix { inherit config; }); let cfg = config.mailserver; + acmeRoot = "/var/lib/acme/acme-challenge"; in { - config = - lib.mkIf (cfg.enable && (cfg.certificateScheme == "acme" || cfg.certificateScheme == "acme-nginx")) - { - services.nginx = lib.mkIf (cfg.certificateScheme == "acme-nginx") { - enable = true; - virtualHosts."${cfg.fqdn}" = { - serverName = cfg.fqdn; - serverAliases = cfg.certificateDomains; - forceSSL = true; - enableACME = true; - }; - }; - - security.acme.certs."${cfg.acmeCertificateName}".reloadServices = [ - "postfix.service" - "dovecot2.service" - ]; + config = lib.mkIf (cfg.enable && cfg.certificateScheme == 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..6fd0bef --- /dev/null +++ b/mail-server/opendkim.nix @@ -0,0 +1,88 @@ +# 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; + keyPath = cfg.dkimKeyDirectory; + 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 e29983a..618d6c5 100644 --- a/mail-server/postfix.nix +++ b/mail-server/postfix.nix @@ -14,12 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: +{ config, pkgs, lib, ... }: with (import ./common.nix { inherit config pkgs lib; }); @@ -30,58 +25,29 @@ let # Merge several lookup tables. A lookup table is a attribute set where # - the key is an address (user@example.com) or a domain (@example.com) # - the value is a list of addresses - mergeLookupTables = tables: lib.zipAttrsWith (_: v: lib.flatten v) tables; + mergeLookupTables = tables: lib.zipAttrsWith (n: v: lib.flatten v) tables; # valiases_postfix :: Map String [String] - valiases_postfix = mergeLookupTables ( - lib.flatten ( - lib.mapAttrsToList ( - name: value: - let - to = name; - in - map (from: { "${from}" = to; }) (value.aliases ++ lib.singleton name) - ) cfg.loginAccounts - ) - ); - regex_valiases_postfix = mergeLookupTables ( - lib.flatten ( - lib.mapAttrsToList ( - name: value: - let - to = name; - in - map (from: { "${from}" = to; }) value.aliasesRegexp - ) cfg.loginAccounts - ) - ); + valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList + (name: value: + let to = name; + in map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name)) + cfg.loginAccounts)); # catchAllPostfix :: Map String [String] - catchAllPostfix = mergeLookupTables ( - lib.flatten ( - lib.mapAttrsToList ( - name: value: - let - to = name; - in - map (from: { "@${from}" = to; }) value.catchAll - ) cfg.loginAccounts - ) - ); + catchAllPostfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList + (name: value: + let to = name; + in map (from: {"@${from}" = to;}) value.catchAll) + cfg.loginAccounts)); # all_valiases_postfix :: Map String [String] - all_valiases_postfix = mergeLookupTables [ - valiases_postfix - extra_valiases_postfix - ]; + all_valiases_postfix = mergeLookupTables [valiases_postfix extra_valiases_postfix]; # attrsToLookupTable :: Map String (Either String [ String ]) -> Map String [String] - attrsToLookupTable = - aliases: - let - lookupTables = lib.mapAttrsToList (from: to: { "${from}" = to; }) aliases; - in - mergeLookupTables lookupTables; + attrsToLookupTable = aliases: let + lookupTables = lib.mapAttrsToList (from: to: {"${from}" = to;}) aliases; + in mergeLookupTables lookupTables; # extra_valiases_postfix :: Map String [String] extra_valiases_postfix = attrsToLookupTable cfg.extraVirtualAliases; @@ -90,49 +56,33 @@ let forwards = attrsToLookupTable cfg.forwards; # lookupTableToString :: Map String [String] -> String - lookupTableToString = - attrs: - let - valueToString = value: lib.concatStringsSep ", " value; - in - lib.concatStringsSep "\n" ( - lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs - ); + lookupTableToString = attrs: let + valueToString = value: lib.concatStringsSep ", " value; + in lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs); # valiases_file :: Path - valiases_file = - let - content = lookupTableToString (mergeLookupTables [ - all_valiases_postfix - catchAllPostfix - ]); - in - builtins.toFile "valias" content; - - regex_valiases_file = - let - content = lookupTableToString regex_valiases_postfix; - in - builtins.toFile "regex_valias" content; + valiases_file = let + content = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix]); + in builtins.toFile "valias" content; # denied_recipients_postfix :: [ String ] - denied_recipients_postfix = map (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") ( - lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts) - ); - denied_recipients_file = builtins.toFile "denied_recipients" ( - lib.concatStringsSep "\n" denied_recipients_postfix - ); + denied_recipients_postfix = (map + (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") + (lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts))); + denied_recipients_file = builtins.toFile "denied_recipients" (lib.concatStringsSep "\n" denied_recipients_postfix); - reject_senders_postfix = map (sender: "${sender} REJECT") cfg.rejectSender; - reject_senders_file = builtins.toFile "reject_senders" ( - lib.concatStringsSep "\n" reject_senders_postfix - ); + reject_senders_postfix = (map + (sender: + "${sender} REJECT") + (cfg.rejectSender)); + reject_senders_file = builtins.toFile "reject_senders" (lib.concatStringsSep "\n" (reject_senders_postfix)) ; - reject_recipients_postfix = map (recipient: "${recipient} REJECT") cfg.rejectRecipients; + reject_recipients_postfix = (map + (recipient: + "${recipient} REJECT") + (cfg.rejectRecipients)); # rejectRecipients :: [ Path ] - reject_recipients_file = builtins.toFile "reject_recipients" ( - lib.concatStringsSep "\n" reject_recipients_postfix - ); + reject_recipients_file = builtins.toFile "reject_recipients" (lib.concatStringsSep "\n" (reject_recipients_postfix)) ; # vhosts_file :: Path vhosts_file = builtins.toFile "vhosts" (concatStringsSep "\n" cfg.domains); @@ -144,257 +94,159 @@ let # every alias is owned (uniquely) by its user. # The user's own address is already in all_valiases_postfix. vaccounts_file = builtins.toFile "vaccounts" (lookupTableToString all_valiases_postfix); - regex_vaccounts_file = builtins.toFile "regex_vaccounts" ( - lookupTableToString regex_valiases_postfix - ); - submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" ( - '' - # Removes sensitive headers from mails handed in via the submission port. - # See https://thomas-leister.de/mailserver-debian-stretch/ - # Uses "pcre" style regex. + submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" ('' + # Removes sensitive headers from mails handed in via the submission port. + # See https://thomas-leister.de/mailserver-debian-stretch/ + # Uses "pcre" style regex. - /^Received:/ IGNORE - /^X-Originating-IP:/ IGNORE - /^X-Mailer:/ IGNORE - /^User-Agent:/ IGNORE - /^X-Enigmail:/ IGNORE - '' - + lib.optionalString cfg.rewriteMessageId '' + /^Received:/ IGNORE + /^X-Originating-IP:/ IGNORE + /^X-Mailer:/ IGNORE + /^User-Agent:/ IGNORE + /^X-Enigmail:/ IGNORE + '' + lib.optionalString cfg.rewriteMessageId '' - # Replaces the user submitted hostname with the server's FQDN to hide the - # user's host or network. + # Replaces the user submitted hostname with the server's FQDN to hide the + # user's host or network. - /^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}> - '' - ); + /^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}> + ''); - smtpdMilters = [ "unix:/run/rspamd/rspamd-milter.sock" ]; + inetSocket = addr: port: "inet:[${toString port}@${addr}]"; + unixSocket = sock: "unix:${sock}"; + + smtpdMilters = + (lib.optional cfg.dkimSigning "unix:/run/opendkim/opendkim.sock") + ++ [ "unix:/run/rspamd/rspamd-milter.sock" ]; + + policyd-spf = pkgs.writeText "policyd-spf.conf" cfg.policydSPFExtraConfig; mappedFile = name: "hash:/var/lib/postfix/conf/${name}"; - mappedRegexFile = name: "pcre:/var/lib/postfix/conf/${name}"; - submissionOptions = { - smtpd_tls_security_level = "encrypt"; - smtpd_sasl_auth_enable = "yes"; - smtpd_sasl_type = "dovecot"; - smtpd_sasl_path = "/run/dovecot2/auth"; - smtpd_sasl_security_options = "noanonymous"; - smtpd_sasl_local_domain = "$myhostname"; - smtpd_client_restrictions = "permit_sasl_authenticated,reject"; - smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}${ - lib.optionalString (regex_valiases_postfix != { }) ",pcre:/etc/postfix/regex_vaccounts" - }"; - smtpd_sender_restrictions = "reject_sender_login_mismatch"; - smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject"; - cleanup_service_name = "submission-header-cleanup"; - }; - - 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; - }; + 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"; + }; 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 = "${sendingFqdn}"; + networksStyle = "host"; mapFiles."valias" = valiases_file; - mapFiles."regex_valias" = regex_valiases_file; mapFiles."vaccounts" = vaccounts_file; - mapFiles."regex_vaccounts" = regex_vaccounts_file; mapFiles."denied_recipients" = denied_recipients_file; mapFiles."reject_senders" = reject_senders_file; mapFiles."reject_recipients" = reject_recipients_file; + sslCert = certificatePath; + sslKey = keyPath; enableSubmission = cfg.enableSubmission; enableSubmissions = cfg.enableSubmissionSsl; - virtual = lookupTableToString (mergeLookupTables [ - all_valiases_postfix - catchAllPostfix - forwards - ]); + virtual = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix forwards]); config = { - myhostname = cfg.sendingFqdn; - mydestination = ""; # disable local mail delivery + # Extra Config + mydestination = ""; recipient_delimiter = cfg.recipientDelimiter; - smtpd_banner = "${cfg.fqdn} ESMTP NO UCE"; + smtpd_banner = "${fqdn} ESMTP NO UCE"; disable_vrfy_command = true; message_size_limit = toString cfg.messageSizeLimit; # virtual mail system virtual_uid_maps = "static:5000"; virtual_gid_maps = "static:5000"; - virtual_mailbox_base = cfg.mailDirectory; + virtual_mailbox_base = mailDirectory; virtual_mailbox_domains = vhosts_file; - virtual_mailbox_maps = - [ - (mappedFile "valias") - ] - ++ lib.optionals cfg.ldap.enable [ - "ldap:${ldapVirtualMailboxMapFile}" - ] - ++ lib.optionals (regex_valiases_postfix != { }) [ - (mappedRegexFile "regex_valias") - ]; - virtual_alias_maps = lib.mkAfter ( - lib.optionals (regex_valiases_postfix != { }) [ - (mappedRegexFile "regex_valias") - ] - ); + virtual_mailbox_maps = mappedFile "valias"; 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 = "eNULL, aNULL"; - smtpd_tls_mandatory_exclude_ciphers = "eNULL, aNULL"; + # Disable deprecated ciphers + smtpd_tls_mandatory_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL"; + smtpd_tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL"; + smtp_tls_mandatory_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL"; + smtp_tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL"; - # Opportunistic DANE support when delivering mail to other servers - # https://www.postfix.org/postconf.5.html#smtp_tls_security_level - smtp_dns_support_level = "dnssec"; - smtp_tls_security_level = "dane"; - - # TLS versions supported for the SMTP client - smtp_tls_protocols = ">=TLSv1.2"; - smtp_tls_mandatory_protocols = ">=TLSv1.2"; - - # Require ciphersuites that OpenSSL classifies as "High" - smtp_tls_ciphers = "high"; - smtp_tls_mandatory_ciphers = "high"; - - # Exclude ciphersuites with undesirable properties - smtp_tls_exclude_ciphers = "eNULL, aNULL"; - smtp_tls_mandatory_exclude_ciphers = "eNULL, aNULL"; - - # Restrict and prioritize the following curves in the given order - # Excludes curves that have no widespread support, so we don't bloat the handshake needlessly. - # https://www.postfix.org/postconf.5.html#tls_eecdh_auto_curves - # https://ssl-config.mozilla.org/#server=postfix&version=3.10&config=intermediate&openssl=3.4.1&guideline=5.7 - tls_eecdh_auto_curves = [ - "X25519" - "prime256v1" - "secp384r1" - ]; - - # Disable FFDHE on TLSv1.3 because it is slower than elliptic curves - # https://www.postfix.org/postconf.5.html#tls_ffdhe_auto_groups - tls_ffdhe_auto_groups = [ ]; - - # As long as all cipher suites are considered safe, let the client use its preferred cipher - tls_preempt_cipherlist = false; + tls_preempt_cipherlist = true; + # Allowing AUTH on a non encrypted connection poses a security risk + smtpd_tls_auth_only = true; # Log only a summary message on TLS handshake completion - smtp_tls_loglevel = "1"; smtpd_tls_loglevel = "1"; + # Configure a non blocking source of randomness + tls_random_source = "dev:/dev/urandom"; + smtpd_milters = smtpdMilters; - non_smtpd_milters = lib.mkIf cfg.dkimSigning [ "unix:/run/rspamd/rspamd-milter.sock" ]; + non_smtpd_milters = lib.mkIf cfg.dkimSigning ["unix:/run/opendkim/opendkim.sock"]; milter_protocol = "6"; - milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_authen}"; + milter_mail_macros = "i {mail_addr} {client_addr} {client_name} {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer}"; + }; submissionOptions = submissionOptions; 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"; @@ -402,10 +254,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 7ed2a0e..371edde 100644 --- a/mail-server/rspamd.nix +++ b/mail-server/rspamd.nix @@ -14,12 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: +{ config, pkgs, lib, ... }: let cfg = config.mailserver; @@ -27,121 +22,56 @@ let postfixCfg = config.services.postfix; rspamdCfg = config.services.rspamd; rspamdSocket = "rspamd.service"; - - rspamdUser = config.services.rspamd.user; - rspamdGroup = config.services.rspamd.group; - - createDkimKeypair = - domain: - let - privateKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.key"; - publicKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.txt"; - in - pkgs.writeShellScript "dkim-keygen-${domain}" '' - if [ ! -f "${privateKey}" ] - then - ${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen \ - --domain "${domain}" \ - --selector "${cfg.dkimSelector}" \ - --type "${cfg.dkimKeyType}" \ - --bits ${toString cfg.dkimKeyBits} \ - --privkey "${privateKey}" > "${publicKey}" - chmod 0644 "${publicKey}" - echo "Generated key for domain ${domain} and selector ${cfg.dkimSelector}" - fi - ''; in { - config = lib.mkIf cfg.enable { - environment.systemPackages = lib.mkBefore [ - (pkgs.runCommand "rspamc-wrapped" - { - nativeBuildInputs = with pkgs; [ makeWrapper ]; - } - '' - makeWrapper ${pkgs.rspamd}/bin/rspamc $out/bin/rspamc \ - --add-flags "-h /run/rspamd/worker-controller.sock" - '' - ) - ]; - + config = with cfg; lib.mkIf enable { services.rspamd = { enable = true; - inherit (cfg) debug; + inherit debug; locals = { + "milter_headers.conf" = { text = '' + extended_spam_headers = yes; + ''; }; + "redis.conf" = { text = '' + servers = "${cfg.redis.address}:${toString cfg.redis.port}"; + '' + (lib.optionalString (cfg.redis.password != null) '' + password = "${cfg.redis.password}"; + ''); }; + "classifier-bayes.conf" = { text = '' + cache { + backend = "redis"; + } + ''; }; + "antivirus.conf" = lib.mkIf cfg.virusScanning { text = '' + clamav { + action = "reject"; + symbol = "CLAM_VIRUS"; + type = "clamav"; + log_clean = true; + servers = "/run/clamav/clamd.ctl"; + scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all + } + ''; }; + "dkim_signing.conf" = { text = '' + # Disable outbound email signing, we use opendkim for this + enabled = false; + ''; }; + }; + + 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 = "${cfg.dmarcReporting.email}"; - domain = "${cfg.dmarcReporting.domain}"; - org_name = "${cfg.dmarcReporting.organizationName}"; - from_name = "${cfg.dmarcReporting.fromName}"; - msgid_from = "${cfg.dmarcReporting.domain}"; - ${lib.optionalString (cfg.dmarcReporting.excludeDomains != [ ]) '' - exclude_domains = ${builtins.toJSON cfg.dmarcReporting.excludeDomains}; - ''} - }''} - ''; - }; }; workers.rspamd_proxy = { type = "rspamd_proxy"; - bindSockets = [ - { - socket = "/run/rspamd/rspamd-milter.sock"; - mode = "0664"; - } - ]; + bindSockets = [{ + socket = "/run/rspamd/rspamd-milter.sock"; + mode = "0664"; + }]; count = 1; # Do not spawn too many processes of this type extraConfig = '' milter = yes; # Enable milter mode @@ -156,13 +86,11 @@ in workers.controller = { type = "controller"; count = 1; - bindSockets = [ - { - socket = "/run/rspamd/worker-controller.sock"; - mode = "0666"; - } - ]; - includes = [ ]; + bindSockets = [{ + socket = "/run/rspamd/worker-controller.sock"; + mode = "0666"; + }]; + includes = []; extraConfig = '' static_dir = "''${WWWDIR}"; # Serve the web UI static assets ''; @@ -170,96 +98,11 @@ in }; - services.redis.servers.rspamd.enable = lib.mkDefault true; - - systemd.tmpfiles.settings."10-rspamd.conf" = { - "${cfg.dkimKeyDirectory}" = { - d = { - # Create /var/dkim owned by rspamd user/group - user = rspamdUser; - group = rspamdGroup; - }; - Z = { - # Recursively adjust permissions in /var/dkim - user = rspamdUser; - group = rspamdGroup; - }; - }; - }; + services.redis.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 = '' - ${pkgs.rspamd}/bin/rspamadm dmarc_report $(date -d "yesterday" "+%Y%m%d") - ''; - serviceConfig = { - User = "${config.services.rspamd.user}"; - Group = "${config.services.rspamd.group}"; - - AmbientCapabilities = [ ]; - CapabilityBoundingSet = ""; - DevicePolicy = "closed"; - IPAddressAllow = "localhost"; - LockPersonality = true; - NoNewPrivileges = true; - PrivateDevices = true; - PrivateMounts = true; - PrivateTmp = true; - PrivateUsers = true; - ProtectClock = true; - ProtectControlGroups = true; - ProtectHome = true; - ProtectHostname = true; - ProtectKernelLogs = true; - ProtectKernelModules = true; - ProtectKernelTunables = true; - ProtectProc = "invisible"; - ProcSubset = "pid"; - ProtectSystem = "strict"; - RemoveIPC = true; - RestrictAddressFamilies = [ - "AF_INET" - "AF_INET6" - ]; - RestrictNamespaces = true; - RestrictRealtime = true; - RestrictSUIDSGID = true; - SystemCallArchitectures = "native"; - SystemCallFilter = [ - "@system-service" - "~@privileged" - ]; - UMask = "0077"; - }; - }; - - systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable { - 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 = { @@ -270,3 +113,4 @@ in users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ]; }; } + diff --git a/mail-server/systemd.nix b/mail-server/systemd.nix index 8fb0da7..36e48d6 100644 --- a/mail-server/systemd.nix +++ b/mail-server/systemd.nix @@ -14,77 +14,70 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: +{ config, pkgs, lib, ... }: let cfg = config.mailserver; certificatesDeps = - if cfg.certificateScheme == "manual" then - [ ] - else if cfg.certificateScheme == "selfsigned" then + if cfg.certificateScheme == 1 then + [] + else if cfg.certificateScheme == 2 then [ "mailserver-selfsigned-certificate.service" ] else [ "acme-finished-${cfg.fqdn}.target" ]; in { - config = lib.mkIf cfg.enable { + config = with cfg; lib.mkIf enable { # Create self signed certificate - systemd.services.mailserver-selfsigned-certificate = - lib.mkIf (cfg.certificateScheme == "selfsigned") - { - after = [ "local-fs.target" ]; - script = '' - # Create certificates if they do not exist yet - dir="${cfg.certificateDirectory}" - fqdn="${cfg.fqdn}" - [[ $fqdn == /* ]] && fqdn=$(< "$fqdn") - key="$dir/key-${cfg.fqdn}.pem"; - cert="$dir/cert-${cfg.fqdn}.pem"; + systemd.services.mailserver-selfsigned-certificate = lib.mkIf (cfg.certificateScheme == 2) { + after = [ "local-fs.target" ]; + script = '' + # Create certificates if they do not exist yet + dir="${cfg.certificateDirectory}" + fqdn="${cfg.fqdn}" + [[ $fqdn == /* ]] && fqdn=$(< "$fqdn") + key="$dir/key-${cfg.fqdn}.pem"; + cert="$dir/cert-${cfg.fqdn}.pem"; - if [[ ! -f $key || ! -f $cert ]]; then - mkdir -p "${cfg.certificateDirectory}" - (umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) && - "${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \ - -days 3650 -out "$cert" - fi - ''; - serviceConfig = { - Type = "oneshot"; - PrivateTmp = true; - }; - }; + if [[ ! -f $key || ! -f $cert ]]; then + mkdir -p "${cfg.certificateDirectory}" + (umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) && + "${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \ + -days 3650 -out "$cert" + fi + ''; + serviceConfig = { + Type = "oneshot"; + PrivateTmp = true; + }; + }; # Create maildir folder before dovecot startup systemd.services.dovecot2 = { wants = certificatesDeps; after = certificatesDeps; - preStart = - let - directories = lib.strings.escapeShellArgs ( - [ cfg.mailDirectory ] ++ lib.optional (cfg.indexDir != null) cfg.indexDir - ); - in - '' - # Create mail directory and set permissions. See - # . - # Prevent world-readable paths, even temporarily. - umask 007 - mkdir -p ${directories} - chgrp "${cfg.vmailGroupName}" ${directories} - chmod 02770 ${directories} - ''; + preStart = let + directories = lib.strings.escapeShellArgs ( + [ mailDirectory ] + ++ lib.optional (cfg.indexDir != null) cfg.indexDir + ); + in '' + # Create mail directory and set permissions. See + # . + mkdir -p ${directories} + chgrp "${vmailGroupName}" ${directories} + chmod 02770 ${directories} + ''; }; # Postfix requires dovecot lmtp socket, dovecot auth socket and certificate to work systemd.services.postfix = { wants = certificatesDeps; - after = [ "dovecot2.service" ] ++ lib.optional cfg.dkimSigning "rspamd.service" ++ certificatesDeps; - requires = [ "dovecot2.service" ] ++ lib.optional cfg.dkimSigning "rspamd.service"; + after = [ "dovecot2.service" ] + ++ lib.optional cfg.dkimSigning "opendkim.service" + ++ certificatesDeps; + requires = [ "dovecot2.service" ] + ++ lib.optional cfg.dkimSigning "opendkim.service"; }; }; } diff --git a/mail-server/users.nix b/mail-server/users.nix index e9af05a..916ec0c 100644 --- a/mail-server/users.nix +++ b/mail-server/users.nix @@ -14,12 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - config, - pkgs, - lib, - ... -}: +{ config, pkgs, lib, ... }: with config.mailserver; @@ -33,14 +28,12 @@ let group = vmailGroupName; }; + virtualMailUsersActivationScript = pkgs.writeScript "activate-virtual-mail-users" '' #!${pkgs.stdenv.shell} set -euo pipefail - # Prevent world-readable paths, even temporarily. - umask 007 - # Create directory to store user sieve scripts if it doesn't exist if (! test -d "${sieveDirectory}"); then mkdir "${sieveDirectory}" @@ -50,54 +43,45 @@ let # Copy user's sieve script to the correct location (if it exists). If it # is null, remove the file. - ${lib.concatMapStringsSep "\n" ( - { name, sieveScript }: - if lib.isString sieveScript then - '' - if (! test -d "${sieveDirectory}/${name}"); then - mkdir -p "${sieveDirectory}/${name}" - chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}" - chmod 770 "${sieveDirectory}/${name}" - fi - cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve" - ${sieveScript} - EOF - chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve" - '' - else - '' - if (test -f "${sieveDirectory}/${name}/default.sieve"); then - rm "${sieveDirectory}/${name}/default.sieve" - fi - if (test -f "${sieveDirectory}/${name}.svbin"); then - rm "${sieveDirectory}/${name}/default.svbin" - fi - '' - ) (map (user: { inherit (user) name sieveScript; }) (lib.attrValues loginAccounts))} + ${lib.concatMapStringsSep "\n" ({ name, sieveScript }: + if lib.isString sieveScript then '' + if (! test -d "${sieveDirectory}/${name}"); then + mkdir -p "${sieveDirectory}/${name}" + chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}" + chmod 770 "${sieveDirectory}/${name}" + fi + cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve" + ${sieveScript} + EOF + chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve" + '' else '' + if (test -f "${sieveDirectory}/${name}/default.sieve"); then + rm "${sieveDirectory}/${name}/default.sieve" + fi + if (test -f "${sieveDirectory}/${name}.svbin"); then + rm "${sieveDirectory}/${name}/default.svbin" + fi + '') (map (user: { inherit (user) name sieveScript; }) + (lib.attrValues loginAccounts))} ''; -in -{ +in { config = lib.mkIf enable { # assert that all accounts provide a password - assertions = map (acct: { - assertion = acct.hashedPassword != null || acct.hashedPasswordFile != null; + assertions = (map (acct: { + assertion = (acct.hashedPassword != null || acct.hashedPasswordFile != null); message = "${acct.name} must provide either a hashed password or a password hash file"; - }) (lib.attrValues loginAccounts); + }) (lib.attrValues loginAccounts)); # warn for accounts that specify both password and file - warnings = - map (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used") - ( - lib.filter (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null)) ( - lib.attrValues loginAccounts - ) - ); + warnings = (map + (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used") + (lib.filter + (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null)) + (lib.attrValues loginAccounts))); # set the vmail gid to a specific value users.groups = { - "${vmailGroupName}" = { - gid = vmailUID; - }; + "${vmailGroupName}" = { gid = vmailUID; }; }; # define all users diff --git a/nix/sources.json b/nix/sources.json new file mode 100644 index 0000000..2d2d79a --- /dev/null +++ b/nix/sources.json @@ -0,0 +1,33 @@ +{ + "blobs": { + "sha256": "1g687x3b2r4ar5i4xyav5qzpy9fp1phx9wf70f4j3scwny0g7hn1", + "type": "tarball", + "url": "https://gitlab.com/simple-nixos-mailserver/blobs/-/archive/2cccdf1ca48316f2cfd1c9a0017e8de5a7156265/blobs-2cccdf1ca48316f2cfd1c9a0017e8de5a7156265.tar.gz", + "url_template": "https://gitlab.com/simple-nixos-mailserver/blobs/-/archive//blobs-.tar.gz", + "version": "2cccdf1ca48316f2cfd1c9a0017e8de5a7156265" + }, + "nixpkgs-20.09": { + "branch": "release-20.09", + "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", + "rev": "b6eefa48d8e10491e43c0c6155ac12b463f6fed3", + "sha256": "0hrp7gshy62bsj719xd6hk6z284pzr8ksw1vvxvyfrffq1f7d8k9", + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/b6eefa48d8e10491e43c0c6155ac12b463f6fed3.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", + "rev": "e5cc06a1e806070693add4f231060a62b962fc44", + "sha256": "04543i332fx9m7jf6167ac825s4qb8is0d0x0pz39il979mlc87v", + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/e5cc06a1e806070693add4f231060a62b962fc44.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..c002da7 --- /dev/null +++ b/nixops/single-server.nix @@ -0,0 +1,31 @@ +{ + network.description = "mail server"; + + mailserver = + { config, pkgs, ... }: + { + imports = [ + ../default.nix + ]; + + mailserver = { + enable = true; + fqdn = "mail.example.com"; + domains = [ "example.com" "example2.com" ]; + loginAccounts = { + "user1@example.com" = { + hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/"; + }; + }; + extraVirtualAliases = { + "info@example.com" = "user1@example.com"; + "postmaster@example.com" = "user1@example.com"; + "abuse@example.com" = "user1@example.com"; + "user1@example2.com" = "user1@example.com"; + "info@example2.com" = "user1@example.com"; + "postmaster@example2.com" = "user1@example.com"; + "abuse@example2.com" = "user1@example.com"; + }; + }; + }; +} diff --git a/nixops/vbox.nix b/nixops/vbox.nix new file mode 100644 index 0000000..2af7518 --- /dev/null +++ b/nixops/vbox.nix @@ -0,0 +1,9 @@ +{ + mailserver = + { config, pkgs, ... }: + { deployment.targetEnv = "virtualbox"; + deployment.virtualbox.memorySize = 1024; # megabytes + deployment.virtualbox.vcpu = 2; # number of cpus + deployment.virtualbox.headless = true; + }; +} diff --git a/scripts/generate-options.py b/scripts/generate-options.py 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 index 39b2688..f5a97a5 100644 --- a/scripts/mail-check.py +++ b/scripts/mail-check.py @@ -1,31 +1,26 @@ +import smtplib, sys import argparse -import email -import email.utils -import imaplib -import smtplib -import time +import os import uuid +import imaplib from datetime import datetime, timedelta -from typing import cast +import email +import time RETRY = 100 +def _send_mail(smtp_host, smtp_port, from_addr, from_pwd, to_addr, subject, starttls): + print("Sending mail with subject '{}'".format(subject)) + message = "\n".join([ + "From: {from_addr}", + "To: {to_addr}", + "Subject: {subject}", + "", + "This validates our mail server can send to Gmail :/"]).format( + from_addr=from_addr, + to_addr=to_addr, + subject=subject) -def _send_mail( - smtp_host, smtp_port, smtp_username, from_addr, from_pwd, to_addr, subject, starttls -): - print(f"Sending mail with subject '{subject}'") - message = "\n".join( - [ - f"From: {from_addr}", - f"To: {to_addr}", - f"Subject: {subject}", - f"Message-ID: {uuid.uuid4()}@mail-check.py", - f"Date: {email.utils.formatdate()}", - "", - "This validates our mail server can send to Gmail :/", - ] - ) retry = RETRY while True: @@ -35,16 +30,14 @@ def _send_mail( if starttls: smtp.starttls() if from_pwd is not None: - smtp.login(smtp_username or from_addr, from_pwd) + smtp.login(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') + elif e.smtp_code == 454: # smtplib.SMTPResponseException: (454, b'4.3.0 Try again later') print(e) else: raise @@ -62,18 +55,16 @@ def _send_mail( print("Retry attempts exhausted") exit(5) - def _read_mail( - imap_host, - imap_port, - imap_username, - to_pwd, - subject, - ignore_dkim_spf, - show_body=False, - delete=True, -): - print("Reading mail from {imap_username}") + imap_host, + imap_port, + imap_username, + to_pwd, + subject, + ignore_dkim_spf, + show_body=False, + delete=True): + print("Reading mail from %s" % imap_username) message = None @@ -83,62 +74,49 @@ def _read_mail( today = datetime.today() cutoff = today - timedelta(days=1) - dt = cutoff.strftime("%d-%b-%Y") + dt = cutoff.strftime('%d-%b-%Y') for _ in range(0, RETRY): print("Retrying") obj.select() - _, data = obj.search(None, f'(SINCE {dt}) (SUBJECT "{subject}")') - if data == [b""]: + typ, data = obj.search(None, '(SINCE %s) (SUBJECT "%s")'%(dt, subject)) + if data == [b'']: time.sleep(1) continue uids = data[0].decode("utf-8").split(" ") if len(uids) != 1: - print( - f"Warning: {len(uids)} messages have been found with subject containing {subject}" - ) + print("Warning: %d messages have been found with subject containing %s " % (len(uids), subject)) # FIXME: we only consider the first matching message... uid = uids[0] - _, raw = obj.fetch(uid, "(RFC822)") + _, raw = obj.fetch(uid, '(RFC822)') if delete: - obj.store(uid, "+FLAGS", "\\Deleted") + obj.store(uid, '+FLAGS', '\\Deleted') obj.expunge() - assert raw[0] and raw[0][1] - message = email.message_from_bytes(cast(bytes, raw[0][1])) - print(f"Message with subject '{message['subject']}' has been found") + message = email.message_from_bytes(raw[0][1]) + print("Message with subject '%s' has been found" % message['subject']) if show_body: - if message.is_multipart(): - for part in message.walk(): - ctype = part.get_content_type() - if ctype == "text/plain": - body = cast(bytes, part.get_payload(decode=True)).decode() - print(f"Body:\n{body}") - else: - print(f"Body with content type {ctype} not printed") - else: - body = cast(bytes, message.get_payload(decode=True)).decode() - print(f"Body:\n{body}") + for m in message.get_payload(): + if m.get_content_type() == 'text/plain': + print("Body:\n%s" % m.get_payload(decode=True).decode('utf-8')) break if message is None: - print( - f"Error: no message with subject '{subject}' has been found in INBOX of {imap_username}" - ) + print("Error: no message with subject '%s' has been found in INBOX of %s" % (subject, imap_username)) exit(1) if ignore_dkim_spf: return # gmail set this standardized header - if "ARC-Authentication-Results" in message: - if "dkim=pass" in message["ARC-Authentication-Results"]: + if 'ARC-Authentication-Results' in message: + if "dkim=pass" in message['ARC-Authentication-Results']: print("DKIM ok") else: print("Error: no DKIM validation found in message:") print(message.as_string()) exit(2) - if "spf=pass" in message["ARC-Authentication-Results"]: + if "spf=pass" in message['ARC-Authentication-Results']: print("SPF ok") else: print("Error: no SPF validation found in message:") @@ -148,108 +126,69 @@ def _read_mail( print("DKIM and SPF verification failed") exit(4) - def send_and_read(args): src_pwd = None if args.src_password_file is not None: src_pwd = args.src_password_file.readline().rstrip() dst_pwd = args.dst_password_file.readline().rstrip() - if args.imap_username != "": + if args.imap_username != '': imap_username = args.imap_username else: imap_username = args.to_addr - subject = f"{uuid.uuid4()}" + subject = "{}".format(uuid.uuid4()) - _send_mail( - smtp_host=args.smtp_host, - smtp_port=args.smtp_port, - smtp_username=args.smtp_username, - from_addr=args.from_addr, - from_pwd=src_pwd, - to_addr=args.to_addr, - subject=subject, - starttls=args.smtp_starttls, - ) - - _read_mail( - imap_host=args.imap_host, - imap_port=args.imap_port, - imap_username=imap_username, - to_pwd=dst_pwd, - subject=subject, - ignore_dkim_spf=args.ignore_dkim_spf, - ) + _send_mail(smtp_host=args.smtp_host, + smtp_port=args.smtp_port, + from_addr=args.from_addr, + from_pwd=src_pwd, + to_addr=args.to_addr, + subject=subject, + starttls=args.smtp_starttls) + _read_mail(imap_host=args.imap_host, + imap_port=args.imap_port, + imap_username=imap_username, + to_pwd=dst_pwd, + subject=subject, + ignore_dkim_spf=args.ignore_dkim_spf) def read(args): - _read_mail( - imap_host=args.imap_host, - imap_port=args.imap_port, - imap_username=args.imap_username, - to_pwd=args.imap_password, - subject=args.subject, - ignore_dkim_spf=args.ignore_dkim_spf, - show_body=args.show_body, - delete=False, - ) - + _read_mail(imap_host=args.imap_host, + imap_port=args.imap_port, + to_addr=args.imap_username, + to_pwd=args.imap_password, + subject=args.subject, + ignore_dkim_spf=args.ignore_dkim_spf, + show_body=args.show_body, + delete=False) parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() -parser_send_and_read = subparsers.add_parser( - "send-and-read", - description="Send a email with a subject containing a random UUID and then try to read this email from the recipient INBOX.", -) -parser_send_and_read.add_argument("--smtp-host", type=str) -parser_send_and_read.add_argument("--smtp-port", type=str, default=25) -parser_send_and_read.add_argument("--smtp-starttls", action="store_true") -parser_send_and_read.add_argument( - "--smtp-username", - type=str, - default="", - help="username used for smtp login. If not specified, the from-addr value is used", -) -parser_send_and_read.add_argument("--from-addr", type=str) -parser_send_and_read.add_argument("--imap-host", required=True, type=str) -parser_send_and_read.add_argument("--imap-port", type=str, default=993) -parser_send_and_read.add_argument("--to-addr", type=str, required=True) -parser_send_and_read.add_argument( - "--imap-username", - type=str, - default="", - help="username used for imap login. If not specified, the to-addr value is used", -) -parser_send_and_read.add_argument("--src-password-file", type=argparse.FileType("r")) -parser_send_and_read.add_argument( - "--dst-password-file", required=True, type=argparse.FileType("r") -) -parser_send_and_read.add_argument( - "--ignore-dkim-spf", - action="store_true", - help="to ignore the dkim and spf verification on the read mail", -) +parser_send_and_read = subparsers.add_parser('send-and-read', description="Send a email with a subject containing a random UUID and then try to read this email from the recipient INBOX.") +parser_send_and_read.add_argument('--smtp-host', type=str) +parser_send_and_read.add_argument('--smtp-port', type=str, default=25) +parser_send_and_read.add_argument('--smtp-starttls', action='store_true') +parser_send_and_read.add_argument('--from-addr', type=str) +parser_send_and_read.add_argument('--imap-host', required=True, type=str) +parser_send_and_read.add_argument('--imap-port', type=str, default=993) +parser_send_and_read.add_argument('--to-addr', type=str, required=True) +parser_send_and_read.add_argument('--imap-username', type=str, default='', help="username used for imap login. If not specified, the to-addr value is used") +parser_send_and_read.add_argument('--src-password-file', type=argparse.FileType('r')) +parser_send_and_read.add_argument('--dst-password-file', required=True, type=argparse.FileType('r')) +parser_send_and_read.add_argument('--ignore-dkim-spf', action='store_true', help="to ignore the dkim and spf verification on the read mail") parser_send_and_read.set_defaults(func=send_and_read) -parser_read = subparsers.add_parser( - "read", - description="Search for an email with a subject containing 'subject' in the INBOX.", -) -parser_read.add_argument("--imap-host", type=str, default="localhost") -parser_read.add_argument("--imap-port", type=str, default=993) -parser_read.add_argument("--imap-username", required=True, type=str) -parser_read.add_argument("--imap-password", required=True, type=str) -parser_read.add_argument( - "--ignore-dkim-spf", - action="store_true", - help="to ignore the dkim and spf verification on the read mail", -) -parser_read.add_argument( - "--show-body", action="store_true", help="print mail text/plain payload" -) -parser_read.add_argument("subject", type=str) +parser_read = subparsers.add_parser('read', description="Search for an email with a subject containing 'subject' in the INBOX.") +parser_read.add_argument('--imap-host', type=str, default="localhost") +parser_read.add_argument('--imap-port', type=str, default=993) +parser_read.add_argument('--imap-username', required=True, type=str) +parser_read.add_argument('--imap-password', required=True, type=str) +parser_read.add_argument('--ignore-dkim-spf', action='store_true', help="to ignore the dkim and spf verification on the read mail") +parser_read.add_argument('--show-body', action='store_true', help="print mail text/plain payload") +parser_read.add_argument('subject', type=str) parser_read.set_defaults(func=read) args = parser.parse_args() diff --git a/shell.nix b/shell.nix index 493783d..d32886e 100644 --- a/shell.nix +++ b/shell.nix @@ -1,9 +1 @@ -(import ( - let - lock = builtins.fromJSON (builtins.readFile ./flake.lock); - in - fetchTarball { - url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; - sha256 = lock.nodes.flake-compat.locked.narHash; - } -) { src = ./.; }).shellNix +(import (builtins.fetchGit "https://github.com/edolstra/flake-compat") { src = ./.; }).shellNix diff --git a/tests/clamav.nix b/tests/clamav.nix index 209e91e..f62df58 100644 --- a/tests/clamav.nix +++ b/tests/clamav.nix @@ -14,115 +14,104 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - lib, - blobs, - ... -}: +{ pkgs ? import {}}: -{ +pkgs.nixosTest { name = "clamav"; - 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; + sources = import ../nix/sources.nix; + blobs = pkgs.fetchzip { + url = sources.blobs.url; + sha256 = sources.blobs.sha256; + }; + 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 ${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, 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 +185,60 @@ ''; }; }; - }; + }; - testScript = '' - start_all() + testScript = { nodes, ... }: + '' + start_all() - server.wait_for_unit("multi-user.target") - client.wait_for_unit("multi-user.target") + server.wait_for_unit("multi-user.target") + client.wait_for_unit("multi-user.target") - # TODO put this blocking into the systemd units? I am not sure if rspamd already waits for the clamd socket. - server.wait_until_succeeds( - "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" - ) - server.wait_until_succeeds( - "set +e; timeout 1 nc -U /run/clamav/clamd.ctl < /dev/null; [ $? -eq 124 ]" - ) + # TODO put this blocking into the systemd units? I am not sure if rspamd already waits for the clamd socket. + server.wait_until_succeeds( + "timeout 1 ${nodes.server.pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" + ) + server.wait_until_succeeds( + "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.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + client.execute("rm ~/mail/*") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("fetchmail --nosslcertck -v >&2") + client.execute("rm ~/mail/*") - with subtest("virus scan file"): - server.succeed( - 'set +o pipefail; clamdscan $(readlink -f /etc/root/eicar.com.txt) | grep "Txt\\.Malware\\.Agent-1787597 FOUND" >&2' - ) + with subtest("virus scan file"): + server.succeed( + 'clamdscan $(readlink -f /etc/root/eicar.com.txt) | grep "Txt\\.Malware\\.Agent-1787597 FOUND" >&2' + ) - with subtest("virus scan email"): - client.succeed( - 'set +o pipefail; msmtp -a user2 user1@example.com < /etc/root/virus-email 2>&1 | tee /dev/stderr | grep "server message: 554 5\\.7\\.1" >&2' - ) - server.succeed("journalctl -u rspamd | grep -i eicar") - # give the mail server some time to process the mail - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + with subtest("virus scan email"): + client.succeed( + 'msmtp -a user2 user1\@example.com < /etc/root/virus-email 2>&1 | tee /dev/stderr | grep "server message: 554 5\\.7\\.1" >&2' + ) + server.succeed("journalctl -u rspamd | grep -i eicar") + # give the mail server some time to process the mail + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - with subtest("no warnings or errors"): - server.fail("journalctl -u postfix | grep -i error >&2") - server.fail("journalctl -u postfix | grep -i warning >&2") - server.fail("journalctl -u dovecot2 | grep -i error >&2") - server.fail("journalctl -u dovecot2 | grep -i warning >&2") - ''; + with subtest("no warnings or errors"): + server.fail("journalctl -u postfix | grep -i error >&2") + server.fail("journalctl -u postfix | grep -i warning >&2") + server.fail("journalctl -u dovecot2 | grep -i error >&2") + server.fail("journalctl -u dovecot2 | grep -i warning >&2") + ''; } diff --git a/tests/default.nix b/tests/default.nix new file mode 100644 index 0000000..10aba18 --- /dev/null +++ b/tests/default.nix @@ -0,0 +1,47 @@ +# Generate an attribute sets containing all tests for all releaeses +# It looks like: +# - external.nixpkgs_20.03 +# - external.nixpkgs_unstable +# - internal.nixpkgs_20.03 +# - internal.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; + }; + + releaseNames = [ + "nixpkgs-unstable" + "nixpkgs-20.09" + ]; + + testNames = [ + "internal" + "external" + "clamav" + "multiple" + ]; + + # 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/external.nix b/tests/external.nix index 82abb65..f453608 100644 --- a/tests/external.nix +++ b/tests/external.nix @@ -14,90 +14,76 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ +{ pkgs ? import {}}: + +pkgs.nixosTest { name = "external"; - nodes = { - server = - { pkgs, ... }: - { - imports = [ - ../default.nix - ./lib/config.nix - ]; - - environment.systemPackages = with pkgs; [ netcat ]; - - virtualisation.memorySize = 1024; - - services.rsyslogd = { - enable = true; - defaultConfig = '' - *.* /dev/console - ''; - }; - - mailserver = { - enable = true; - debug = true; - fqdn = "mail.example.com"; - domains = [ - "example.com" - "example2.com" - ]; - rewriteMessageId = true; - dkimKeyBits = 1535; - dmarcReporting = { - enable = true; - domain = "example.com"; - organizationName = "ACME Corp"; - }; - - loginAccounts = { - "user1@example.com" = { - hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/"; - aliases = [ "postmaster@example.com" ]; - catchAll = [ "example.com" ]; - }; - "user2@example.com" = { - hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0"; - aliases = [ "chuck@example.com" ]; - }; - "user@example2.com" = { - hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0"; - }; - "lowquota@example.com" = { - hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0"; - quota = "1B"; - }; - }; - - extraVirtualAliases = { - "single-alias@example.com" = "user1@example.com"; - "multi-alias@example.com" = [ - "user1@example.com" - "user2@example.com" + server = { config, pkgs, ... }: + { + imports = [ + ../default.nix + ./lib/config.nix ]; - }; - enableImap = true; - enableImapSsl = true; - fullTextSearch = { - enable = true; - autoIndex = true; - # special use depends on https://github.com/NixOS/nixpkgs/pull/93201 - autoIndexExclude = [ - (if (pkgs.lib.versionAtLeast pkgs.lib.version "21") then "\\Junk" else "Junk") - ]; - enforced = "yes"; - }; + virtualisation.memorySize = 1024; + + services.rsyslogd = { + enable = true; + defaultConfig = '' + *.* /dev/console + ''; + }; + + + mailserver = { + enable = true; + debug = true; + fqdn = "mail.example.com"; + domains = [ "example.com" "example2.com" ]; + rewriteMessageId = true; + dkimKeyBits = 1535; + + loginAccounts = { + "user1@example.com" = { + hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/"; + aliases = [ "postmaster@example.com" ]; + catchAll = [ "example.com" ]; + }; + "user2@example.com" = { + hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0"; + aliases = [ "chuck@example.com" ]; + }; + "user@example2.com" = { + hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0"; + }; + "lowquota@example.com" = { + hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0"; + quota = "1B"; + }; + }; + + extraVirtualAliases = { + "single-alias@example.com" = "user1@example.com"; + "multi-alias@example.com" = [ "user1@example.com" "user2@example.com" ]; + }; + + enableImap = true; + enableImapSsl = true; + fullTextSearch = { + enable = true; + autoIndex = true; + # special use depends on https://github.com/NixOS/nixpkgs/pull/93201 + autoIndexExclude = [ (if (pkgs.lib.versionAtLeast pkgs.lib.version "21") then "\\Junk" else "Junk") ]; + enforced = "yes"; + # fts-xapian warns when memory is low, which makes the test fail + memoryLimit = 100000; + }; + }; }; - }; - client = - { nodes, pkgs, ... }: - let - serverIP = nodes.server.networking.primaryIPAddress; - clientIP = nodes.client.networking.primaryIPAddress; + client = { nodes, config, pkgs, ... }: let + serverIP = nodes.server.config.networking.primaryIPAddress; + clientIP = nodes.client.config.networking.primaryIPAddress; grep-ip = pkgs.writeScriptBin "grep-ip" '' #!${pkgs.stdenv.shell} echo grep '${clientIP}' "$@" >&2 @@ -182,36 +168,27 @@ assert needle in repr(response) imap.close() ''; - in - { + in { imports = [ - ./lib/config.nix + ./lib/config.nix ]; environment.systemPackages = with pkgs; [ - fetchmail - msmtp - procmail - findutils - grep-ip - check-mail-id - test-imap-spam - test-imap-ham - search + fetchmail msmtp procmail findutils grep-ip check-mail-id test-imap-spam test-imap-ham search ]; environment.etc = { "root/.fetchmailrc" = { text = '' - poll ${serverIP} with proto IMAP - user 'user1@example.com' there with password 'user1' is 'root' here - mda procmail + poll ${serverIP} with proto IMAP + user 'user1@example.com' there with password 'user1' is 'root' here + mda procmail ''; mode = "0700"; }; "root/.fetchmailRcLowQuota" = { text = '' - poll ${serverIP} with proto IMAP - user 'lowquota@example.com' there with password 'user2' is 'root' here - mda procmail + poll ${serverIP} with proto IMAP + user 'lowquota@example.com' there with password 'user2' is 'root' here + mda procmail ''; mode = "0700"; }; @@ -290,7 +267,7 @@ To: Chuck Cc: Bcc: - Subject: This is a test Email from postmaster@example.com to chuck + Subject: This is a test Email from postmaster\@example.com to chuck Reply-To: Hello Chuck, @@ -304,7 +281,7 @@ To: User1 Cc: Bcc: - Subject: This is a test Email from single-alias@example.com to user1 + Subject: This is a test Email from single-alias\@example.com to user1 Reply-To: Hello User1, @@ -319,7 +296,7 @@ To: Multi Alias Cc: Bcc: - Subject: This is a test Email from user2@example.com to multi-alias + Subject: This is a test Email from user2\@example.com to multi-alias Reply-To: Hello Multi Alias, @@ -340,7 +317,7 @@ Hello User1, this email contains the needle: - 576a4565b70f5a4c1a0925cabdb587a6 + 576a4565b70f5a4c1a0925cabdb587a6 ''; "root/email7".text = '' Message-ID: <1234578qwerty@host.local.network> @@ -357,176 +334,175 @@ ''; }; }; - }; + }; - testScript = '' - start_all() + testScript = { nodes, ... }: + '' + start_all() - server.wait_for_unit("multi-user.target") - client.wait_for_unit("multi-user.target") + server.wait_for_unit("multi-user.target") + client.wait_for_unit("multi-user.target") - # TODO put this blocking into the systemd units? - server.wait_until_succeeds( - "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" - ) + # TODO put this blocking into the systemd units? + server.wait_until_succeeds( + "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") + client.execute("cp -p /etc/root/.* ~/") + client.succeed("mkdir -p ~/mail") + client.succeed("ls -la ~/ >&2") + client.succeed("cat ~/.fetchmailrc >&2") + client.succeed("cat ~/.procmailrc >&2") + client.succeed("cat ~/.msmtprc >&2") - with subtest("imap retrieving mail"): - # fetchmail returns EXIT_CODE 1 when no new mail - client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2") + with subtest("imap retrieving mail"): + # fetchmail returns EXIT_CODE 1 when no new mail + client.succeed("fetchmail --nosslcertck -v || [ $? -eq 1 ] >&2") - with subtest("submission port send mail"): - # send email from user2 to user1 - client.succeed( - "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email1 >&2" - ) - # give the mail server some time to process the mail - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + with subtest("submission port send mail"): + # send email from user2 to user1 + client.succeed( + "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email1 >&2" + ) + # give the mail server some time to process the mail + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - with subtest("imap retrieving mail 2"): - client.execute("rm ~/mail/*") - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.succeed("fetchmail --nosslcertck -v >&2") + with subtest("imap retrieving mail 2"): + client.execute("rm ~/mail/*") + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("fetchmail --nosslcertck -v >&2") - with subtest("remove sensitive information on submission port"): - client.succeed("cat ~/mail/* >&2") - ## make sure our IP is _not_ in the email header - client.fail("grep-ip ~/mail/*") - client.succeed("check-mail-id ~/mail/*") + with subtest("remove sensitive information on submission port"): + client.succeed("cat ~/mail/* >&2") + ## make sure our IP is _not_ in the email header + client.fail("grep-ip ~/mail/*") + client.succeed("check-mail-id ~/mail/*") - with subtest("have correct fqdn as sender"): - client.succeed("grep 'Received: from mail.example.com' ~/mail/*") + with subtest("have correct fqdn as sender"): + client.succeed("grep 'Received: from mail.example.com' ~/mail/*") - with subtest("dkim has user-specified size"): - server.succeed( - "openssl rsa -in /var/dkim/example.com.mail.key -text -noout | grep 'Private-Key: (1535 bit'" - ) + with subtest("dkim has user-specified size"): + server.succeed( + "openssl rsa -in /var/dkim/example.com.mail.key -text -noout | grep 'Private-Key: (1535 bit'" + ) - with subtest("dkim singing, multiple domains"): - client.execute("rm ~/mail/*") - # send email from user2 to user1 - client.succeed( - "msmtp -a test2 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email2 >&2" - ) - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.succeed("fetchmail --nosslcertck -v") - client.succeed("cat ~/mail/* >&2") - # make sure it is dkim signed - client.succeed("grep DKIM-Signature: ~/mail/*") + with subtest("dkim singing, multiple domains"): + client.execute("rm ~/mail/*") + # send email from user2 to user1 + client.succeed( + "msmtp -a test2 --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email2 >&2" + ) + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("fetchmail --nosslcertck -v") + client.succeed("cat ~/mail/* >&2") + # make sure it is dkim signed + client.succeed("grep DKIM ~/mail/*") - with subtest("aliases"): - client.execute("rm ~/mail/*") - # send email from chuck to postmaster - client.succeed( - "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on postmaster@example.com < /etc/root/email2 >&2" - ) - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.succeed("fetchmail --nosslcertck -v") + with subtest("aliases"): + client.execute("rm ~/mail/*") + # send email from chuck to postmaster + client.succeed( + "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on postmaster\@example.com < /etc/root/email2 >&2" + ) + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("fetchmail --nosslcertck -v") - with subtest("catchAlls"): - client.execute("rm ~/mail/*") - # send email from chuck to non exsitent account - client.succeed( - "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lol@example.com < /etc/root/email2 >&2" - ) - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.succeed("fetchmail --nosslcertck -v") + with subtest("catchAlls"): + client.execute("rm ~/mail/*") + # send email from chuck to non exsitent account + client.succeed( + "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lol\@example.com < /etc/root/email2 >&2" + ) + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("fetchmail --nosslcertck -v") - client.execute("rm ~/mail/*") - # send email from user1 to chuck - client.succeed( - "msmtp -a test4 --tls=on --tls-certcheck=off --auth=on chuck@example.com < /etc/root/email2 >&2" - ) - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # fetchmail returns EXIT_CODE 1 when no new mail - # if this succeeds, it means that user1 recieved the mail that was intended for chuck. - client.fail("fetchmail --nosslcertck -v") + client.execute("rm ~/mail/*") + # send email from user1 to chuck + client.succeed( + "msmtp -a test4 --tls=on --tls-certcheck=off --auth=on chuck\@example.com < /etc/root/email2 >&2" + ) + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + # fetchmail returns EXIT_CODE 1 when no new mail + # if this succeeds, it means that user1 recieved the mail that was intended for chuck. + client.fail("fetchmail --nosslcertck -v") - with subtest("extraVirtualAliases"): - client.execute("rm ~/mail/*") - # send email from single-alias to user1 - client.succeed( - "msmtp -a test5 --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email4 >&2" - ) - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.succeed("fetchmail --nosslcertck -v") + with subtest("extraVirtualAliases"): + client.execute("rm ~/mail/*") + # send email from single-alias to user1 + client.succeed( + "msmtp -a test5 --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email4 >&2" + ) + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("fetchmail --nosslcertck -v") - client.execute("rm ~/mail/*") - # send email from user1 to multi-alias (user{1,2}@example.com) - client.succeed( - "msmtp -a test --tls=on --tls-certcheck=off --auth=on multi-alias@example.com < /etc/root/email5 >&2" - ) - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.succeed("fetchmail --nosslcertck -v") + client.execute("rm ~/mail/*") + # send email from user1 to multi-alias (user{1,2}@example.com) + client.succeed( + "msmtp -a test --tls=on --tls-certcheck=off --auth=on multi-alias\@example.com < /etc/root/email5 >&2" + ) + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.succeed("fetchmail --nosslcertck -v") - with subtest("quota"): - client.execute("rm ~/mail/*") - client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc") + with subtest("quota"): + client.execute("rm ~/mail/*") + client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc") - client.succeed( - "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lowquota@example.com < /etc/root/email2 >&2" - ) - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # fetchmail returns EXIT_CODE 0 when it retrieves mail - client.fail("fetchmail --nosslcertck -v") + client.succeed( + "msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lowquota\@example.com < /etc/root/email2 >&2" + ) + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + # fetchmail returns EXIT_CODE 0 when it retrieves mail + client.fail("fetchmail --nosslcertck -v") - with subtest("imap sieve junk trainer"): - # send email from user2 to user1 - client.succeed( - "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email1 >&2" - ) - # give the mail server some time to process the mail - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + with subtest("imap sieve junk trainer"): + # send email from user2 to user1 + client.succeed( + "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email1 >&2" + ) + # give the mail server some time to process the mail + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - client.succeed("imap-mark-spam >&2") - server.wait_until_succeeds("journalctl -u dovecot2 | grep -i rspamd-learn-spam.sh >&2") - client.succeed("imap-mark-ham >&2") - server.wait_until_succeeds("journalctl -u dovecot2 | grep -i rspamd-learn-ham.sh >&2") + client.succeed("imap-mark-spam >&2") + server.wait_until_succeeds("journalctl -u dovecot2 | grep -i sa-learn-spam.sh >&2") + client.succeed("imap-mark-ham >&2") + server.wait_until_succeeds("journalctl -u dovecot2 | grep -i sa-learn-ham.sh >&2") - with subtest("full text search and indexation"): - # send 2 email from user2 to user1 - client.succeed( - "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email6 >&2" - ) - client.succeed( - "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1@example.com < /etc/root/email7 >&2" - ) - # give the mail server some time to process the mail - server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') + with subtest("full text search and indexation"): + # send 2 email from user2 to user1 + client.succeed( + "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email6 >&2" + ) + client.succeed( + "msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email7 >&2" + ) + # give the mail server some time to process the mail + server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') - # should find exactly one email containing this - client.succeed("search INBOX 576a4565b70f5a4c1a0925cabdb587a6 >&2") - # should fail because this folder is not indexed - client.fail("search Junk a >&2") - # check that search really goes through the indexer - server.succeed("journalctl -u dovecot2 | grep 'fts-flatcurve(INBOX): Query ' >&2") - # check that Junk is not indexed - server.fail("journalctl -u dovecot2 | grep 'fts-flatcurve(JUNK): Indexing ' >&2") + # should find exactly one email containing this + client.succeed("search INBOX 576a4565b70f5a4c1a0925cabdb587a6 >&2") + # should fail because this folder is not indexed + client.fail("search Junk a >&2") + # check that search really goes through the indexer + server.succeed( + "journalctl -u dovecot2 | grep -E 'indexer-worker.*Indexed . messages in INBOX' >&2" + ) + # check that Junk is not indexed + server.fail( + "journalctl -u dovecot2 | grep -E 'indexer-worker.*Indexed . messages in Junk' >&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 dovecot2 | grep -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2") - # harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html - server.fail( - "journalctl -u dovecot2 | \ - grep -v 'Expunged message reappeared, giving a new UID' | \ - grep -v 'Time moved forwards' | \ - grep -i warning >&2" - ) - ''; + with subtest("no warnings or errors"): + server.fail("journalctl -u postfix | grep -i error >&2") + server.fail("journalctl -u postfix | grep -i warning >&2") + server.fail("journalctl -u dovecot2 | grep -i error >&2") + # harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html + server.fail( + "journalctl -u dovecot2 |grep -v 'Expunged message reappeared, giving a new UID'| grep -i warning >&2" + ) + ''; } diff --git a/tests/internal.nix b/tests/internal.nix index af552c3..12590c0 100644 --- a/tests/internal.nix +++ b/tests/internal.nix @@ -14,10 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - pkgs, - ... -}: +{ pkgs ? import {}}: let sendMail = pkgs.writeTextFile { @@ -30,80 +27,61 @@ let ''; }; - hashPassword = - password: - pkgs.runCommand "password-${password}-hashed" - { - buildInputs = [ pkgs.mkpasswd ]; - inherit password; - } - '' - mkpasswd -sm bcrypt <<<"$password" > $out - ''; + hashPassword = password: pkgs.runCommand + "password-${password}-hashed" + { buildInputs = [ pkgs.apacheHttpd ]; } '' + htpasswd -nbB "" "${password}" | cut -d: -f2 > $out + ''; hashedPasswordFile = hashPassword "my-password"; passwordFile = pkgs.writeText "password" "my-password"; in -{ +pkgs.nixosTest { name = "internal"; - nodes = { - machine = - { pkgs, ... }: - { - imports = [ - ./../default.nix - ./lib/config.nix - ]; + machine = { config, pkgs, ... }: { + imports = [ + ./../default.nix + ./lib/config.nix + ]; - virtualisation.memorySize = 1024; + virtualisation.memorySize = 1024; - environment.systemPackages = - [ - (pkgs.writeScriptBin "mail-check" '' - ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ - '') - ] - ++ (with pkgs; [ - curl - openssl - netcat - ]); + environment.systemPackages = [ + (pkgs.writeScriptBin "mail-check" '' + ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ + '')]; - mailserver = { - enable = true; - fqdn = "mail.example.com"; - domains = [ - "example.com" - "domain.com" - ]; - localDnsResolver = false; + mailserver = { + enable = true; + fqdn = "mail.example.com"; + domains = [ "example.com" ]; + localDnsResolver = false; - loginAccounts = { - "user1@example.com" = { - hashedPasswordFile = hashedPasswordFile; - }; - "user2@example.com" = { - hashedPasswordFile = hashedPasswordFile; - aliasesRegexp = [ ''/^user2.*@domain\.com$/'' ]; - }; - "send-only@example.com" = { - hashedPasswordFile = hashPassword "send-only"; - sendOnly = true; - }; + loginAccounts = { + "user1@example.com" = { + hashedPasswordFile = hashedPasswordFile; }; - forwards = { - # user2@example.com is a local account and its mails are - # also forwarded to user1@example.com - "user2@example.com" = "user1@example.com"; + "user2@example.com" = { + hashedPasswordFile = hashedPasswordFile; + }; + "send-only@example.com" = { + hashedPasswordFile = hashPassword "send-only"; + sendOnly = true; }; - - vmailGroupName = "vmail"; - vmailUID = 5000; - - enableImap = false; }; + forwards = { + # user2@example.com is a local account and its mails are + # also forwarded to user1@example.com + "user2@example.com" = "user1@example.com"; + }; + + vmailGroupName = "vmail"; + vmailUID = 5000; + + enableImap = false; }; + }; }; testScript = '' machine.start() @@ -148,46 +126,6 @@ in ) ) - with subtest("regex email alias are received"): - # A mail sent to user2-regex-alias@domain.com is in the user2@example.com mailbox - machine.succeed( - " ".join( - [ - "mail-check send-and-read", - "--smtp-port 587", - "--smtp-starttls", - "--smtp-host localhost", - "--imap-host localhost", - "--imap-username user2@example.com", - "--from-addr user1@example.com", - "--to-addr user2-regex-alias@domain.com", - "--src-password-file ${passwordFile}", - "--dst-password-file ${passwordFile}", - "--ignore-dkim-spf", - ] - ) - ) - - with subtest("user can send from regex email alias"): - # A mail sent from user2-regex-alias@domain.com, using user2@example.com credentials is received - machine.succeed( - " ".join( - [ - "mail-check send-and-read", - "--smtp-port 587", - "--smtp-starttls", - "--smtp-host localhost", - "--imap-host localhost", - "--smtp-username user2@example.com", - "--from-addr user2-regex-alias@domain.com", - "--to-addr user1@example.com", - "--src-password-file ${passwordFile}", - "--dst-password-file ${passwordFile}", - "--ignore-dkim-spf", - ] - ) - ) - with subtest("vmail gid is set correctly"): machine.succeed("getent group vmail | grep 5000") @@ -195,22 +133,22 @@ in machine.wait_for_open_port(25) # TODO put this blocking into the systemd units machine.wait_until_succeeds( - "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" + "timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" ) machine.succeed( - "cat ${sendMail} | nc localhost 25 | grep -q '554 5.5.0 Error'" + "cat ${sendMail} | ${pkgs.netcat-gnu}/bin/nc localhost 25 | grep -q 'This account cannot receive emails'" ) with subtest("rspamd controller serves web ui"): machine.succeed( - "set +o pipefail; curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q ''" + "${pkgs.curl}/bin/curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q ''" ) with subtest("imap port 143 is closed and imaps is serving SSL"): machine.wait_for_closed_port(143) machine.wait_for_open_port(993) machine.succeed( - "echo | openssl s_client -connect localhost:993 | grep 'New, TLS'" + "echo | ${pkgs.openssl}/bin/openssl s_client -connect localhost:993 | grep 'New, TLS'" ) ''; } diff --git a/tests/ldap.nix b/tests/ldap.nix deleted file mode 100644 index 1c92572..0000000 --- a/tests/ldap.nix +++ /dev/null @@ -1,221 +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; - - 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 = '' - 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"): - 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"): - machine.succeed(" ".join([ - "mail-check send-and-read", - "--smtp-port 587", - "--smtp-starttls", - "--smtp-host localhost", - "--smtp-username alice@example.com", - "--imap-host localhost", - "--imap-username bob@example.com", - "--from-addr alice@example.com", - "--to-addr bob@example.com", - "--src-password-file <(echo '${alicePassword}')", - "--dst-password-file <(echo '${bobPassword}')", - "--ignore-dkim-spf" - ])) - - with subtest("Test mail forwarding works"): - machine.succeed(" ".join([ - "mail-check send-and-read", - "--smtp-port 587", - "--smtp-starttls", - "--smtp-host localhost", - "--smtp-username alice@example.com", - "--imap-host localhost", - "--imap-username bob@example.com", - "--from-addr alice@example.com", - "--to-addr bob_fw@example.com", - "--src-password-file <(echo '${alicePassword}')", - "--dst-password-file <(echo '${bobPassword}')", - "--ignore-dkim-spf" - ])) - - with subtest("Test cannot send mail from forwarded address"): - machine.fail(" ".join([ - "mail-check send-and-read", - "--smtp-port 587", - "--smtp-starttls", - "--smtp-host localhost", - "--smtp-username bob@example.com", - "--imap-host localhost", - "--imap-username alice@example.com", - "--from-addr bob_fw@example.com", - "--to-addr alice@example.com", - "--src-password-file <(echo '${bobPassword}')", - "--dst-password-file <(echo '${alicePassword}')", - "--ignore-dkim-spf" - ])) - machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user bob@example.com'") - - ''; -} diff --git a/tests/lib/config.nix b/tests/lib/config.nix index fe66875..2cdc9d2 100644 --- a/tests/lib/config.nix +++ b/tests/lib/config.nix @@ -1,4 +1,3 @@ { - # Testing eval failures that result from stateVersion assertion is out of scope - mailserver.stateVersion = 999; + security.dhparams.defaultBitSize = 1024; # minimum size required by dovecot } diff --git a/tests/minimal.nix b/tests/minimal.nix index e78814e..7327f55 100644 --- a/tests/minimal.nix +++ b/tests/minimal.nix @@ -14,17 +14,18 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see -{ - name = "minimal"; +import { - nodes.machine = { - imports = [ - ../default.nix - ./lib/config.nix - ]; - }; + machine = + { config, pkgs, ... }: + { + imports = [ + ./../default.nix + ]; + }; - testScript = '' - machine.wait_for_unit("multi-user.target"); - ''; + testScript = + '' + $machine->waitForUnit("multi-user.target"); + ''; } diff --git a/tests/multiple.nix b/tests/multiple.nix index 2c6d0fc..9f54cff 100644 --- a/tests/multiple.nix +++ b/tests/multiple.nix @@ -1,33 +1,19 @@ # This tests is used to test features requiring several mail domains. -{ - pkgs, - ... -}: +{ pkgs ? import {}}: let - hashPassword = - password: - pkgs.runCommand "password-${password}-hashed" - { - buildInputs = [ pkgs.mkpasswd ]; - inherit password; - } + hashPassword = password: pkgs.runCommand + "password-${password}-hashed" + { buildInputs = [ pkgs.apacheHttpd ]; } '' - mkpasswd -sm bcrypt <<<"$password" > $out + htpasswd -nbB "" "${password}" | cut -d: -f2 > $out ''; - password = pkgs.writeText "password" "password"; + password = pkgs.writeText "password" "password"; - domainGenerator = - domain: - { pkgs, ... }: - { - imports = [ - ../default.nix - ./lib/config.nix - ]; - environment.systemPackages = with pkgs; [ netcat ]; + domainGenerator = domain: { config, pkgs, ... }: { + imports = [../default.nix]; virtualisation.memorySize = 1024; mailserver = { enable = true; @@ -44,47 +30,35 @@ let }; services.dnsmasq = { enable = true; - settings.mx-host = [ - "domain1.com,domain1,10" - "domain2.com,domain2,10" - ]; + extraConfig = '' + mx-host=domain1.com,domain1,10 + mx-host=domain2.com,domain2,10 + ''; }; }; in -{ +pkgs.nixosTest { name = "multiple"; - nodes = { - domain1 = - { ... }: - { - imports = [ - ../default.nix - (domainGenerator "domain1.com") - ]; - mailserver.forwards = { - "non-local@domain1.com" = [ - "user@domain2.com" - "user@domain1.com" - ]; - "non@domain1.com" = [ - "user@domain2.com" - "user@domain1.com" - ]; - }; + domain1 = {...}: { + imports = [ + ../default.nix + (domainGenerator "domain1.com") + ]; + mailserver.forwards = { + "non-local@domain1.com" = ["user@domain2.com" "user@domain1.com"]; + "non@domain1.com" = ["user@domain2.com" "user@domain1.com"]; }; + }; domain2 = domainGenerator "domain2.com"; - client = - { pkgs, ... }: - { - environment.systemPackages = [ - (pkgs.writeScriptBin "mail-check" '' - ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ - '') - ]; - }; + client = { config, pkgs, ... }: { + environment.systemPackages = [ + (pkgs.writeScriptBin "mail-check" '' + ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ + '')]; + }; }; testScript = '' start_all() @@ -94,10 +68,10 @@ in # TODO put this blocking into the systemd units? domain1.wait_until_succeeds( - "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" + "timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" ) domain2.wait_until_succeeds( - "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" + "timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]" ) # user@domain1.com sends a mail to user@domain2.com diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..39d6402 --- /dev/null +++ b/update.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +sed -i -e "s/v[0-9]\+\.[0-9]\+\.[0-9]\+/$1/g" README.md + +HASH=$(nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/v2.3.0/nixos-mailserver-$1.tar.gz" --unpack) + +sed -i -e "s/sha256 = \"[0-9a-z]\{52\}\"/sha256 = \"$HASH\"/g" README.md