1 Commits

Author SHA1 Message Date
Jakub Skokan 4c1ad4797f Allow TLSv1 for compatibility with older devices 2024-06-12 10:14:19 +02:00
85 changed files with 3188 additions and 6798 deletions
-3
View File
@@ -1,3 +0,0 @@
# shellcheck shell=bash
use flake
-12
View File
@@ -1,12 +0,0 @@
# Ignore non-functional treewide changes by configuring
#
# $ git config blame.ignoreRevsFile .git-blame-ignore-revs
#
# or used temporarily with --ignore-revs-file=
#
# nixfmt
1a7f3d718c5a6406b7d5b54f10f5c9c69ed90ef9
# language hints
06cc71c76eb52dc747704a317ac5e175ebdd2ba8
-2
View File
@@ -1,3 +1 @@
result
.direnv
.pre-commit-config.yaml
+8 -13
View File
@@ -1,18 +1,13 @@
.hydra-cli:
image: docker.nix-community.org/nixpkgs/nix-flakes
script:
- nix run --inputs-from ./. nixpkgs#hydra-cli -- -H https://hydra.nix-community.org jobset-wait simple-nixos-mailserver "${jobset}"
hydra-pr:
extends: .hydra-cli
only:
- merge_requests
variables:
jobset: $CI_MERGE_REQUEST_IID
image: nixos/nix
script:
- nix-shell -I nixpkgs=channel:nixos-22.05 -p hydra-cli --run 'hydra-cli -H https://hydra.nix-community.org jobset-wait simple-nixos-mailserver ${CI_MERGE_REQUEST_IID}'
hydra-main:
extends: .hydra-cli
hydra-master:
only:
- main
variables:
jobset: main
- master
image: nixos/nix
script:
- nix-shell -I nixpkgs=channel:nixos-22.05 -p hydra-cli --run 'hydra-cli -H https://hydra.nix-community.org jobset-wait simple-nixos-mailserver master'
+22 -22
View File
@@ -1,24 +1,25 @@
{ nixpkgs, pulls, ... }:
let
pkgs = import nixpkgs { };
pkgs = import nixpkgs {};
prs = builtins.fromJSON (builtins.readFile pulls);
prJobsets = pkgs.lib.mapAttrs (num: info: {
enabled = 1;
hidden = false;
description = "PR ${num}: ${info.title}";
checkinterval = 300;
schedulingshares = 20;
enableemail = false;
emailoverride = "";
keepnr = 1;
type = 1;
flake = "gitlab:simple-nixos-mailserver/nixos-mailserver/merge-requests/${info.iid}/head";
}) prs;
prJobsets = pkgs.lib.mapAttrs (num: info:
{ enabled = 1;
hidden = false;
description = "PR ${num}: ${info.title}";
checkinterval = 30;
schedulingshares = 20;
enableemail = false;
emailoverride = "";
keepnr = 1;
type = 1;
flake = "gitlab:simple-nixos-mailserver/nixos-mailserver/merge-requests/${info.iid}/head";
}
) prs;
mkFlakeJobset = branch: {
description = "Build ${branch} branch of Simple NixOS MailServer";
checkinterval = 300;
checkinterval = "60";
enabled = "1";
schedulingshares = 100;
enableemail = false;
@@ -30,9 +31,9 @@ let
};
desc = prJobsets // {
"main" = mkFlakeJobset "main";
"nixos-26.05" = mkFlakeJobset "nixos-26.05";
"nixos-25.11" = mkFlakeJobset "nixos-25.11";
"master" = mkFlakeJobset "master";
"nixos-23.11" = mkFlakeJobset "nixos-23.11";
"nixos-24.05" = mkFlakeJobset "nixos-24.05";
};
log = {
@@ -40,14 +41,13 @@ let
jobsets = desc;
};
in
{
jobsets = pkgs.runCommand "spec-jobsets.json" { } ''
cat >$out <<'EOF'
in {
jobsets = pkgs.runCommand "spec-jobsets.json" {} ''
cat >$out <<EOF
${builtins.toJSON desc}
EOF
# This is to get nice .jobsets build logs on Hydra
cat >tmp <<'EOF'
cat >tmp <<EOF
${builtins.toJSON log}
EOF
${pkgs.jq}/bin/jq . tmp
+2 -2
View File
@@ -12,12 +12,12 @@
"type": 0,
"inputs": {
"nixexpr": {
"value": "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver main",
"value": "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver master",
"type": "git",
"emailresponsible": false
},
"nixpkgs": {
"value": "https://github.com/NixOS/nixpkgs nixpkgs-unstable",
"value": "https://github.com/NixOS/nixpkgs 0f920b05cbcdb8c0f3c5c4a8ea29f1f0065c7033 ",
"type": "git",
"emailresponsible": false
},
+5 -7
View File
@@ -5,22 +5,20 @@
version: 2
build:
os: ubuntu-24.04
os: ubuntu-22.04
tools:
python: "3"
apt_packages:
- curl
- nix
- proot
jobs:
pre_install:
- curl -L https://github.com/DavHau/nix-portable/releases/latest/download/nix-portable-$(uname -m) > ./nix-portable
- chmod +x ./nix-portable
- ./nix-portable nix build --print-build-logs .#optionsDoc
- ./nix-portable nix store cat $(readlink result) > docs/options.md
- mkdir -p ~/.nix ~/.config/nix
- echo "experimental-features = nix-command flakes" > ~/.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
fail_on_warning: true
formats:
- pdf
-2
View File
@@ -1,2 +0,0 @@
[rstcheck]
ignore_messages = Hyperlink target ".*" is not referenced.
-2
View File
@@ -1,2 +0,0 @@
[default.extend-identifiers]
reportd = "reportd"
+62 -67
View File
@@ -1,78 +1,75 @@
# ![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/main/pipeline.svg)](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/commits/main)
[![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
We publish a branch for each NixOS release. Only matching branch versions are
supported.
For each NixOS release, we publish a branch. You then have to use the
SNM branch corresponding to your NixOS version.
* For NixOS 26.05
* Use the [`nixos-26.05`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-25.11) branch
* [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-26.05/)
* [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-26.05/release-notes.html#nixos-26-05)
* For NixOS 24.05
- Use the [SNM branch `nixos-24.05`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-24.05)
- [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-24.05/)
- [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-24.05/release-notes.html#nixos-24-05)
* For NixOS 23.11
- Use the [SNM branch `nixos-23.11`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-23.11)
- [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-23.11/)
- [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-23.11/release-notes.html#nixos-23-11)
* For NixOS unstable
* Use the [`main`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/main) branch
* [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/)
[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.
## Features
* [x] Continuous 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
* [x] DANE and MTA-STS validation
* [x] SMTP TLS Reports ([RFC 8460](https://www.rfc-editor.org/rfc/rfc8460))
* 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
* [x] Automatic key generation
* [x] Multiple selectors per Domain
* 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
* Improve the Forwarding Experience
* [x] [Sender Rewriting Scheme](https://en.wikipedia.org/wiki/Sender_Rewriting_Scheme)
### 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)
* Improve the Forwarding Experience
* [ ] Support [ARC](https://en.wikipedia.org/wiki/Authenticated_Received_Chain) signing with [Rspamd](https://rspamd.com/doc/modules/arc.html)
* 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 Libera Chat IRC channel `#nixos-mailserver`
## How to Set Up a 10/10 Mail Server Guide
@@ -85,18 +82,16 @@ For a complete list of options, [see in readthedocs](https://nixos-mailserver.re
See the [How to Develop SNM](https://nixos-mailserver.readthedocs.io/en/latest/howto-develop.html) documentation page.
## Contributors
See the [contributor tab](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/graphs/main)
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
+611 -1100
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?= --fail-on-warning
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
+55
View File
@@ -0,0 +1,55 @@
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`.
.. code:: nix
{ config, pkgs, lib, ... }:
with lib;
let
mailAccounts = config.mailserver.loginAccounts;
htpasswd = pkgs.writeText "radicale.users" (concatStrings
(flip mapAttrsToList mailAccounts (mail: user:
mail + ":" + user.hashedPassword + "\n"
))
);
in {
services.radicale = {
enable = true;
settings = {
auth = {
type = "htpasswd";
htpasswd_filename = "${htpasswd}";
htpasswd_encryption = "bcrypt";
};
};
};
services.nginx = {
enable = true;
virtualHosts = {
"cal.example.com" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://localhost:5232/";
extraConfig = ''
proxy_set_header X-Script-Name /;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass_header Authorization;
'';
};
};
};
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
}
+32
View File
@@ -0,0 +1,32 @@
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 ];
}
-23
View File
@@ -1,23 +0,0 @@
Advanced Configurations
=======================
Congratulations on completing the `Setup Guide <setup-guide.html>`_!
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:
- Configure regular, automatic `backups <backup-guide.html>`_.
- Enable `fulltext search <fts.html>`_ to let clients that dont sync all
mail efficiently find messages, by performing searches directly on the server.
- Set up the `Sender Rewriting Scheme`_ if you rely on server-side mail
forwarding to external mail servers using mail forwards or Sieve rules.
- Contribute `DMARC reports`_ and `SMTP TLS reports`_ to help improve email
security across the internet by sending feedback on authentication failures,
spoofing attempts, and TLS encryption issues.
.. _DMARC reports: options.html#mailserver-dmarcreporting
.. _SMTP TLS reports: options.html#mailserver-tlsrpt
.. _Sender Rewriting Scheme: srs.html
+12 -67
View File
@@ -1,73 +1,18 @@
Autodiscovery
=============
`RFC6186`_ defines how email clients can automatically discover a mail server's
SMTP and IMAP endpoints. To enable this, the following DNS records must be
configured:
`RFC6186 <https://www.rfc-editor.org/rfc/rfc6186>`_ allows supporting email clients to automatically discover SMTP / IMAP addresses
of the mailserver. For that, the following records are required:
.. csv-table:: Resource record set
:header: "Name", "TTL", "Type", "Priority", "Weight", "Port", "Value"
:widths: 30, 5, 5, 5, 5, 5, 20
================= ==== ==== ======== ====== ==== =================
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.
================= ==== ==== ======== ====== ==== =================
_submissions._tcp.example.com., 3600, SRV, 10, 1, 465, mail.example.com.
_imaps._tcp.example.com., 3600, SRV, 10, 1, 993, mail.example.com.
Please note that only a few MUAs currently implement this. For vendor-specific
discovery mechanisms `automx <https://github.com/rseichter/automx2>`_ can be used instead.
Legacy records
^^^^^^^^^^^^^^
The following DNS records are only supported with
:option:`mailserver.enableSubmission` and :option:`mailserver.enableImap`,
because they only support connections with explicit TLS. These services are
disabled by default because they are deprecated through `RFC8314 4.1`_.
.. csv-table:: Resource record set
:header: "Name", "TTL", "Type", "Priority", "Weight", "Port", "Value"
:widths: 30, 5, 5, 5, 5, 5, 20
_submission._tcp.example.com., 3600, SRV, 20, 1, 587, mail.example.com.
_imap._tcp.example.com., 3600, SRV, 20, 1, 143, mail.example.com.
Client support
^^^^^^^^^^^^^^
*As researched in March 2026*
Only a small number of MUAs currently implement this. The most common concern
from the bigger and security-conscious vendors is lack of widespread DNSSEC
propagation that could be used to authenticate these SRV records.
- Aerc: since 0.20.1
- ``_submissions._tcp`` support submitted in https://lists.sr.ht/~rjarry/aerc-devel/patches/68173
- Evolution: Since 3.49.3 for mail accounts
- https://gitlab.gnome.org/GNOME/evolution/-/wikis/Autoconfig
- https://gitlab.gnome.org/GNOME/evolution/-/issues/941
Unsupported
***********
- DeltaChat:
- https://github.com/chatmail/core/issues/1508
- Thunderbird:
- Desktop: https://bugzilla.mozilla.org/show_bug.cgi?id=342242
- Android: https://github.com/thunderbird/thunderbird-android/issues/4721
Vendor-specific autoconfig
^^^^^^^^^^^^^^^^^^^^^^^^^^
The `automx2`_ service can provide autoconfig support for Apple's
`mobileconfig`_, Microsoft's `Autodiscover`_ and Mozilla's `Autoconfig`_
standards. It does however lack support for multiple mail domains and isn't open for
contributions due to copyright concerns.
.. _mobileconfig: https://support.apple.com/de-de/guide/profile-manager/pmdbd71ebc9/mac
.. _Autodiscover: https://learn.microsoft.com/en-us/exchange/architecture/client-access/autodiscover?view=exchserver-2019
.. _Autoconfig: https://benbucksch.github.io/autoconfig-spec/draft-ietf-mailmaint-autoconfig.html
.. _automx2: https://github.com/rseichter/automx2
.. _RFC6186: https://www.rfc-editor.org/rfc/rfc6186
.. _RFC8314 4.1: https://www.rfc-editor.org/rfc/rfc8314#section-4.1
+12 -13
View File
@@ -5,17 +5,16 @@ First off you should have a backup of your ``configuration.nix`` file
where you have the server config (but that is already in a git
repository right?)
Next you need to backup ``/var/vmail`` or whatever you have specified for the
option :option:`mailserver.storage.path`. This is where all the mails reside.
Good options are a cron job with ``rsync`` or ``scp``. But really anything
works, as it is simply a folder with plenty of files in it. If your backup
solution does not preserve the owner of the files dont forget to ``chown`` them
to ``virtualMail:virtualMail`` if you copy them back (or whatever you specified
as :option:`mailserver.storage.owner`, and :option:`mailserver.storage.group`).
Next you need to backup ``/var/vmail`` or whatever you have specified
for the option ``mailDirectory``. This is where all the mails reside.
Good options are a cron job with ``rsync`` or ``scp``. But really
anything works, as it is simply a folder with plenty of files in it. If
your backup solution does not preserve the owner of the files dont
forget to ``chown`` them to ``virtualMail:virtualMail`` if you copy them
back (or whatever you specified as ``vmailUserName``, and
``vmailGoupName``).
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 :option:`mailserver.dkim.keyDirectory`). If you should lose those
dont worry, new ones will be created on the fly. But you will need to update
the DKIM TXT records to reflect the new key material.
Finally you can (optionally) make a backup of ``/var/dkim`` (or whatever
you specified as ``dkimKeyDirectory``). If you should lose those dont
worry, new ones will be created on the fly. But you will need to repeat
step ``B)5`` and correct all the ``dkim`` keys.
+13 -11
View File
@@ -17,41 +17,43 @@
# -- Project information -----------------------------------------------------
project = "NixOS Mailserver"
copyright = "2022, NixOS Mailserver Contributors"
author = "NixOS Mailserver Contributors"
version = "26.05"
project = 'NixOS Mailserver'
copyright = '2022, NixOS Mailserver Contributors'
author = 'NixOS Mailserver Contributors'
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = ["myst_parser"]
extensions = [
'myst_parser'
]
myst_enable_extensions = [
"colon_fence",
"linkify",
'colon_fence',
'linkify',
]
smartquotes = False
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
templates_path = ['_templates']
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
master_doc = "index"
master_doc = 'index'
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = "sphinx_rtd_theme"
html_theme = 'sphinx_rtd_theme'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
-205
View File
@@ -1,205 +0,0 @@
.. _dkim:
DKIM Signing
============
DKIM (DomainKeys Identified Mail) is an email authentication mechanism that
allows a mailserver to digitally sign outgoing emails for a domain. Receiving
mail servers can verify this signature using a public key published in DNS to
confirm the message was authorized by the domain and was not modified during
transit.
How DKIM works in practice
~~~~~~~~~~~~~~~~~~~~~~~~~~
1. ``bob@bar.example`` sends an email to ``alice@foo.example``. The sending
mail server for ``bar.example`` selects one or multiple DKIM keys using a
**selector** (e.g., ``mail``) and creates one or multiple cryptographic
signature for selected headers and the message body, adding a ``DKIM-Signature``
header that references the selector.
2. The message arrives at ``foo.example``. The receiving mail server reads the
``DKIM-Signature`` headers, looks up the public keys for ``bar.example`` for
each of the specified selectors (e.g., ``mail._domainkey.bar.example``), and
verifies that at least one signature matches the message content.
3. With a valid signature, the receiver knows the message was authorized by
``bar.example`` and that the signed headers and body were not modified in
transit. If the content or signed headers were changed, the DKIM verification
fails. The use of selectors allows ``bar.example`` to rotate or migrate keys
without disrupting verification for previously sent messages.
Enabling DKIM Signing
~~~~~~~~~~~~~~~~~~~~~
Because DKIM signing is crucial for reliable mail delivery it is enabled by
default and without further configuration a DKIM keypair will be generated for
each :option:`mailserver.domains` (including :option:`mailserver.srs.domain`,
if set) based on :option:`mailserver.dkim.defaults
<mailserver.dkim.defaults.keyLength>`.
.. code:: nix
{
mailserver = {
domains = [ "example.com" ];
dkim.enable = true; # enabled by default
};
}
.. note::
If you set up NixOS Mailserver before the `25.11 release`_ your DKIM keys were
generated with 1024 bit RSA and we recommend replacing them with 2048 bit
RSA key material per `RFC8301 3.2`_.
.. _25.11 release: release-notes.html#nixos-25-11
.. _RFC8301 3.2: https://www.rfc-editor.org/rfc/rfc8301#section-3.2
.. _dkim-key-rotation:
DKIM Key Rotation
~~~~~~~~~~~~~~~~~
DKIM key rotation replaces a domain's signing keys to maintain
strong email authentication and support algorithm upgrades. Rotation is
essential for migrating away from weaker or deprecated algorithms.
Selectors allow multiple keys to coexist during the transition: a new key can
be deployed under a different selector while the old key remains valid for a
limited period to verify messages still in transit. Once all messages signed
with the old key have been delivered, the key can be safely retired, ensuring a
reliable migration without breaking verification.
1. Make the automatically generated key explicit
************************************************
First we need to make sure we keep the current DKIM key configured. If you were
relying on automatically generated keys before, you now need to start explicitly
defining that key, because explicit selector configuration takes precedence.
.. code:: nix
{
mailserver = {
domains = [ "example.com" ];
dkim.domains = {
"example.com".selectors = {
"${config.mailserver.dkim.defaults.selector}" = { };
};
};
};
}
2. Create the new DKIM keypair
******************************
Next we need to create a new DKIM key with a unique selector, you can
for example choose the current date. Without any settings passed a new
key will be generated from the current :option:`mailserver.dkim.defaults
<mailserver.dkim.defaults.keyLength>`, which should be sufficient.
.. code:: nix
{
mailserver = {
domains = [ "example.com" ];
dkim = {
enable = true;
domains."example.com".selectors = {
"${config.mailserver.dkim.defaults.selector}" = { };
"rsa-2026-03" = {
keyType = "rsa";
keyLength = 2048;
};
};
};
};
}
.. warning::
While DKIM does support Ed25519 keys (`RFC8463`_), many validators still lack
proper support and may treat Ed25519 key material as invalid. As a result,
mail signed only with Ed25519 DKIM keys may fail verification at some
receivers.
.. _RFC8463: https://datatracker.ietf.org/doc/html/rfc8463
Once this configuration is applied the new keypair will be generated below
:option:`mailserver.dkim.keyDirectory`, which defaults to ``/var/dkim``. The
mailserver then begins signing outgoing mail with this key, so that it is now
signing with two DKIM keys simultaneously.
To allow receiving servers to verify the new DKIM signature its
public key needs to be published into DNS. Look up the public key from
``/var/dkim/example.com.rsa-2026-03.txt`` and create the following DNS record by
substituting selector and public key.
.. csv-table::
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
rsa-2026-03._domainkey.example.com., 86400, TXT, v=DKIM1; k=rsa; p=<public key>
.. note::
If you created an Ed25519 key, make sure to set the correct key type: ``k=ed25519``
Now wait for a few minutes and then check DNS propagation to show the value specified.
.. code-block:: console
$ nix-shell -p dig --command "dig @ns1.example.org TXT rsa-2026-03._domainkey.example.com"
You can use https://www.mail-tester.com to test the new DKIM signature passes
validation. They allow you to view the message source where you can check that
the correct number of ``DKIM-Signature`` keys are present in the mail header.
3. Stop signing with the old DKIM keypair
*****************************************
Once validation passes we need to stop signing with the old DKIM keypair, so
mail in transit eventually stops depending on the key material we want to rotate
out. Removing the selector will not remove the key material from disk, but it
will stop using it to sign outgoing mail.
.. code:: nix
{
mailserver = {
domains = [ "example.com" ];
dkim = {
enable = true;
domains."example.com".selectors = {
"rsa-2026-03" = {
keyType = "rsa";
keyLength = 2048;
};
};
}
}
}
Apply the configuration.
4. Remove the old DKIM selector from DNS
****************************************
.. warning::
Do not remove the DNS records for the old selector immediately. Keeping them
in place is essential to ensure that messages still in transit can be verified
and delivered successfully.
Mail delivery is not always instantaneous. In the worst case, multiple retries
over several days may be required. According to `RFC5321 4.5.4.1`_ delivery
should be retried for at least 4-5 days.
This means messages signed only with the old DKIM key could still be in transit
and rely on the old selector to verify their signatures. To ensure reliable
delivery, we recommend waiting **at least five days** before removing the old
DKIM selector from DNS.
.. _RFC5321 4.5.4.1: https://www.rfc-editor.org/rfc/rfc5321#section-4.5.4.1
+1 -1
View File
@@ -13,7 +13,7 @@ domain ``example.com`` and send mails with any address of this domain:
.. code:: nix
mailserver.accounts = {
mailserver.loginAccounts = {
"user@example.com" = {
aliases = [ "@example.com" ];
};
-35
View File
@@ -1,35 +0,0 @@
{
description = "NixOS configuration";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05-small";
simple-nixos-mailserver.url = "gitlab:simple-nixos-mailserver/nixos-mailserver/nixos-26.05";
simple-nixos-mailserver.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =
{
nixpkgs,
simple-nixos-mailserver,
...
}:
{
nixosConfigurations = {
hostname = nixpkgs.lib.nixosSystem {
system = "x86_64-linux"; # or aarch64-linux
modules = [
simple-nixos-mailserver.nixosModule
{
mailserver = {
enable = true;
# Check the setup guide if you have no idea how to continue
# from here!
};
}
];
};
};
};
}
+25 -13
View File
@@ -1,18 +1,30 @@
Flakes
======
Nix Flakes
==========
To use NixOS mailserver `Nix flakes`_, the following minimal ``flake.nix`` can
serve as an example to get started:
If you're using `flakes <https://nixos.wiki/wiki/Flakes>`__, you can use
the following minimal ``flake.nix`` as an example:
.. _Nix flakes: https://wiki.nixos.org/wiki/Flakes
.. code:: nix
.. literalinclude:: ./flakes.nix
:language: nix
{
description = "NixOS configuration";
inputs.simple-nixos-mailserver.url = "gitlab:simple-nixos-mailserver/nixos-mailserver/nixos-20.09";
Lock the inputs and deploy the system closure:
.. code-block:: console
nix flake lock
nixos-rebuild --target-host root@mail.example.com --flake .#hostname switch
outputs = { self, nixpkgs, simple-nixos-mailserver }: {
nixosConfigurations = {
hostname = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
simple-nixos-mailserver.nixosModule
{
mailserver = {
enable = true;
# ...
};
}
];
};
};
};
}
+26 -26
View File
@@ -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 the ``fts_flatcurve`` dovecot plugin.
*index* emails with a plugin to dovecot, ``fts_xapian``.
Enabling full text search
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -20,50 +20,50 @@ To enable indexing for full text search here is an example configuration.
enable = true;
# index new email as they arrive
autoIndex = true;
# only query index
fallback = false;
# this only applies to plain text attachments, binary attachments are never indexed
indexAttachments = true;
enforced = "body";
};
};
}
Disabling the :option:`mailserver.fullTextSearch.fallback` option tells dovecot
to fail any body search query that cannot use an index. This prevents Dovecot to
fall back to the IO-intensive brute force search.
The ``enforced`` parameter tells dovecot to fail any body search query that cannot
use an index. This prevents dovecot to fall back to the IO-intensive brute
force search.
If you set :option:`mailserver.fullTextSearch.autoIndex` to ``false``, indices
will be created when the IMAP client issues a search query, so latency will
be high.
If you set ``autoIndex`` to ``false``, indices will be created when the IMAP client
issues a search query, so latency will be high.
Resource requirements
~~~~~~~~~~~~~~~~~~~~~~~~
Indices created by the full text search feature can take more disk space than
the emails themselves. By default, they are kept within the maildir. When
enabling the full text search feature, it is recommended to move indices in a
different location, such as (``/var/lib/dovecot/indices``) by configuring
:option:`mailserver.indexDir`.
Indices created by the full text search feature can take more disk
space than the emails themselves. By default, they are kept in the
emails location. When enabling the full text search feature, it is
recommended to move indices in a different location, such as
(``/var/lib/dovecot/indices``) by using the option
``mailserver.indexDir``.
.. warning::
When the value of the :option:`mailserver.indexDir` option is changed, all
dovecot indices needs to be recreated: clients would need to resynchronize.
When the value of the ``indexDir`` option is changed, all dovecot
indices needs to be recreated: clients would need to resynchronize.
Indexation itself is rather resource intensive, in CPU, and for emails with
Indexation itself is rather resouces intensive, in CPU, and for emails with
large headers, in memory as well. Initial indexation of existing emails can take
hours. If the indexer worker is killed or segfaults during indexation, it can be
that it tried to allocate more memory than allowed. You can increase the default
memory limit through :option:`mailserver.fullTextSearch.memoryLimit`.
hours. If the indexer worker is killed or segfaults during indexation, it can
be that it tried to allocate more memory than allowed. You can increase the memory
limit by eg ``mailserver.fullTextSearch.memoryLimit = 2000`` (in MiB).
Mitigating resources requirements
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can:
* exclude some headers from indexation with :option:`mailserver.fullTextSearch.headerExcludes`
* disable expensive token normalisation in :option:`mailserver.fullTextSearch.filters`
* disable automatic indexation for individual mailboxes by overriding
`fts_autoindex`_ on the mailbox level. This is exposed via
:option:`mailserver.mailboxes`, where all default mailboxes are defined.
* 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.
.. _fts_autoindex: https://doc.dovecot.org/main/core/plugins/fts.html#fts_autoindex
+22 -57
View File
@@ -4,33 +4,13 @@ Contribute or troubleshoot
To report an issue, please go to
`<https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues>`_.
If you have questions, feel free to reach out:
* Matrix: `#nixos-mailserver:nixos.org <https://matrix.to/#/#nixos-mailserver:nixos.org>`__
* IRC: `#nixos-mailserver <ircs://irc.libera.chat/nixos-mailserver>`__ on `Libera Chat <https://libera.chat/guides/connect>`__
All our workflows rely on Nix being configured with `Flakes <https://wiki.nixos.org/wiki/Flakes#Installing_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 <https://direnv.net/>`__ to automatically
attach to the development environment when entering the project directories.
You can also chat with us on the Libera IRC channel ``#nixos-mailserver``.
Run NixOS tests
---------------
To run the test suite, you need to enable `Nix Flakes
<https://wiki.nixos.org/wiki/Flakes#Installing_flakes>`__.
<https://nixos.wiki/wiki/Flakes#Installing_flakes>`_.
You can then run the testsuite via
@@ -38,8 +18,8 @@ You can then run the testsuite via
$ nix flake check -L
Since Nix doesn't guarantee your machine have enough resources to run
all test VMs in parallel, some tests can fail. You would then have to
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:
::
@@ -57,7 +37,7 @@ For the syntax, see the `RST/Sphinx primer
<https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html>`_.
To build the documentation, you need to enable `Nix Flakes
<https://wiki.nixos.org/wiki/Flakes#Installing_flakes>`__.
<https://nixos.wiki/wiki/Flakes#Installing_flakes>`_.
::
@@ -65,43 +45,28 @@ To build the documentation, you need to enable `Nix Flakes
$ nix build .#documentation
$ xdg-open result/index.html
Nixops
------
Manual migrations
-----------------
You can test the setup via ``nixops``. After installation, do
We need to take great care around providing a migration story around breaking
changes. If manual intervention becomes necessary we provide the `stateVersion`
option to notify the user that they need to complete a migration before
they can deploy an update.
::
If that is the case for your change, find the highest `stateVersion` that is
being asserted on in `mail-server/assertions.nix`. Then pick the next number
and add a new assertion, write a good summary describing the issue and what
remediation steps are necessary. Finally reference the URL to the specific
section on the migration page in the documentation.
$ nixops create nixops/single-server.nix nixops/vbox.nix -d mail
$ nixops deploy -d mail
$ nixops info -d mail
.. code-block:: nix
You can then test the server via e.g. \ ``telnet``. To log into it, use
{
assertions = [
{
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 1;
message = ''
Problem: The home directory for the foobar service is snafu.
Remediation:
- Stop the `foobar.service`
- Rename `/var/lib/foobaz` to `/var/lib/foobar`
- Increase the `mailserver.stateVersion` to 1.
::
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#specific-anchor-here for further details.
'';
}
];
}
$ nixops ssh -d mail mailserver
The setup guide should always reference the latest `stateVersion`, since we
don't require any migration steps for new setups.
Imap
----
The migration documentation should paint a more complete picture about the steps
that need to be carried out and why this has become necessary. Make sure to
reference the correct anchor in the URL you put into the assertion message.
To test imap manually use
::
$ openssl s_client -host mail.example.com -port 143 -starttls imap
+6 -25
View File
@@ -14,42 +14,23 @@ Welcome to NixOS Mailserver's documentation!
:maxdepth: 2
setup-guide
advanced-configurations
howto-develop
faq
release-notes
options
migrations
.. toctree::
:maxdepth: 1
:caption: Account backends
ldap
.. toctree::
:maxdepth: 1
:caption: Features
dkim
fts
srs
.. toctree::
:maxdepth: 0
:caption: How-to
autodiscovery
backup-guide
flakes
add-radicale
add-roundcube
rspamd-tuning
.. toctree::
:maxdepth: 0
:caption: Integrations
radicale
roundcube
fts
flakes
autodiscovery
ldap
Indices and tables
==================
-12
View File
@@ -1,12 +0,0 @@
{
mailserver = {
ldap = {
attributes = {
uuid = "entryUUID";
username = "uid";
password = "userPassword";
mail = "mail";
};
};
};
}
-17
View File
@@ -1,17 +0,0 @@
{
mailserver = {
ldap = {
enable = true;
uris = [
"ldaps://ldap1.example.com"
"ldaps://ldap2.example.com"
];
bind = {
dn = "cn=mail,dc=example=dc=com";
passwordFile = "/run/keys/ldap-bind-pw";
};
base = "ou=users,dc=example,dc=com";
scope = "one";
};
};
}
+10 -92
View File
@@ -1,96 +1,14 @@
.. _ldap-top:
LDAP Support
============
LDAP
====
It is possible to manage mail user accounts with LDAP rather than with
the option `loginAccounts <options.html#mailserver-loginaccounts>`_.
LDAP (Lightweight Directory Access Protocol) is a protocol for accessing and
managing a centralized directory of user and group information. It can be used
to authenticate users and provide a single source of truth for email accounts
and aliases across mail services.
All related LDAP options are described in the `LDAP options section
<options.html#mailserver-ldap>`_ and the `LDAP test
<https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/tests/ldap.nix>`_
provides a getting started example.
.. note::
The LDAP support can not be enabled if some accounts are also defined with ``mailserver.loginAccounts``.
Requirements
~~~~~~~~~~~~
To enable the LDAP integration the following requirements must be fulfilled:
- Existing LDAP service (we currently only test against OpenLDAP)
- Bind credentials against LDAP with permissions to
- search for the acceptable set of users
- read the :option:`mailserver.ldap.attributes.password` attribute
- Each user entry must provide attributes that can serve as
- :option:`mailserver.ldap.attributes.mail` (primary mail address)
- :option:`mailserver.ldap.attributes.username` (login name)
- :option:`mailserver.ldap.attributes.password` (login password)
- :option:`mailserver.ldap.attributes.uuid` (stable identifier)
Features
~~~~~~~~
We currently have a basic feature set covering user accounts only and try to
follow best practices to simplify maintenance.
- Users authenticate with the username and password attribute
- Maildir storage paths are constructed using the uuid attribute
- Primary mail address read from mail attribute
Limitations
~~~~~~~~~~~
Design choices
^^^^^^^^^^^^^^
These are intentional choices in how the mail server operates that affect the
LDAP integration.
- For mail address routing local accounts always take priority over LDAP accounts.
Planned
^^^^^^^
These are features we are interested in but require implementation,
documentation and tests.
- Aliases based on LDAP attributes
- Quotas based on LDAP attributes
Avoided
^^^^^^^
The following features will likely never be implemented, since they would
complicate the setup significantly.
- Domains based on LDAP entries (would require integration with everything we
already do for :option:`mailserver.domains`)
- Use of ``homeDirectory``, ``uid``, ``gid`` LDAP attributes (we are
committed to a virtual setup with one vmail user/uid/gid and UUID based home
directories)
- Declarative aliases through :option:`mailserver.aliases`. These are limited
to local accounts, because Postfix enforces sender ownership based on login
identity and does not consult virtual aliases for authorization.
Enabling LDAP support
~~~~~~~~~~~~~~~~~~~~~
Enable the LDAP integration by configuring an authenticated LDAP connection
and how to locate all users. The bind DN must be allowed to read the configured
password attribute, which may require additional configuration
.. literalinclude:: ./ldap-basic.nix
:language: nix
We provide sensible defaults for each attribute, that can be adapted to your
local setup.
.. literalinclude:: ./ldap-attrs.nix
:language: nix
Refer to our `LDAP test`_ for an complete example, and see the `LDAP options`_ section for all possible settings.
.. _LDAP options: options.html#mailserver-ldap
.. _LDAP test: https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/main/tests/ldap.nix
-299
View File
@@ -1,299 +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 :option:`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 26.05
-----------
.. _migration-5:
#5 Sieve script directory migration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Sieve scripts managed by users via ManageSieve were previously stored in
``/var/sieve`` (or via the now-removed option
``mailserver.sieveDirectory``). This setup partially mirrored the mail
directory structure in ``/var/vmail`` (``mailserver.storage.path``),
which proved to be fragile and error-prone.
Thanks to a `prior migration`_, we can now migrate these directories into each
users home directory, aligning with upstream recommendations — almost
nine years later.
.. _prior migration: #dovecot-mail-directory-migration
This migration is only required if you have :option:`mailserver.enableManageSieve` enabled.
1. If you are coming from ``25.11`` and are using LDAP, temporarily disable
:option:`mailserver.enableManageSieve` by setting it to ``false``, deploy,
and only then continue with this migration.
If you are not coming from ``25.11`` or are not using LDAP, continue with
this migration as is.
2. Copy the migration script to your mailserver and make it executable:
.. code-block:: console
cd /tmp
wcurl https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/main/migrations/nixos-mailserver-migration-05.py
chmod +x nixos-mailserver-migration-05.py
3. Stop the ``postfix.service``.
.. code-block:: bash
systemctl stop postfix.service
4. Create a backup or snapshot of your ``mailserver.sieveDirectory``, so
you can restore should anything go wrong.
5. Run the migration script and pass your ``mailserver.sieveDirectory`` as argument:
The script should be run under the user who owns the ``mailserver.sieveDirectory``.
If run as root it will automatically switch into the appropriate user by itself.
The script will not modify your data unless called with ``--execute``.
The migration script finds all Sieve script directories in
``/var/sieve/`` (or any other ``mailserver.sieveDirectory``), for
example that of ``bob`` at ``/var/vmail/bob@example.com``.
It then takes ``bob@example.com`` and looks up the home directory
for ``bob``. Finally, it starts suggesting the necessary move
operations to migrate the Sieve directory to
``/var/vmail/example.com/bob/sieve`` and symlinks the active script
to ``/var/vmail/example.com/bob/.dovecot.sieve``.
Example:
.. code-block:: bash
./nixos-mailserver-migration-05.py \
/var/sieve
6. Review the script output.
The script can highlight various inconsistencies and problems, that should
be reviewed and acted upon.
If in doubt, join our community chat for help before applying any changes.
7. Rerun the command with ``--execute`` or run the proposed commands manually.
8. Update the ``mailserver.stateVersion`` to ``5``.
9. The previous Sieve directory (``mailserver.sieveDirectory``) should now be safe to delete.
10. If you temporarily disabled :option:`mailserver.enableManageSieve` in step 1,
re-enable it now by setting it back to ``true``.
.. _migration-4:
#4 Dovecot LDAP UUID-based home directories
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
LDAP Support in NixOS mailserver was introduced during the 23.11 release cycle
and came with a number of flaws that we are correcting now, three years later.
This particular migration is needed because up until now we were
relying on email addresses to construct the Dovecot home directory path
(``var/vmail/ldap/user@example.com``) which is fragile: addresses can
change, requiring manual homedir relocation. Switching to UUID-based homedirs
(``/var/vmail/ldap/<uuid>``) ensures stable, unique paths and applies well-known
best practices to mailserver management.
1. Copy the migration script to your mailserver and make it executable:
.. code-block:: console
cd /tmp
wcurl https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/main/migrations/nixos-mailserver-migration-04.py
chmod +x nixos-mailserver-migration-04.py
2. Stop the ``dovecot.service``.
.. code-block:: bash
systemctl stop dovecot.service
3. Create a backup or snapshot of your :option:`mailserver.storage.path`, so
you can restore should anything go wrong.
4. Run the migration script and pass the required arguments to enable LDAP lookups:
The script should be run under the user who owns the :option:`mailserver.storage.path`.
If run as root it will automatically switch into the appropriate user by itself.
The script will not modify your data unless called with ``--execute``.
The migration script finds all Dovecot home directories in
``/var/vmail/ldap/`` (or any other :option:`mailserver.storage.path`),
for example that of bob at ``/var/vmail/ldap/bob@example.com``.
It then takes ``bob@example.com`` and queries the LDAP server for
``mail=bob@example.com`` to retrieve the UUID attribute. Finally
it starts suggesting the necessary move operations to arrive at
``/var/vmail/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092`` for bob.
Example:
.. code-block:: bash
./nixos-mailserver-migration-04.py \
--ldap-uri ldaps://ldap1.example.com
--ldap-bind-dn cn=mail,ou=accounts,dc=example,dc=com \
--ldap-bind-pw-file /run/keys/ldap-bind-pw \
--ldap-base ou=people,ou=accounts,dc=example,dc=com \
--ldap-scope sub \
--ldap-filter "(mail=%s)" \
--ldap-attr-uuid entryUUID \
/var/vmail
For the ``--ldap-attr-uuid`` parameter we expect a long-term stable
identifier, ideally a UUID field. The exact attribute name depends on your
LDAP implementation, for example:
- Authentik: ``uid`` `[1]`_
- Kanidm: ``uuid`` `[2]`_
- Keycloak ``entryUUID``
- OpenLDAP: ``entryUUID`` (`RFC4530`_)
If your LDAP provider isn't listed you can determine the correct
attribute by querying a user entry with ``ldapsearch``. Finally, configure
:option:`mailserver.ldap.attributes.uuid` accordingly.
Add ``--ldap-starttls`` if you use the the `ldap://` URI scheme and require
explicit TLS.
.. _[1]: https://docs.goauthentik.io/add-secure-apps/providers/ldap#users
.. _[2]: https://kanidm.github.io/kanidm/stable/integrations/ldap.html#data-mapping
.. _RFC4530: https://www.rfc-editor.org/rfc/rfc4530.html
5. Review the script output.
It's primary job is to determine the UUID for an LDAP account, so that it
can rename the Dovecot home directory from mail address to UUID within the
same directory.
The script can highlight various inconsistencies and problems, that should
be reviewed and acted upon.
If in doubt, join our community chat for help before applying any changes.
6. Rerun the command with ``--execute`` or run the proposed commands manually.
7. Update the ``mailserver.stateVersion`` to ``4``.
NixOS 25.11
-----------
#3 Dovecot mail directory migration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The way the Dovecot home directory for login accounts were previously set up
resulted in shared home directories for all those users. This is not a
supported Dovecot configuration.
To resolve this we migrated the home directory into the individual
`domain/localpart` subdirectory below the `mailserver.mailDirectory`.
But since this now overlaps with the location of the Maildir, it must be
migrated into the `mail/` directory below the home directory.
And while the LDAP home directory is not affected we use this migration to
keep the Maildir configurations of LDAP users in sync with those of local
accounts.
This is a big step forward, since we can now more cleanly colocate other
data directories, like sieve in the home directory, which in turn simplifies
backups.
This migration is required for every configuration.
For remediating this issue the following steps are required:
1. Copy the `migration script <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/main/migrations/nixos-mailserver-migration-03.py>`_ script to your mailserver
and make it executable:
.. code-block:: bash
wcurl https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/raw/main/migrations/nixos-mailserver-migration-03.py
chmod +x nixos-mailserver-migration-03.py
2. Stop the ``dovecot.service``.
.. code-block:: bash
systemctl stop dovecot.service
3. Create a backup or snapshot of your ``mailserver.mailDirectory``, so you can restore
should anything go wrong.
4. Run the migration script under your virtual mail user with the following arguments:
- ``--layout default`` unless ``useFSLayout`` is enabled, then ``--layout folder``
- The value of ``mailserver.mailDirectory``, which defaults to ``/var/vmail``
The script should be run under the user who owns the ``mailDirectory``.
If run as root it will try to switch into the appropriate user by itself.
The script will not modify your data unless called with ``--execute``.
Example:
.. code-block:: bash
./nixos-mailserver-migration-03.py --layout default /var/vmail
5. Review the commands. They should be
- create a ``mail`` directory for each account,
- move maildir contents from the parent directory into it,
- suggest removal of files that do not belong to the maildir
- their removal is not mandatory and the script **will not** remove them when called with ``--execute``
- review these items carefully if you want to remove them yourself
- remove obsolete files from the old home directory location
6. Rerun the command with ``--execute`` or run the commands manually.
7. Update the ``mailserver.stateVersion`` to ``3``.
#2 Dovecot LDAP home directory migration
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The Dovecot configuration for LDAP home directories previously did not respect
the ``mailserver.mailDirectory`` setting.
This means that home directories were unconditionally located at
``/var/vmail/ldap/%{user}``.
This migration is required if you both:
* enabled the LDAP integration (``mailserver.ldap.enable``)
* and customized the default mail directory (``mailserver.mailDirectory != "/var/vmail"``)
For remediating this issue the following steps are required:
1. Stop ``dovecot.service``.
2. Move ``/var/vmail/ldap`` below your ``mailserver.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;
-55
View File
@@ -1,55 +0,0 @@
{
config,
pkgs,
lib,
...
}:
let
inherit (lib)
concatStrings
flip
mapAttrsToList
;
mailAccounts = config.mailserver.accounts;
htpasswd = pkgs.writeText "radicale.users" (
concatStrings (flip mapAttrsToList mailAccounts (mail: user: "${mail}+:${user.hashedPassword}\n"))
);
in
{
services.radicale = {
enable = true;
settings = {
auth = {
type = "htpasswd";
htpasswd_filename = "${htpasswd}";
htpasswd_encryption = "bcrypt";
};
};
};
services.nginx = {
enable = true;
virtualHosts = {
"cal.example.com" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://localhost:5232/";
extraConfig = ''
proxy_set_header X-Script-Name /;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass_header Authorization;
'';
};
};
};
};
networking.firewall.allowedTCPPorts = [
80
443
];
}
-29
View File
@@ -1,29 +0,0 @@
Radicale
========
Radicale is a lightweight open-source CalDAV/CardDAV server that stores
calendars and contacts as plain files on the filesystem, enabling simple
self-hosted synchronization with standard clients.
Limitations
^^^^^^^^^^^
Radicale since the 3.x release (introduced in NixOS 20.09) does not support
traditional crypt() password hashes any longer. To establish access for
existing :option:`mailserver.accounts`, the hashing method used
for ``hashedPassword`` needs to be compatible with one of the available
`htpasswd_encryption`_ methods. Such hashes can for example be created using
.. code-block:: console
nix-shell -p mkpasswd --command "mkpasswd -m bcrypt"
.. _htpasswd_encryption: https://radicale.org/v3.html#htpasswd_encryption
Code
^^^^
Configuration contributed by Robert Schütz (@dotlambda).
.. literalinclude:: ./radicale.nix
:language: nix
+1 -168
View File
@@ -1,174 +1,6 @@
Release Notes
=============
NixOS 26.05
-----------
Features
^^^^^^^^
- :ref:`DKIM key management <dkim>` now supports multiple selectors per domain,
enabling :ref:`key rotation <dkim-key-rotation>`. Pre-created key material is
also supported. Existing automatically generated DKIM keys from before 25.11
use 1024-bit RSA and should be rotated. See :option:`mailserver.dkim.domains`.
- Certificate handling was simplified. We recommend using the NixOS
ACME module (``security.acme.certs``) and referencing a certificate
configuration by name. Alternatively, certificate and private key can be
managed manually. Configure either :option:`mailserver.x509.useACMEHost`
or :option:`mailserver.x509.certificateFile` and
:option:`mailserver.x509.privateKeyFile`. See the updated :ref:`setup guide
<setup-guide>` for a basic ACME HTTP-01 example.
- Local mail accounts can now use managed cleartext passwords. This integrates
well with secret management tools such as `agenix`_ and `sops-nix`_ while
avoiding password leakage into the world-readable Nix store. See
:option:`mailserver.accounts.<name>.passwordFile`.
- Blocked sender responses can now be customized. This is useful if you require GDPR
compliance. See :option:`mailserver.rejectSenderMessage`.
Security
^^^^^^^^
- TLSv1.2 cipher suites in Postfix now require `AEAD`_ and `ECDHE`_.
- Postfix and Dovecot now support negotiation of the ``SecP256r1MLKEM768``
key agreement mechanism. The `standardization process
<https://datatracker.ietf.org/doc/draft-ietf-tls-ecdhe-mlkem/>`__ is ongoing.
- Deprecated and obsolete TLS signature algorithms were removed from Postfix.
Sieve
^^^^^
- **Migration**: When ManageSieve is enabled, user-created Sieve scripts must
be migrated into their Dovecot home directory. See the :ref:`migration guide
<migration-5>`.
LDAP
^^^^
- **Migration**: Dovecot home directories for LDAP users must be migrated to
UUID-based directory names. The UUID attribute can be customized through
:option:`mailserver.ldap.attributes.uuid`. See the :ref:`migration guide
<migration-4>`.
- The LDAP configuration has been revamped. Option names have been simplified,
examples and documentation improved. The :ref:`LDAP documentation <ldap-top>`
was written from the ground up.
- The default LDAP login attribute changed from ``mail`` to ``uid``.
This allows users to login with their account name rather than
their email address, which is more convenient and consistent with
typical LDAP practices. The exact attribute can be customized through
:option:`mailserver.ldap.attributes.username`.
- The LDAP bind password is now read verbatim without trimming whitespace. Any
trailing newline is now preserved and may cause authentication failures.
- Local and LDAP accounts can now coexist. For overlapping accounts and addresses
the local account will always win.
Internals
^^^^^^^^^
- Dovecot has been updated from 2.3 to 2.4 and now relies on the structured settings option.
Deprecations
^^^^^^^^^^^^
The following integrations are deprecated and will be removed before the next
release:
- :option:`mailserver.borgbackup.enable`
- :option:`mailserver.backup.enable`
- :option:`mailserver.monitoring.enable`
.. _key rotation: dkim.html#dkim-key-rotation
.. _agenix: https://github.com/ryantm/agenix
.. _sops-nix: https://github.com/Mic92/sops-nix
.. _AEAD: https://en.wikipedia.org/wiki/Authenticated_encryption
.. _ECDHE: https://www.rfc-editor.org/rfc/rfc8422
NixOS 25.11
-----------
- The ``systemName`` and ``systemDomain`` options have been introduced to have
reusable configurations for automated reports (DMARC, TLSRPT). They come with
reasonable defaults, but it is suggested to check and change them as needed.
- Support for the `Sender Rewriting Scheme`_ has been added, which allows
forwarding mail without breaking SPF by rewriting the envelope address.
- The default key length for new DKIM RSA keys was increased to 2048 bits as
recommended in `RFC 8301 3.2`_.
We recommend rotating existing keys, as the RFC advises that signatures from
1024 bit keys should not be considered valid any longer.
- IMAP access over port ``143/tcp`` is now default disabled in line
with `RFC 8314 4.1`_. Use IMAP over implicit TLS on port ``993/tcp``
instead. If you still require this feature you can re-enable it using
``mailserver.enableImap``, but it is scheduled for removal after the 25.11
release.
- SMTP server and client now support and prefer a hybrid key exchange
(X25519MLKEM768)
- SMTP access over STARTTLS on port ``587/tcp`` is now default disabled in line
with `RFC 8314 3.3`_. If you still require this feature you can re-enable it
using ``mailserver.enableSubmission``.
- DMARC reports are now sent with the ``noreply-dmarc`` localpart from the
system domain.
- DANE and MTA-STS are now validated for outgoing SMTP connections using
`postfix-tlspol`_.
- SMTP TLS connection reports (`RFC 8460`_) are now supported using
`tlsrpt-reporter`_. They can be enabled with the ``mailserver.tlsrpt.enable``
option.
.. _Sender Rewriting Scheme: srs.html
.. _RFC 8301 3.2: https://www.rfc-editor.org/rfc/rfc8301#section-3.2
.. _RFC 8314 3.3: https://www.rfc-editor.org/rfc/rfc8314#section-3.3
.. _RFC 8314 4.1: https://www.rfc-editor.org/rfc/rfc8314#section-4.1
.. _RFC 8460: https://www.rfc-editor.org/rfc/rfc8460
.. _postfix-tlspol: https://github.com/Zuplu/postfix-tlspol
.. _tlsrpt-reporter: https://github.com/sys4/tlsrpt-reporter
NixOS 25.05
-----------
- OpenDKIM has been removed and DKIM signing is now handled by Rspamd, which only supports ``relaxed`` canoncalizaliaton.
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/374>`__)
- Rspamd now connects to Redis over its Unix Domain Socket by default
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/375>`__)
- 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 <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/380>`__)
- Switch to the more efficient `fts-flatcurve` indexer for full text search
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/361>`__).
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 <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/297>`__)
- Configuring ``mailserver.forwards`` is now possible when the setup relies on LDAP.
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/313>`__)
- Support for TLS 1.1 was disabled in accordance with `Mozilla's recommendations <https://ssl-config.mozilla.org/#server=postfix>`_.
(`merge request <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/merge_requests/234>`__)
NixOS 24.11
-----------
- No new feature, only bug fixes and documentation improvements
NixOS 24.05
-----------
@@ -213,6 +45,7 @@ NixOS 21.11
- New option ``certificateDomains`` to generate certificate for
additional domains (such as ``imap.example.com``)
NixOS 21.05
-----------
-1
View File
@@ -2,4 +2,3 @@ sphinx ~= 5.3
sphinx_rtd_theme ~= 1.1
myst-parser ~= 0.18
linkify-it-py ~= 2.0
standard-imghdr
-17
View File
@@ -1,17 +0,0 @@
{ config, ... }:
{
services.nginx.virtualHosts.${config.services.roundcube.hostName} = {
forceSSL = false;
enableACME = false;
listen = [
{
addr = "127.0.0.1";
port = 8000;
}
];
};
services.caddy.virtualHosts."${config.services.roundcube.hostName}".extraConfig = ''
reverse_proxy localhost:8000
'';
}
-49
View File
@@ -1,49 +0,0 @@
{
config,
pkgs,
...
}:
{
services.roundcube = {
enable = true;
hostName = "webmail.example.com"; # the nginx vhost
package = pkgs.roundcube.withPlugins (
plugins: with plugins; [
# external plugins to be included
# https://search.nixos.org/packages?query=roundcubePlugins
persistent_login
]
);
# activate plugins
plugins = [
"persistent_login"
"managesieve" # built-in
];
dicts = with pkgs.aspellDicts; [
# https://search.nixos.org/packages?query=aspellDicts
en
];
maxAttachmentSize = config.mailserver.messageSizeLimit / 1024 / 1024;
extraConfig = ''
$config['imap_host'] = "ssl://${config.mailserver.fqdn}";
$config['smtp_host'] = "ssl://${config.mailserver.fqdn}";
$config['smtp_user'] = "%u";
$config['smtp_pass'] = "%p";
$config['managesieve_host'] = "tls://${config.mailserver.fqdn}";
$config['managesieve_port'] = 4190;
$config['managesieve_usetls'] = true;
'';
};
services.nginx.virtualHosts.${config.services.roundcube.hostName} = {
enableACME = true;
forceSSL = true;
};
networking.firewall.allowedTCPPorts = [
80
443
];
}
-26
View File
@@ -1,26 +0,0 @@
Roundcube
=========
Roundcube is a browser-based open-source webmail client that provides a
full-featured email interface with support for IMAP, SMTP, address books, and
extensible plugins.
Code
^^^^
The NixOS module for Roundcube integrates almost immediately with NixOS
mailserver, automatically configuring an Nginx virtual host and ACME-managed
TLS for secure webmail access; using other web servers may require additional
manual setup.
Once set up you can login with your login account credentials.
.. literalinclude:: ./roundcube.nix
:language: nix
To use a different reverse proxy, such as Caddy, bind Roundcube's Nginx virtual
host to ``127.0.0.1`` on a custom port and disable SSL and ACME, as the reverse
proxy will handle those.
.. literalinclude:: ./roundcube-caddy.nix
:language: nix
+10 -8
View File
@@ -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
~~~~~~~~~~~~~~~~~~
@@ -44,10 +47,9 @@ details the meaning of each symbol. You can tune the weight if a symbol if neede
services.rspamd.locals = {
"groups.conf".text = ''
symbols "FORGED_RECIPIENTS" {
weight = 0;
}
'';
symbols {
"FORGED_RECIPIENTS" { weight = 0; }
}'';
};
Tune action thresholds
@@ -94,7 +96,7 @@ With an nginx reverse-proxy
If you have a secured nginx reverse proxy set on the host, you can use it to expose the socket.
**Keep in mind the UI is unsecured by default, you need to setup an authentication scheme**, for
example with `basic auth <https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/>`_:
exemple with `basic auth <https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/>`_:
.. code:: nix
-58
View File
@@ -1,58 +0,0 @@
{
config,
...
}:
{
imports = [
(builtins.fetchTarball {
# This is a quick and dirty way to import a NixOS mailserver release. What
# you should do long-term is use a proper dependency pinning tool like npins
# or flakes.
# URL to the tarball for the release matching your NixOS release
url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/nixos-26.05/nixos-mailserver-nixos-26.05.tar.gz";
# Hash of the unpacked tarball, run the following command to retrieve it
# release="nixos-26.05" nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/${release}/nixos-mailserver-${release}.tar.gz" --unpack
sha256 = "0000000000000000000000000000000000000000000000000000";
})
];
# https://letsencrypt.org/repository/#let-s-encrypt-subscriber-agreement
security.acme.acceptTerms = true;
# Allow incoming HTTP connections
networking.firewall.allowedTCPPorts = [ 80 ];
# Enable ACME HTTP-01 challenge with nginx
services.nginx = {
enable = true;
virtualHosts.${config.mailserver.fqdn}.enableACME = true;
};
mailserver = {
enable = true;
stateVersion = 5;
fqdn = "mail.example.com";
domains = [ "example.com" ];
# Reference the existing ACME configuration created by nginx
x509.useACMEHost = config.mailserver.fqdn;
# A list of all login accounts. To create the password hashes, use
# nix-shell -p mkpasswd --run 'mkpasswd -s'
accounts = {
"user1@example.com" = {
# Reads the password hash from a file on the server
hashedPasswordFile = "/a/file/containing/a/hashed/password";
# Additional addresses delivered to this mailbox
aliases = [ "postmaster@example.com" ];
};
"user2@example.com" = {
# Provides the password hash inline
hashedPassword = "$y$j9T$JqqefR6flaaJBRjD4KVZc1$QM6h4Spr5.yn/FuIT.ydTV22daEbiVd8ZprV/POtPgB";
};
};
};
}
+150 -235
View File
@@ -1,5 +1,3 @@
.. _setup-guide:
Setup Guide
===========
@@ -7,306 +5,223 @@ Mail servers can be a tricky thing to set up. This guide is supposed to
run you through the most important steps to achieve a 10/10 score on
`<https://mail-tester.com>`_.
Requirements
~~~~~~~~~~~~
What you need is:
To set up a self-hosted mail server, you need the following:
* Small (e.g. 1C/2G) server running NixOS
* Stable IPv4 and - strongly recommended - IPv6 addresses
* Ability to configure a Reverse DNS (PTR record) for your IP addresses
* Access to SMTP traffic on port 25/tcp - some hosters make you ask for this
* A registered domain name with DNS record management access
Once these requirements are in place, you can begin setting up your selfhosted
mailserver.
- a server running NixOS with a public IP
- a domain name.
.. note::
Below we'll assume that your server got assigned the public IP addresses
``192.0.2.1`` (IPv4) and ``2001:db8::1`` (IPv6) and that you control the
``example.com`` domain.
In the following, we consider a server with the public IP ``1.2.3.4``
and the domain ``example.com``.
Configure forward DNS records
First, we will set the minimum DNS configuration to be able to deploy
an up and running mail server. Once the server is deployed, we could
then set all DNS entries required to send and receive mails on this
server.
Setup DNS A record for server
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Here we set up ``mail.example.com`` as the forward hostname for your mail server
to point to the IP addresses allocated to the server. This allows reaching
the server under this name and to reference it later in MX records for mail
delivery.
Add a DNS record to the domain ``example.com`` with the following
entries
Now edit the ``example.com`` zone and create the following DNS records:
==================== ===== ==== =============
Name (Subdomain) TTL Type Value
==================== ===== ==== =============
``mail.example.com`` 10800 A ``1.2.3.4``
==================== ===== ==== =============
.. csv-table::
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
You can check this with
mail.example.com., 3600, A, 192.0.2.1
mail.example.com., 3600, AAAA, 2001:db8::1
::
.. note::
If your server does not have an IPv6 address, you must skip the ``AAAA``
record.
$ ping mail.example.com
64 bytes from mail.example.com (1.2.3.4): icmp_seq=1 ttl=46 time=21.3 ms
...
Verify DNS record propagation
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Before we continue with the next step, we require that the forward DNS record
has propagated. For that it's best to check an authoritative nameserver for
``example.com`` so that we don't look at cached DNS records.
.. code-block:: console
# Find the authoritative nameservers for example.com
$ nix-shell -p dig --command "dig NS example.com +short"
ns1.example.org.
ns2.example.org.
# Query the A record from an authoritative nameserver
$ nix-shell -p dig --command "dig @ns1.example.org A mail.example.com +short"
192.0.2.1
# Query the AAAA record from an authoritative nameserver
$ nix-shell -p dig --command "dig @ns1.example.org AAAA mail.example.com +short"
2001:db8::1
DNS propagation usually takes a few minutes, so you might need to retry these
queries. Once the IP addresses appear you can continue with the next step.
Note that it can take a while until a DNS entry is propagated. This
DNS entry is required for the Let's Encrypt certificate generation
(which is used in the below configuration example).
Setup the server
~~~~~~~~~~~~~~~~
The following configuration describes a fairly complete mail server, capable
of sending and receiving mail for statically configured accounts. It includes
encrypted SMTP and IMAP services for secure delivery and retrieval, and relies
on ACME HTTP-01 to automatically obtain and maintain a TLS certificate.
The following describes a server setup that is fairly complete. Even
though there are more possible options (see the `NixOS Mailserver
options documentation <options.html>`_), these should be the most
common ones.
While `more options`_ are available, the configuration below covers the most
common settings to get your mail server up and running.
.. code:: nix
.. _more options: options.html
{ 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-23.05/nixos-mailserver-nixos-23.05.tar.gz";
# To get the sha256 of the nixos-mailserver tarball, we can use the nix-prefetch-url command:
# release="nixos-23.05"; nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/${release}/nixos-mailserver-${release}.tar.gz" --unpack
sha256 = "0000000000000000000000000000000000000000000000000000";
})
];
.. literalinclude:: ./setup-example.nix
:language: nix
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [ "example.com" ];
After a ``nixos-rebuild switch`` your server should be running all the necessary
mail services.
# A list of all login accounts. To create the password hashes, use
# nix-shell -p mkpasswd --run 'mkpasswd -sm bcrypt'
loginAccounts = {
"user1@example.com" = {
hashedPasswordFile = "/a/file/containing/a/hashed/password";
aliases = ["postmaster@example.com"];
};
"user2@example.com" = { ... };
};
Configure DNS records
~~~~~~~~~~~~~~~~~~~~~
# Use Let's Encrypt certificates. Note that this needs to set up a stripped
# down nginx and opens port 80.
certificateScheme = "acme-nginx";
};
security.acme.acceptTerms = true;
security.acme.defaults.email = "security@example.com";
}
Reverse DNS
^^^^^^^^^^^
After a ``nixos-rebuild switch`` your server should be running all
mail components.
Earlier, we configured forward DNS from your hostname to your IP address. Now we
will configure reverse DNS so that your IP address points back to your hostname.
Setup all other DNS requirements
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If your forward and reverse DNS do not match, many mail servers will reject or
flag your emails as spam, severely impairing delivery.
Set rDNS (reverse DNS) entry for server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Your server provider should allow you to configure reverse DNS (PTR record)
records for the IP addresses you control, typically through their control panel
or account management interface:
- Configure ``192.0.2.1`` to point to ``mail.example.com.``
- Configure ``2001:db8::1`` to point to ``mail.example.com.``, if you have IPv6
addressing
Alternatively, if you interact with a proper DNS setup, the actual DNS records
look like this:
.. csv-table::
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
1.2.0.192.in-addr.arpa., 86400, PTR, mail.example.com.
1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa., 86400, PTR, mail.example.com.
.. note::
Reverse DNS uses reverse notation for naming its records:
* IPv4 reverses the order of the octets and appends ``in-addr.arpa.``, so
``192.0.2.1`` becomes ``1.2.0.192.in-addr.arpa.``
* IPv6 fully expands the address and reverses each hex digit before
concatenating it with dots and appending ``ip6.arpa.``
.. code-block:: console
nix-shell -p haskellPackages.ip6addr --command "ip6addr --ptr 2001:db8::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.8.b.d.0.1.0.0.2.IP6.ARPA.
Wherever you have rented your server, you should be able to set reverse
DNS entries for the IPs you own. Add an entry resolving ``1.2.3.4``
to ``mail.example.com``.
.. warning::
We don't recommend setting up a mail server if you are unable to configure
reverse DNS on your public IP addresses because mails would inevitable be
marked as spam. Note that many residential ISP providers don't allow you to
set a reverse DNS entry and prohibit sending mail through policy blocklists
like Spamhaus PBL.
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 propagation often isn't instant, so verify before continuing:
You can check this with
.. code-block:: console
::
$ nix-shell -p dig --command "dig -x 192.0.2.1 +short"
mail.example.com.
$ 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 dig --command "dig -x 2001:db8::1 +short"
mail.example.com.
Note that it can take a while until a DNS entry is propagated.
Set a ``MX`` record
^^^^^^^^^^^^^^^^^^^
MX record
^^^^^^^^^
Add a ``MX`` record to the domain ``example.com``.
The MX record instructs other mailservers where to deliver mail for a domain
name.
================ ==== ======== =================
Name (Subdomain) Type Priority Value
================ ==== ======== =================
example.com MX 10 mail.example.com
================ ==== ======== =================
Create the MX record for ``example.com`` to point to the hostname of the server.
You can check this with
.. csv-table::
:header: "Name", "TTL", "Priority", "Type", "Value"
:widths: 30, 10, 10, 10, 50
::
example.com., 3600, MX, 10, mail.example.com.
$ nix-shell -p bind --command "host -t mx example.com"
example.com mail is handled by 10 mail.example.com.
The priority field determines the order when multiple servers are configured.
It is not important in this scenario but setting a value is mandatory and 10
leaves some wiggle room below and above, should you ever need that.
Note that it can take a while until a DNS entry is propagated.
.. code-block:: console
Set a ``SPF`` record
^^^^^^^^^^^^^^^^^^^^
$ nix-shell -p dig --command "dig @ns1.example.org MX example.com +short"
10 mail.example.com.
Add a `SPF <https://en.wikipedia.org/wiki/Sender_Policy_Framework>`_
record to the domain ``example.com``.
SPF record
^^^^^^^^^^
================ ===== ==== ================================
Name (Subdomain) TTL Type Value
================ ===== ==== ================================
example.com 10800 TXT `v=spf1 a:mail.example.com -all`
================ ===== ==== ================================
With `SPF`_ we can specify which mail servers are authorized to send mail on
behalf of a domain name.
You can check this with
.. _SPF: https://en.wikipedia.org/wiki/Sender_Policy_Framework
::
The SPF record is TXT record and we can tie it in with the MX record we created
in the previous step. Finishing with ``-all`` indicates that without any match
the mail should be rejected.
$ nix-shell -p bind --command "host -t TXT example.com"
example.com descriptive text "v=spf1 a:mail.example.com -all"
.. csv-table::
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
Note that it can take a while until a DNS entry is propagated.
example.com., 86400, TXT, v=spf1 mx -all
Set ``DKIM`` signature
^^^^^^^^^^^^^^^^^^^^^^
.. code-block:: console
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
$ nix-shell -p dig --command "dig TXT example.com +short"
v=spf1 mx -all
::
mail._domainkey IN TXT "v=DKIM1; k=rsa; s=email; p=<really-long-key>" ; ----- DKIM mail for domain.tld
DKIM record
^^^^^^^^^^^
where ``really-long-key`` is your public key.
On system activation a `DKIM`_ keypair for ``example.com`` was generated. The
mail server uses this key to sign outgoing emails, allowing receiving servers to
verify the authenticity of the sender domain and ensuring that the signed parts
of the message have not been tampered with.
Based on the content of this file, we can add a ``DKIM`` record to the
domain ``example.com``.
.. _DKIM: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail
=========================== ===== ==== ==============================
Name (Subdomain) TTL Type Value
=========================== ===== ==== ==============================
mail._domainkey.example.com 10800 TXT ``v=DKIM1; p=<really-long-key>``
=========================== ===== ==== ==============================
Now, check ``/var/dkim/example.com.mail.txt``, which contains the proposed DNS
record for the ``mail`` DKIM selector.
You can check this with
.. code-block:: none
::
mail._domainkey IN TXT ( "v=DKIM1; k=rsa; "
"p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv7hSess/UgEjaaq/NDn5KtW2iZzYljhf45DH3tN/kqcJ04JJk/Z1rS7CMJQ/pYZSSnQOju0H25uOtODvhqXPDxDdtCyDSrx54z/38lGNtA76/iWy/ikjb9hEkb2k3HuKex3P4KhhOC1pytDEFnh/T2aBxPNOigc/cpqm1U9RbnAwvArtx9dgOAgiV8rOIgPgyrPw1B3cJG3hgFYU2"
"GwXMoiFQPgwm7bkjelmThqXozA7jFJfnYt49jjrIYCv8X/nQx9cNpVAv2852mhU/3uuy6sa4MPjT6RiK9BJCMyDnqSpTPCjIubL4VhGCuzp7RPBkayWnlaH0X8PWGq6BQ0eBwIDAQAB"
) ;
$ nix-shell -p bind --command "host -t txt mail._domainkey.example.com"
mail._domainkey.example.com descriptive text "v=DKIM1;p=<really-long-key>"
Based on the content of this file, we can create the DKIM TXT record for the
``mail`` selector in the ``example.com`` zone. For the ``p=`` value, glue the
two long strings back together without any quotes and spaces and put them into
your record below.
Note that it can take a while until a DNS entry is propagated.
.. csv-table::
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
Set a ``DMARC`` record
^^^^^^^^^^^^^^^^^^^^^^
mail._domainkey.example.com., 86400, TXT, v=DKIM1; k=rsa; p=MIIBIjANBgk...Q0eBwIDAQAB
Add a ``DMARC`` record to the domain ``example.com``.
.. code-block:: console
======================== ===== ==== ====================
Name (Subdomain) TTL Type Value
======================== ===== ==== ====================
_dmarc.example.com 10800 TXT ``v=DMARC1; p=none``
======================== ===== ==== ====================
$ nix-shell -p dig --command "dig @ns1.example.org TXT mail._domainkey.example.com +short"
"v=DKIM1; k=rsa; p=MIIBIjANBgk...Q0eBwIDAQAB"
You can check this with
::
DMARC record
^^^^^^^^^^^^
$ nix-shell -p bind --command "host -t TXT _dmarc.example.com"
_dmarc.example.com descriptive text "v=DMARC1; p=none"
Finally, DMARC lets you define a policy for how strictly SPF and DKIM should be
checked and how to handle validation failures. For a new server, its important
to have a DMARC record in place, even if it doesnt enforce any actions yet,
because it improves deliverability by showing receiving servers that your domain
is properly managed and reducing the risk of email spoofing.
.. csv-table::
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
_dmarc.example.com., 86400, TXT, v=DMARC1; p=none;
Verify propagation one final time.
.. code-block:: console
$ nix-shell -p dig --command "dig @ns1.example.org TXT _dmarc.example.com +short"
"v=DMARC1; p=none"
Note that it can take a while until a DNS entry is propagated.
Test your Setup
~~~~~~~~~~~~~~~
Write an email to your aunt — shes been waiting far too long for your reply,
and this is your chance to finally make her day. Or, if you prefer a less
emotional test, send a message to `mail-tester.com`_ to see how your outgoing
mail scores.
Write an email to your aunt (who has been waiting for your reply far too
long), and sign up for some of the finest newsletters the Internet has.
Maybe you want to sign up for the `SNM Announcement
List <https://www.freelists.org/list/snm>`__?
You can also let `MXToolbox`_ take a peek at your setup. If you followed the
steps carefully, everything should be working perfectly!
.. _mail-tester.com: https://mail-tester.com/
.. _MXToolbox: https://mxtoolbox.com/
Join the community
~~~~~~~~~~~~~~~~~~
The community has a lively chat room on Matrix at `#nixos-mailserver:nixos.org`_
where you can ask questions, get help, share ideas, or discuss contributions.
.. _#nixos-mailserver:nixos.org: https://matrix.to/#/#nixos-mailserver:nixos.org
Next steps
~~~~~~~~~~
Your server scored perfect results already, so these steps are entirely
optional.
Are you feeling adventurous? Dive into our `advanced configurations`_ to explore
additional features and capabilities that let you fine-tune and extend your
mail setup.
If you want to take things even further, more elaborate testing services can
give you a clearer picture of your mail service and suggest ways to improve
it.
- `internet.nl`_ (supported by the Dutch Government)
- `MECSA`_ (supported by the European Commission)
Finally, you can also browse the full list of `options`_ provided by NixOS mailserver.
.. _advanced configurations: advanced-configurations.html
.. _options: options.html
.. _internet.nl: https://internet.nl/test-mail/
.. _MECSA: https://mecsa.jrc.ec.europa.eu/
Besides that, you can send an email to
`mail-tester.com <https://www.mail-tester.com/>`__ and see how you
score, and let `mxtoolbox.com <http://mxtoolbox.com/>`__ take a look at
your setup, but if you followed the steps closely then everything should
be awesome!
-102
View File
@@ -1,102 +0,0 @@
Sender Rewriting Scheme
=======================
The Sender Rewriting Scheme (SRS) allows mail servers to forward emails without
breaking SPF checks. By rewriting the envelope sender to an address within the
forwarders domain, SRS ensures that forwarded messages pass SPF validation,
preventing them from being rejected as spoofed or unauthorized.
How SRS works in practice
~~~~~~~~~~~~~~~~~~~~~~~~~
1. ``alice@foo.example`` receives an E-Mail from ``bob@bar.example``. Both the
envelope sender as well as the ``From`` header show ``bob@bar.example``. This
results in strict SPF alignment, because ``bar.example`` is the domain used in
both the ``Return-Path`` and ``FROM`` headers.
2. ``alice@foo.example`` forwards the mail to ``charlie@moo.example`` and
uses SRS to rewrite the envelope sender to originate from the local SRS domain
(e.g. `SRS0=HHH=TT=bar.example=alice@foo.example`). The ``FROM`` header remains
unchanged. This ensures that the forwarded mail succeeds SPF checks.
3. The email reaches ``charlie@moo.example``. SPF passes because the sender
domain in the envelope has been rewritten. The mismatch between envelope sender
domain and ``FROM`` domain does however break strict SPF alignment.
Enabling SRS
~~~~~~~~~~~~
In a simple setup just enabling SRS will use your ``mailserver.systemDomain``
when rewriting the envelope sender domain.
.. code:: nix
{
mailserver = {
srs = {
enable = true;
#domain = "srs.example.com";
};
};
};
..
While you can reuse an existing email domain for SRS, it is recommended to
configure a dedicated SRS domain. This is particularly important under the
following conditions:
* Multiple unrelated mail domains are hosted on the mailserver
* The mail domain requires strict SPF alignment in its DMARC policy
Required DNS changes
~~~~~~~~~~~~~~~~~~~~
.. note::
In the following example we assume that you want to set up a dedicated SRS
domain. If that is not the case you already have SPF and DKIM set up for the
system domain. If you have a DMARC record on the system domain, make sure it
uses a relaxed SPF alignment policy (``aspf=r``).
First we set up an MX record. This is so that we can receive and route bounces
that can result from forwards.
======================== ===== ==== ======== =====================
Name (Subdomain) TTL Type Priority Value
======================== ===== ==== ======== =====================
srs.example.com 10800 MX 10 ``mail.example.com``
======================== ===== ==== ==============================
Next up is the SPF record on the SRS domain to allow SPF authentication.
======================== ===== ==== ===================
Name (Subdomain) TTL Type Value
======================== ===== ==== ===================
srs.example.com 10800 TXT ``v=spf1 mx -all``
======================== ===== ==== ===================
Then we deploy the DKIM record with the `p=<value>` taken from
``/var/dkim/srs.example.com.mail.txt``, that appears after deploying with SRS
enabled.
=============================== ===== ==== ========================================
Name (Subdomain) TTL Type Value
=============================== ===== ==== ========================================
mail._domainkey.srs.example.com 10800 TXT ``v=DKIM1; k=rsa; p=<really-long-key>``
=============================== ===== ==== ========================================
Finally we can tie this together in the DMARC record to require receivers to
verify the requested SPF/DKIM alignment.
.. note::
The SRS domain can only support relaxed SPF alignment due to the envelope
sender and ``FROM`` header mismatch.
======================== ===== ==== =========================================
Name (Subdomain) TTL Type Value
======================== ===== ==== =========================================
_dmarc.srs.example.com 10800 TXT ``v=DMARC1; p=reject; aspf=r; adkim=s;``
======================== ===== ==== =========================================
We can safely configure a ``reject`` policy on the SRS domain, to enforce the
SPF and DKIM alignment as configured above.
Generated
+59 -56
View File
@@ -19,86 +19,89 @@
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "NixOS",
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": [
"flake-compat"
],
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1778507602,
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
"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": 1779622335,
"narHash": "sha256-ViA62qtL5za7V3d5I8OA9q9JcFhsVAiL5jVHwEclWqk=",
"lastModified": 1717602782,
"narHash": "sha256-pL9jeus5QpX5R+9rsp3hhZ+uplVHscNJh8n8VpqscM0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "705e9929918b43bd7b715dc0a878ac870449bb03",
"rev": "e8057b67ebf307f01bdcc8fba94d94f75039d1f6",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-unstable",
"type": "indirect"
}
},
"nixpkgs-24_05": {
"locked": {
"lastModified": 1717144377,
"narHash": "sha256-F/TKWETwB5RaR8owkPPi+SPJh83AQsm6KrQAlJ8v/uA=",
"owner": "NixOS",
"ref": "nixos-26.05-small",
"repo": "nixpkgs",
"rev": "805a384895c696f802a9bf5bf4720f37385df547",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-24.05",
"type": "indirect"
}
},
"root": {
"inputs": {
"blobs": "blobs",
"flake-compat": "flake-compat",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs"
"nixpkgs": "nixpkgs",
"nixpkgs-24_05": "nixpkgs-24_05",
"utils": "utils"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1709126324,
"narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "d465f4819400de7c8d874d50b982301f28a84605",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
}
},
+107 -201
View File
@@ -3,220 +3,126 @@
inputs = {
flake-compat = {
# for shell.nix compat
url = "github:NixOS/flake-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-26.05-small";
utils.url = "github:numtide/flake-utils";
nixpkgs.url = "flake:nixpkgs/nixos-unstable";
nixpkgs-24_05.url = "flake:nixpkgs/nixos-24.05";
blobs = {
url = "gitlab:simple-nixos-mailserver/blobs";
flake = false;
};
};
outputs =
{
self,
blobs,
git-hooks,
nixpkgs,
...
}:
let
lib = nixpkgs.lib;
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
releases = [
{
name = "unstable";
nixpkgs = nixpkgs;
pkgs = nixpkgs.legacyPackages.${system};
}
];
testNames = [
"clamav"
"external"
"internal"
"ldap"
"multiple"
];
genTest =
testName: release:
let
pkgs = release.pkgs;
nixos-lib = import (release.nixpkgs + "/nixos/lib") {
inherit (pkgs) lib;
};
in
{
name = "${testName}-${builtins.replaceStrings [ "." ] [ "_" ] release.name}";
value = nixos-lib.runTest {
hostPkgs = pkgs;
imports = [ ./tests/${testName}.nix ];
_module.args = { inherit blobs; };
extraBaseModules.imports = [ ./default.nix ];
};
};
# Generate an attribute set such as
# {
# external-unstable = <derivation>;
# external-21_05 = <derivation>;
# ...
# }
allTests = lib.listToAttrs (lib.flatten (map (t: map (r: genTest t r) releases) testNames));
mailserverModule = import ./.;
# Generate a MarkDown file describing the options of the NixOS mailserver module
optionsDoc =
let
eval = lib.evalModules {
modules = [
mailserverModule
{
_module.check = false;
mailserver = {
fqdn = "mx.example.com";
systemDomain = "example.com";
domains = [
"example.com"
];
};
}
];
};
options = builtins.toFile "options.json" (
builtins.toJSON (
lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver") (
lib.optionAttrSetToDocList eval.options
)
)
);
in
pkgs.runCommand "options.md" { buildInputs = [ pkgs.python3Minimal ]; } ''
echo "Generating options.md from ${options}"
python ${./scripts/generate-options.py} ${options} > $out
echo $out
'';
documentation = pkgs.stdenv.mkDerivation {
name = "documentation";
src = lib.sourceByRegex ./docs [
"logo\\.png"
"conf\\.py"
"Makefile"
".*\\.nix"
".*\\.rst"
];
buildInputs = [
(pkgs.python3.withPackages (
p: with p; [
sphinx
sphinx-rtd-theme
myst-parser
linkify-it-py
]
))
];
buildPhase = ''
cp ${optionsDoc} options.md
# Workaround for https://github.com/sphinx-doc/sphinx/issues/3451
unset SOURCE_DATE_EPOCH
make html
'';
installPhase = ''
cp -Tr _build/html $out
'';
outputs = { self, utils, blobs, nixpkgs, nixpkgs-24_05, ... }: let
lib = nixpkgs.lib;
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
releases = [
{
name = "unstable";
pkgs = nixpkgs.legacyPackages.${system};
}
{
name = "24.05";
pkgs = nixpkgs-24_05.legacyPackages.${system};
}
];
testNames = [
"internal"
"external"
"clamav"
"multiple"
"ldap"
];
genTest = testName: release: {
"name"= "${testName}-${builtins.replaceStrings ["."] ["_"] release.name}";
"value"= import (./tests/. + "/${testName}.nix") {
pkgs = release.pkgs;
inherit blobs;
};
};
# Generate an attribute set such as
# {
# external-unstable = <derivation>;
# external-21_05 = <derivation>;
# ...
# }
allTests = lib.listToAttrs (
lib.flatten (map (t: map (r: genTest t r) releases) testNames));
in
{
nixosModules = rec {
mailserver = mailserverModule;
default = mailserver;
};
nixosModule = self.nixosModules.default; # compatibility
hydraJobs.${system} = allTests // {
inherit documentation;
inherit (self.checks.${system}) pre-commit;
};
checks.${system} = allTests // {
pre-commit = git-hooks.lib.${system}.run {
src = ./.;
package = pkgs.prek;
hooks = {
# docs
markdownlint = {
enable = true;
settings.configuration = {
# Max line length, doesn't seem to correctly account for lines containing links
# https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md
MD013 = false;
mailserverModule = import ./.;
# Generate a MarkDown file describing the options of the NixOS mailserver module
optionsDoc = let
eval = lib.evalModules {
modules = [
mailserverModule
{
_module.check = false;
mailserver = {
fqdn = "mx.example.com";
domains = [
"example.com"
];
dmarcReporting = {
organizationName = "Example Corp";
domain = "example.com";
};
};
rstcheck = {
enable = true;
package = pkgs.rstcheckWithSphinx;
entry = lib.getExe pkgs.rstcheckWithSphinx;
files = "\\.rst$";
};
# spell checking
typos = {
enable = true;
settings.configPath = ".typos.toml";
};
# nix
deadnix.enable = true;
nixfmt.enable = true;
# python
pyright.enable = true;
ruff = {
enable = true;
args = [
"--extend-select"
"I"
];
};
ruff-format.enable = true;
# scripts
shellcheck.enable = true;
# sieve
check-sieve = {
enable = true;
package = pkgs.check-sieve;
entry = lib.getExe pkgs.check-sieve;
files = "\\.sieve$";
};
};
};
}
];
};
packages.${system} = {
inherit optionsDoc documentation;
};
devShells.${system}.default = pkgs.mkShellNoCC {
inputsFrom = [ documentation ];
packages =
with pkgs;
[
glab
]
++ self.checks.${system}.pre-commit.enabledPackages;
shellHook = self.checks.${system}.pre-commit.shellHook;
};
devShell.${system} = self.devShells.${system}.default; # compatibility
options = builtins.toFile "options.json" (builtins.toJSON
(lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver")
(lib.optionAttrSetToDocList eval.options)));
in pkgs.runCommand "options.md" { buildInputs = [pkgs.python3Minimal]; } ''
echo "Generating options.md from ${options}"
python ${./scripts/generate-options.py} ${options} > $out
'';
formatter.${system} = pkgs.nixfmt-tree;
documentation = pkgs.stdenv.mkDerivation {
name = "documentation";
src = lib.sourceByRegex ./docs ["logo\\.png" "conf\\.py" "Makefile" ".*\\.rst"];
buildInputs = [(
pkgs.python3.withPackages (p: with p; [
sphinx
sphinx_rtd_theme
myst-parser
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;
};
checks.${system} = allTests;
packages.${system} = {
inherit optionsDoc documentation;
};
devShells.${system}.default = pkgs.mkShell {
inputsFrom = [ documentation ];
packages = with pkgs; [
clamav
];
};
devShell.${system} = self.devShells.${system}.default; # compatibility
};
}
+20 -144
View File
@@ -1,146 +1,22 @@
{ config, lib, pkgs, ... }:
{
config,
lib,
...
}:
let
mailserverRelease = "26.05";
nixpkgsRelease = lib.trivial.release;
releaseMismatch =
config.mailserver.enableNixpkgsReleaseCheck && mailserverRelease != nixpkgsRelease;
in
{
warnings =
lib.optionals releaseMismatch [
''
You are using
NixOS Mailserver version ${mailserverRelease} and
Nixpkgs version ${nixpkgsRelease}.
Using mismatched versions is likely to cause compatibility issues
and may require migrations that make an eventual rollback tricky.
It is therefore highly recommended to use a release of
NixOS mailserver that corresponds with your chosen release of Nixpkgs.
If you insist then you can disable this warning by adding
mailserver.enableNixpkgsReleaseCheck = false;
to your configuration.
''
]
++ lib.optionals config.mailserver.borgbackup.enable [
''
`mailserver.borgbackup` will be removed after 26.05.
The borgbackup integration will be removed with the recommendation to
migrate to the upstream `services.borgbackup` module, which receives far
superior maintenance and testing.
NixOS manual: https://nixos.org/manual/nixos/stable/#module-borgbase
''
]
++ lib.optionals config.mailserver.backup.enable [
''
`mailserver.backup` will be removed after 26.05.
The rsnapshot integration will be removed due to lack of maintenance,
expertise and tests to make sure it still works. Please use the upstream
module directly instead.
''
]
++ lib.optionals config.mailserver.monitoring.enable [
''
`mailserver.monitoring` will be removed after 26.05.
The monit integration will be removed due to lack of maintenance,
expertise and tests to make sure it still works.
''
];
# 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.";
}
{
assertion =
config.mailserver.x509.useACMEHost != null
-> config.mailserver.x509.certificateFile == null && config.mailserver.x509.privateKeyFile == null;
message = "Configuring an ACME certificate (`mailserver.x509.useACMEHost`) is not possible while also passing an existing certificate (`mailserver.x509.certificateFile`, `mailserver.x509.privateKeyFile`).";
}
{
assertion =
config.mailserver.x509.useACMEHost != null
|| (
config.mailserver.x509.certificateFile != null && config.mailserver.x509.privateKeyFile != null
);
message = "Configure either an ACME certificate (`mailserver.x509.useACMEHost`) or pass an existing certificate (`mailserver.x509.certificateFile`, `mailserver.x509.privateKeyFile`).";
}
]
++ lib.optionals config.mailserver.dkim.enable (
lib.flatten (
lib.mapAttrsToList (
domain: domainAttrs:
lib.mapAttrsToList (selector: selectorAttrs: [
{
assertion =
selectorAttrs.keyFile != null -> (selectorAttrs.keyType == null && selectorAttrs.keyLength == null);
message = "${domain} DKIM selector ${selector} can only use either `keyType`, `keyLength` OR `keyFile` not both.";
}
]) domainAttrs.selectors
) config.mailserver.dkim.domains
)
)
++ lib.optionals (config.mailserver.ldap.enable && config.mailserver.storage.path != "/var/vmail") [
{
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 2;
message = ''
Issue: The dovecot homedir for LDAP users was previously not respecting `mailserver.storage.path`.
Remediation:
- Stop the `dovecot.service`
- Move `/var/vmail/ldap` below your `mailserver.storage.path`
- Increase the `stateVersion` to 2.
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-home-directory-migration for more information.
'';
}
]
++ [
{
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 3;
message = ''
Issue: The dovecot mail location for all users has changed and need to be migrated.
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-mail-directory-migration for the required remediation steps.
'';
}
]
++ lib.optionals (config.mailserver.ldap.enable) [
{
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 4;
message = ''
NixOS Mailserver requires migrating LDAP home directories to UUID scheme
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-uuid-based-home-directories for required migration steps.
'';
}
]
++ lib.optionals (config.mailserver.enableManageSieve) [
{
assertion = config.mailserver.stateVersion != null -> config.mailserver.stateVersion >= 5;
message = ''
NixOS Mailserver requires moving the Sieve script directories into Dovecot home directories.
Check https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#sieve-script-directory-migration for required migration steps.
'';
}
]
);
assertions = lib.optionals config.mailserver.ldap.enable [
{
assertion = config.mailserver.loginAccounts == {};
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.loginAccounts";
}
{
assertion = config.mailserver.extraVirtualAliases == {};
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.extraVirtualAliases";
}
{
assertion = config.mailserver.forwards == {};
message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.forwards";
}
] ++ lib.optionals (config.mailserver.enable && 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";
}
];
}
+16 -32
View File
@@ -14,43 +14,28 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
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;
@@ -66,15 +51,14 @@ let
borgScript = ''
export BORG_REPO=${repoLocation}
${cmdPreexec}
${passphraseFragment} ${lib.getExe' config.services.borgbackup.package "borg"} init ${extraInitArgs} --encryption ${encryptionFragment} || true
${passphraseFragment} ${lib.getExe' config.services.borgbackup.package "borg"} create ${extraCreateArgs} ${compression} ::${name} ${locations}
${passphraseFragment} ${pkgs.borgbackup}/bin/borg init ${extraInitArgs} --encryption ${encryptionFragment} || true
${passphraseFragment} ${pkgs.borgbackup}/bin/borg create ${extraCreateArgs} ${compression} ::${name} ${locations}
${cmdPostexec}
'';
in
{
in {
config = lib.mkIf (config.mailserver.enable && cfg.enable) {
environment.systemPackages = [
config.services.borgbackup.package
environment.systemPackages = with pkgs; [
borgbackup
];
systemd.services.borgbackup = {
+1 -1
View File
@@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, lib, ... }:
{ config, pkgs, lib, options, ... }:
let
cfg = config.mailserver;
+41 -62
View File
@@ -14,78 +14,57 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
pkgs,
lib,
...
}:
{ config, pkgs, lib }:
let
cfg = config.mailserver;
in
rec {
withACME = cfg.x509.useACMEHost != null;
{
# 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";
x509CertificateFile =
if withACME then
"${config.security.acme.certs.${cfg.x509.useACMEHost}.directory}/fullchain.pem"
else
cfg.x509.certificateFile;
# 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";
x509PrivateKeyFile =
if withACME then
"${config.security.acme.certs.${cfg.x509.useACMEHost}.directory}/key.pem"
else
cfg.x509.privateKeyFile;
passwordFiles =
let
mkHashFile = name: hash: pkgs.writeText "${builtins.hashString "sha256" name}-password-hash" hash;
in
lib.mapAttrs (
name: value:
if value.hashedPasswordFile != null then
value.hashedPasswordFile
else if value.hashedPassword != null then
builtins.toString (mkHashFile name value.hashedPassword)
else
value.passwordFile
) cfg.accounts;
# Collect accounts with plain text passwords that require hashing
accountsWithPlaintextPasswordFiles = lib.filter (name: cfg.accounts.${name}.passwordFile != null) (
builtins.attrNames cfg.accounts
);
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}"
# bash
''
#!${pkgs.stdenv.shell}
set -euo pipefail
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
baseDir=$(dirname ${destination})
if (! test -d "$baseDir"); then
mkdir -p $baseDir
chmod 755 $baseDir
fi
cat ${file} > ${destination}
echo -n '${prefix}' >> ${destination}
cat ${passwordFile} | tr -d '\n' >> ${destination}
echo -n '${suffix}' >> ${destination}
chmod 600 ${destination}
'';
cat ${file} > ${destination}
echo -n '${prefix}' >> ${destination}
cat ${passwordFile} >> ${destination}
echo -n '${suffix}' >> ${destination}
chmod 600 ${destination}
'';
}
+4
View File
@@ -0,0 +1,4 @@
{ config, lib, ... }:
{
mailserver.policydSPFExtraConfig = lib.mkIf config.mailserver.debug "debugLevel = 4";
}
-17
View File
@@ -1,17 +0,0 @@
{
imports = [
./assertions.nix
./borgbackup.nix
./rsnapshot.nix
./clamav.nix
./monit.nix
./users.nix
./environment.nix
./networking.nix
./systemd.nix
./dovecot.nix
./postfix.nix
./rspamd.nix
./kresd.nix
];
}
+337 -490
View File
@@ -14,539 +14,386 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
options,
pkgs,
lib,
...
}:
{ config, pkgs, lib, ... }:
with (import ./common.nix {
inherit
config
options
pkgs
lib
;
});
with (import ./common.nix { inherit config pkgs lib; });
let
inherit (lib)
attrNames
concatMapStringsSep
filterAttrs
mapAttrs'
mkForce
mkIf
mkMerge
nameValuePair
;
cfg = config.mailserver;
passwdDir = "/run/dovecot2";
passwdFile = "${passwdDir}/passwd";
userdbFile = "${passwdDir}/userdb";
# This file contains the ldap bind password
ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext";
bool2int = x: if x then "1" else "0";
genPasswdScript =
pkgs.writeScript "generate-password-file"
# bash
''
#!${pkgs.stdenv.shell}
maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs";
maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8";
set -euo pipefail
# maildir in format "/${domain}/${user}"
dovecotMaildir =
"maildir:${cfg.mailDirectory}/%d/%n${maildirLayoutAppendix}${maildirUTF8FolderNames}"
+ (lib.optionalString (cfg.indexDir != null)
":INDEX=${cfg.indexDir}/%d/%n"
);
if (! test -d "${passwdDir}"); then
mkdir "${passwdDir}"
chmod 755 "${passwdDir}"
fi
postfixCfg = config.services.postfix;
dovecot2Cfg = config.services.dovecot2;
# Prevent world-readable password files, even temporarily.
umask 077
stateDir = "/var/lib/dovecot";
prepend_scheme() {
case "$1" in
{*}*) printf '%s' "$1" ;;
*) printf '{CRYPT}%s' "$1" ;;
esac
}
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 f in ${
builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.accounts)
}; do
if [ ! -f "$f" ]; then
echo "Expected password hash file $f does not exist!"
exit 1
fi
done
for file in $out/pipe/bin/*; do
wrapProgram $file \
--set PATH "${pkgs.coreutils}/bin:${pkgs.rspamd}/bin"
done
'';
};
cat <<EOF > ${passwdFile}
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: _:
if lib.elem name accountsWithPlaintextPasswordFiles then
"${name}:${"$(sed -n '1{p;p;q}' ${passwordFiles."${name}"} | ${lib.getExe' config.services.dovecot2.package "doveadm"} pw)"}::::::"
else
"${name}:${"$(prepend_scheme \"$(head -n 1 ${passwordFiles."${name}"})\")"}::::::"
) cfg.accounts
)}
EOF
chown dovecot2:dovecot2 ${passwdFile}
cat <<EOF > ${userdbFile}
${lib.concatStringsSep "\n" (
lib.mapAttrsToList (
name: value:
# https://doc.dovecot.org/2.4.3/core/config/auth/databases/passwd_file.html
# https://doc.dovecot.org/2.4.3/core/plugins/quota.html#per-user-quota
# https://dovecot.org/mailman3/archives/list/dovecot@dovecot.org/thread/67DBLLW4L5QBTEYRKGA26POFZ52ZR7ZO/#67DBLLW4L5QBTEYRKGA26POFZ52ZR7ZO
"${name}:::::::"
+ lib.optionalString (value.quota != null) "userdb_quota/user/storage_size=${value.quota}"
) cfg.accounts
)}
EOF
chown dovecot2:dovecot2 ${userdbFile}
'';
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 != "") ''
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}
'';
};
junkMailboxes = builtins.attrNames (
lib.filterAttrs (_: v: v ? "special_use" && v.special_use == "\\Junk") cfg.mailboxes
);
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}
set -euo pipefail
if (! test -d "${passwdDir}"); then
mkdir "${passwdDir}"
chmod 755 "${passwdDir}"
fi
# Prevent world-readable password files, even temporarily.
umask 077
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
fi
done
cat <<EOF > ${passwdFile}
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value:
"${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::"
) cfg.loginAccounts)}
EOF
cat <<EOF > ${userdbFile}
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value:
"${name}:::::::"
+ (if lib.isString value.quota
then "userdb_quota_rule=*:storage=${value.quota}"
else "")
) cfg.loginAccounts)}
EOF
'';
junkMailboxes = builtins.attrNames (lib.filterAttrs (n: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes);
junkMailboxNumber = builtins.length junkMailboxes;
# The assertion guarantees there is exactly one Junk mailbox.
# The assertion garantees there is exactly one Junk mailbox.
junkMailboxName = if junkMailboxNumber == 1 then builtins.elemAt junkMailboxes 0 else "";
mkLdapSearchScope =
scope:
(
if scope == "sub" then
"subtree"
else if scope == "one" then
"onelevel"
else
scope
);
mkLdapSearchScope = scope: (
if scope == "sub" then "subtree"
else if scope == "one" then "onelevel"
else scope
);
in
{
config = lib.mkIf cfg.enable {
config = with cfg; lib.mkIf enable {
assertions = [
{
assertion = junkMailboxNumber == 1;
message = "nixos-mailserver requires exactly one dovecot mailbox with the 'special_use' flag set to '\\Junk' (${builtins.toString junkMailboxNumber} have been found)";
message = "nixos-mailserver requires exactly one dovecot mailbox with the 'special use' flag set to 'Junk' (${builtins.toString junkMailboxNumber} have been found)";
}
(
let
usersWithQuota = attrNames (
filterAttrs (_: account: account.quota != null) config.mailserver.accounts
);
in
{
assertion = !cfg.quota.enable -> usersWithQuota == [ ];
message = ''
Without quota support enabled, per-user quotas cannot be applied to the following accounts:
${concatMapStringsSep "\n" (account: "- ${account}") usersWithQuota}
Either remove per user quota settings or re-enable `mailserver.quota.enable`.
'';
}
)
];
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.
'';
security.acme.certs = lib.mkIf withACME {
${cfg.x509.useACMEHost} = {
reloadServices = [ "dovecot.service" ];
};
};
# Dovecot modules
# 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
];
# For compatibility with python imaplib
environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
services.dovecot2 = {
enable = true;
package = pkgs.dovecot; # pin over stateVersion logic in nixox 26.05
enablePAM = mkForce false;
enableImap = enableImap || enableImapSsl;
enablePop3 = enablePop3 || enablePop3Ssl;
enablePAM = false;
enableQuota = true;
mailGroup = vmailGroupName;
mailUser = vmailUserName;
mailLocation = dovecotMaildir;
sslServerCert = certificatePath;
sslServerKey = keyPath;
enableLmtp = true;
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";
sieve.pipeBins = map lib.getExe [
(pkgs.writeShellScriptBin "rspamd-learn-ham.sh" "exec ${lib.getExe' config.services.rspamd.package "rspamc"} -h /run/rspamd/worker-controller.sock learn_ham")
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh" "exec ${lib.getExe' config.services.rspamd.package "rspamc"} -h /run/rspamd/worker-controller.sock learn_spam")
];
pluginSettings = {
sieve = "file:${cfg.sieveDirectory}/%u/scripts;active=${cfg.sieveDirectory}/%u/active.sieve";
sieve_default = "file:${cfg.sieveDirectory}/%u/default.sieve";
sieve_default_name = "default";
};
# https://doc.dovecot.org/2.4.3/core/settings/syntax.html
# https://doc.dovecot.org/2.4.3/core/settings/types.html#boolean-list
settings = mkMerge [
({
# https://doc.dovecot.org/main/core/summaries/settings.html#dovecot_config_version
dovecot_config_version = "2.4.3";
# https://doc.dovecot.org/main/core/summaries/settings.html#dovecot_storage_version
dovecot_storage_version = "2.3.21.1";
sieve = {
extensions = [
"fileinto"
];
# server identity
hostname = cfg.fqdn;
scripts.after = builtins.toFile "spam.sieve" ''
require "fileinto";
# vmail user
mail_uid = cfg.storage.owner;
mail_gid = cfg.storage.group;
mail_access_groups = cfg.storage.group;
# authentication
auth_mechanisms = [
"plain"
"login"
];
# backend services
"service anvil" = {
"unix_listener anvil" = {
mode = "0660";
group = cfg.storage.group;
};
};
"service auth" = {
"unix_listener auth" = {
user = config.services.postfix.user;
group = config.services.postfix.group;
mode = "0660";
};
};
"service lmtp" = {
"unix_listener dovecot-lmtp" = {
user = config.services.postfix.user;
group = config.services.postfix.group;
mode = "0600";
};
user = cfg.storage.owner;
vsz_limit = "${toString cfg.lmtpMemoryLimit} MB";
};
# frontend services
"service imap-login" = mkIf (cfg.enableImap || cfg.enableImapSsl) {
"inet_listener imap" = {
# https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = if cfg.enableImap then 143 else 0;
};
"inet_listener imaps" = mkIf cfg.enableImapSsl {
port = 993;
ssl = true;
};
};
"service pop3-login" = mkIf (cfg.enablePop3 || cfg.enablePop3Ssl) {
"inet_listener pop3" = {
# https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = if cfg.enablePop3 then 110 else 0;
};
"inet_listener pop3s" = mkIf cfg.enablePop3Ssl {
port = 995;
ssl = true;
};
};
"service imap" = {
vsz_limit = "${toString cfg.imapMemoryLimit} MB";
};
# protocols
protocols = {
lmtp = true;
imap = cfg.enableImap || cfg.enableImapSsl;
pop3 = cfg.enablePop3 || cfg.enablePop3Ssl;
sieve = cfg.enableManageSieve;
};
"protocol lmtp" = {
mail_plugins = {
sieve = true;
};
};
"protocol imap" = {
mail_max_userip_connections = cfg.maxConnectionsPerUser;
mail_plugins = {
imap_sieve = true;
};
};
"protocol pop3" = {
mail_max_userip_connections = cfg.maxConnectionsPerUser;
};
# tls settings
ssl_server_cert_file = x509CertificateFile;
ssl_server_key_file = x509PrivateKeyFile;
# https://ssl-config.mozilla.org/#server=dovecot&version=2.3.21&config=intermediate&openssl=3.4.1&guideline=5.7
ssl = "required";
ssl_min_protocol = "TLSv1";
ssl_server_prefer_ciphers = "client";
ssl_cipher_list = lib.concatStringsSep ":" [
# TLS1.3
"TLS_AES_128_GCM_SHA256"
"TLS_CHACHA20_POLY1305_SHA256"
"TLS_AES_256_GCM_SHA384"
# TLS1.2
# EC key material
"ECDHE-ECDSA-AES128-GCM-SHA256"
"ECDHE-ECDSA-CHACHA20-POLY1305"
"ECDHE-ECDSA-AES256-GCM-SHA384"
# RSA key material
"ECDHE-RSA-AES128-GCM-SHA256"
"ECDHE-RSA-CHACHA20-POLY1305"
"ECDHE-RSA-AES256-GCM-SHA384"
];
ssl_curve_list = lib.concatStringsSep ":" [
"X25519MLKEM768"
"X25519"
"SecP256r1MLKEM768"
"prime256v1"
"secp384r1"
];
# default user mailboxes
"namespace inbox" = {
inbox = true;
separator = cfg.hierarchySeparator;
if header :is "X-Spam" "Yes" {
fileinto "${junkMailboxName}";
stop;
}
// mapAttrs' (name: value: nameValuePair ''mailbox "${name}"'' value) cfg.mailboxes;
lda_mailbox_autosubscribe = true;
lda_mailbox_autocreate = true;
'';
# subaddressing
recipient_delimiter = cfg.recipientDelimiter;
lmtp_save_to_detail_mailbox = cfg.lmtpSaveToDetailMailbox;
# sieve filtering
"sieve_script spamfilter" = {
# junk filter
path = pkgs.writeText "after.sieve" ''
require "fileinto";
if header :is "X-Spam" "Yes" {
fileinto "${junkMailboxName}";
stop;
}
'';
type = "after";
};
"sieve_script default" = {
# declarative
type = "default";
name = "default";
# TODO: Pre-compile Sieve scripts with 'sievec' (requires a Dovecot config in build sandbox)
path = "${
pkgs.runCommand "declarative-sieve-scripts" { } (
''
mkdir "$out"
''
+ lib.concatMapAttrsStringSep "\n" (_: value: ''
mkdir "$out/${value.name}"
cp -v "${builtins.toFile "default.sieve" value.sieveScript}" "$out/${value.name}/default.sieve"
'') (lib.filterAttrs (_: value: value.sieveScript != null) cfg.accounts)
)
}/%{user}/default.sieve";
};
"sieve_script personal" = {
# managesieve
type = "personal";
# Upstream default, but we want to be explicit about it
# https://doc.dovecot.org/main/core/plugins/sieve.html#script-storage-type-personal
active_path = "~/.dovecot.sieve";
path = "~/sieve";
};
sieve_extensions = {
fileinto = true;
};
sieve_global_extensions = {
"vnd.dovecot.pipe" = true;
};
sieve_plugins = {
sieve_imapsieve = true;
sieve_extprograms = true;
};
# imapsieve (spam/ham learning)
"mailbox ${junkMailboxName}" = {
"sieve_script spam" = {
cause = [
"APPEND"
"COPY"
];
path = ./dovecot/imap_sieve/report-spam.sieve;
type = "before";
};
};
"imapsieve_from ${junkMailboxName}" = {
"sieve_script ham" = {
cause = "copy";
path = ./dovecot/imap_sieve/report-ham.sieve;
type = "before";
};
};
mailbox_list_layout = cfg.storage.directoryLayout;
mailbox_list_utf8 = cfg.useUTF8FolderNames;
mail_driver = "maildir";
mail_path = "~/mail";
# declarative users
"userdb declarative" = {
driver = "passwd-file";
passwd_file_path = userdbFile;
fields = {
home = "${cfg.storage.path}/%{user | domain}/%{user | username}";
inherit (cfg.storage) uid gid;
mail_index_path = mkIf (cfg.indexDir != null) "${cfg.indexDir}/%{user | domain}/%{user | username}";
};
};
"passdb declarative" = {
driver = "passwd-file";
passwd_file_path = passwdFile;
};
})
(mkIf cfg.ldap.enable {
# ldap users
ssl_client_ca_file = cfg.ldap.caFile;
ssl_client_require_valid_cert = true;
ldap_version = 3;
ldap_uris = cfg.ldap.uris;
ldap_starttls = cfg.ldap.startTls;
ldap_auth_dn = cfg.ldap.bind.dn;
ldap_auth_dn_password = "</run/credentials/dovecot.service/ldap-bind-pw";
ldap_base = cfg.ldap.base;
ldap_scope = mkLdapSearchScope cfg.ldap.scope;
"userdb ldap" = {
driver = "ldap";
filter = cfg.ldap.dovecot.userFilter;
fields = {
home = "${cfg.storage.path}/ldap/%{ldap:${cfg.ldap.attributes.uuid}}";
inherit (cfg.storage) uid gid;
mail_index_path = mkIf (
cfg.indexDir != null
) "${cfg.indexDir}/ldap/%{ldap:${cfg.ldap.attributes.uuid}}";
};
ldap_connection_group = "ldap-userdb-conn";
};
"passdb ldap" = {
driver = "ldap";
filter = cfg.ldap.dovecot.passFilter;
bind = cfg.ldap.attributes.password == null;
fields = {
password = mkIf (cfg.ldap.attributes.password != null) "%{ldap:${cfg.ldap.attributes.password}}";
};
ldap_connection_group = "ldap-passdb-conn";
};
})
(mkIf cfg.quota.enable {
mail_plugins.quota = true;
"protocol imap".mail_plugins.imap_quota = true;
"service quota-status" = {
executable = toString [
"${config.services.dovecot2.package}/libexec/dovecot/quota-status"
"-p"
"postfix"
];
"unix_listener quota-status" = {
user = "postfix";
};
client_limit = 1;
vsz_limit = "${toString cfg.quotaStatusMemoryLimit} MB";
};
quota_status_success = "DUNNO";
quota_status_nouser = "DUNNO";
quota_status_overquota = "552 5.2.2 Mailbox is full";
# quota_storage_grace = "10M";
"quota user" = {
driver = "count";
storage_size = mkIf (cfg.quota.defaults.perUser != null) cfg.quota.defaults.perUser;
};
})
(mkIf cfg.fullTextSearch.enable (
{
mail_plugins = {
fts = true;
fts_flatcurve = true;
};
"service indexer-worker" = mkIf (cfg.fullTextSearch.memoryLimit != null) {
vsz_limit = "${toString cfg.fullTextSearch.memoryLimit} MB";
};
fts_autoindex = cfg.fullTextSearch.autoIndex;
fts_driver = "flatcurve";
fts_search_add_missing = "yes";
fts_search_read_fallback = cfg.fullTextSearch.fallback;
fts_header_excludes = lib.genAttrs cfg.fullTextSearch.headerExcludes (_: true);
"fts flatcurve" = {
flatcurve_substring_search = cfg.fullTextSearch.substringSearch;
};
# languages
language_filters = lib.genAttrs cfg.fullTextSearch.filters (_: true);
language_tokenizer_address_token_maxlen = 100; # default 250 too large for Xapian
}
# build languages from list, the first one becomes the default language
// lib.listToAttrs (
lib.imap0 (i: lang: {
name = "language ${lang}";
value = (if i == 0 then { default = true; } else { }) // {
language_tokenizers = [
"generic"
"email-address"
];
};
}) cfg.fullTextSearch.languages
)
))
(mkIf cfg.debug.dovecot {
mail_debug = true;
# https://doc.dovecot.org/2.4.3/core/config/events/filter.html#common-unified-filter-language
log_debug = "category=ssl OR category=auth";
})
];
};
systemd.services.dovecot = {
preStart = ''
${genPasswdScript}
'';
reloadTriggers = lib.mkIf (!withACME) [
x509CertificateFile
x509PrivateKeyFile
];
serviceConfig = lib.optionalAttrs cfg.ldap.enable {
LoadCredential = [
"ldap-bind-pw:${cfg.ldap.bind.passwordFile}"
pipeBins = map lib.getExe [
(pkgs.writeShellScriptBin "sa-learn-ham.sh"
"exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_ham")
(pkgs.writeShellScriptBin "sa-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 debug ''
mail_debug = yes
auth_debug = yes
verbose_ssl = yes
''}
${lib.optionalString (cfg.enableImap || cfg.enableImapSsl) ''
service imap-login {
inet_listener imap {
${if cfg.enableImap then ''
port = 143
'' else ''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''}
}
inet_listener imaps {
${if cfg.enableImapSsl then ''
port = 993
ssl = yes
'' else ''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''}
}
}
''}
${lib.optionalString (cfg.enablePop3 || cfg.enablePop3Ssl) ''
service pop3-login {
inet_listener pop3 {
${if cfg.enablePop3 then ''
port = 110
'' else ''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''}
}
inet_listener pop3s {
${if cfg.enablePop3Ssl then ''
port = 995
ssl = yes
'' else ''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''}
}
}
''}
protocol imap {
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
mail_plugins = $mail_plugins imap_sieve
}
protocol pop3 {
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
}
mail_access_groups = ${vmailGroupName}
ssl = required
ssl_min_protocol = TLSv1
ssl_prefer_server_ciphers = yes
service lmtp {
unix_listener dovecot-lmtp {
group = ${postfixCfg.group}
mode = 0600
user = ${postfixCfg.user}
}
}
recipient_delimiter = ${cfg.recipientDelimiter}
lmtp_save_to_detail_mailbox = ${cfg.lmtpSaveToDetailMailbox}
protocol lmtp {
mail_plugins = $mail_plugins sieve
}
passdb {
driver = passwd-file
args = ${passwdFile}
}
userdb {
driver = passwd-file
args = ${userdbFile}
default_fields = uid=${builtins.toString cfg.vmailUID} gid=${builtins.toString cfg.vmailUID} home=${cfg.mailDirectory}
}
${lib.optionalString cfg.ldap.enable ''
passdb {
driver = ldap
args = ${ldapConfFile}
}
userdb {
driver = ldap
args = ${ldapConfFile}
default_fields = home=/var/vmail/ldap/%u uid=${toString cfg.vmailUID} gid=${toString cfg.vmailUID}
}
''}
service auth {
unix_listener auth {
mode = 0660
user = ${postfixCfg.user}
group = ${postfixCfg.group}
}
}
auth_mechanisms = plain login
namespace inbox {
separator = ${cfg.hierarchySeparator}
inbox = yes
}
${lib.optionalString cfg.fullTextSearch.enable ''
plugin {
plugin = fts fts_xapian
fts = xapian
fts_xapian = partial=${toString cfg.fullTextSearch.minSize} full=${toString cfg.fullTextSearch.maxSize} attachments=${bool2int cfg.fullTextSearch.indexAttachments} verbose=${bool2int cfg.debug}
fts_autoindex = ${if cfg.fullTextSearch.autoIndex then "yes" else "no"}
${lib.strings.concatImapStringsSep "\n" (n: x: "fts_autoindex_exclude${if n==1 then "" else toString n} = ${x}") cfg.fullTextSearch.autoIndexExclude}
fts_enforced = ${cfg.fullTextSearch.enforced}
}
${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) ''
service indexer-worker {
vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit*1024*1024)}
}
''}
''}
lda_mailbox_autosubscribe = yes
lda_mailbox_autocreate = yes
'';
};
systemd.services.postfix.restartTriggers = [
genPasswdScript
];
systemd.services.dovecot2 = {
preStart = ''
${genPasswdScript}
'' + (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile);
};
systemd.services.postfix.restartTriggers = [ genPasswdScript ] ++ (lib.optional cfg.ldap.enable [setPwdInLdapConfFile]);
systemd.services.dovecot-fts-xapian-optimize = lib.mkIf (cfg.fullTextSearch.enable && cfg.fullTextSearch.maintenance.enable) {
description = "Optimize dovecot indices for fts_xapian";
requisite = [ "dovecot2.service" ];
after = [ "dovecot2.service" ];
startAt = cfg.fullTextSearch.maintenance.onCalendar;
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.dovecot}/bin/doveadm fts optimize -A";
PrivateDevices = true;
PrivateNetwork = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectSystem = true;
PrivateTmp = true;
};
};
systemd.timers.dovecot-fts-xapian-optimize = lib.mkIf (cfg.fullTextSearch.enable && cfg.fullTextSearch.maintenance.enable && cfg.fullTextSearch.maintenance.randomizedDelaySec != 0) {
timerConfig = {
RandomizedDelaySec = cfg.fullTextSearch.maintenance.randomizedDelaySec;
};
};
};
}
@@ -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}" ];
@@ -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}" ];
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
set -o errexit
exec rspamc -h /run/rspamd/worker-controller.sock learn_ham
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
set -o errexit
exec rspamc -h /run/rspamd/worker-controller.sock learn_spam
+5 -13
View File
@@ -14,23 +14,15 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
pkgs,
lib,
...
}:
{ config, pkgs, lib, ... }:
let
cfg = config.mailserver;
in
{
config = lib.mkIf cfg.enable {
environment.systemPackages = [
config.services.dovecot2.package
pkgs.openssh
config.services.postfix.package
config.services.rspamd.package
];
config = with cfg; lib.mkIf enable {
environment.systemPackages = with pkgs; [
dovecot opendkim openssh postfix rspamd
] ++ (if certificateScheme == "selfsigned" then [ openssl ] else []);
};
}
+2 -1
View File
@@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, lib, ... }:
{ config, pkgs, lib, ... }:
let
cfg = config.mailserver;
@@ -24,3 +24,4 @@ in
services.kresd.enable = true;
};
}
+1 -1
View File
@@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, lib, ... }:
{ config, pkgs, lib, ... }:
let
cfg = config.mailserver;
+10 -11
View File
@@ -20,19 +20,18 @@ let
cfg = config.mailserver;
in
{
config = lib.mkIf (cfg.enable && cfg.openFirewall) {
config = with cfg; lib.mkIf (enable && openFirewall) {
networking.firewall = {
allowedTCPPorts = [
25
]
++ lib.optional cfg.enableSubmission 587
++ lib.optional cfg.enableSubmissionSsl 465
++ lib.optional cfg.enableImap 143
++ lib.optional cfg.enableImapSsl 993
++ lib.optional cfg.enablePop3 110
++ lib.optional cfg.enablePop3Ssl 995
++ lib.optional cfg.enableManageSieve 4190;
allowedTCPPorts = [ 25 ]
++ lib.optional enableSubmission 587
++ lib.optional enableSubmissionSsl 465
++ lib.optional enableImap 143
++ lib.optional enableImapSsl 993
++ lib.optional enablePop3 110
++ lib.optional enablePop3Ssl 995
++ lib.optional enableManageSieve 4190
++ lib.optional (certificateScheme == "acme-nginx") 80;
};
};
}
+42
View File
@@ -0,0 +1,42 @@
# 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 <http://www.gnu.org/licenses/>
{ config, pkgs, lib, ... }:
with (import ./common.nix { inherit config lib pkgs; });
let
cfg = config.mailserver;
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"
];
};
}
+89
View File
@@ -0,0 +1,89 @@
# nixos-mailserver: a simple mail server
# Copyright (C) 2017 Brian Olsen
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.mailserver;
dkimUser = config.services.opendkim.user;
dkimGroup = config.services.opendkim.group;
createDomainDkimCert = dom:
let
dkim_key = "${cfg.dkimKeyDirectory}/${dom}.${cfg.dkimSelector}.key";
dkim_txt = "${cfg.dkimKeyDirectory}/${dom}.${cfg.dkimSelector}.txt";
in
''
if [ ! -f "${dkim_key}" ]
then
${pkgs.opendkim}/bin/opendkim-genkey -s "${cfg.dkimSelector}" \
-d "${dom}" \
--bits="${toString cfg.dkimKeyBits}" \
--directory="${cfg.dkimKeyDirectory}"
mv "${cfg.dkimKeyDirectory}/${cfg.dkimSelector}.private" "${dkim_key}"
mv "${cfg.dkimKeyDirectory}/${cfg.dkimSelector}.txt" "${dkim_txt}"
chmod 644 "${dkim_txt}"
echo "Generated key for domain ${dom} selector ${cfg.dkimSelector}"
fi
'';
createAllCerts = lib.concatStringsSep "\n" (map createDomainDkimCert cfg.domains);
keyTable = pkgs.writeText "opendkim-KeyTable"
(lib.concatStringsSep "\n" (lib.flip map cfg.domains
(dom: "${dom} ${dom}:${cfg.dkimSelector}:${cfg.dkimKeyDirectory}/${dom}.${cfg.dkimSelector}.key")));
signingTable = pkgs.writeText "opendkim-SigningTable"
(lib.concatStringsSep "\n" (lib.flip map cfg.domains (dom: "${dom} ${dom}")));
dkim = config.services.opendkim;
args = [ "-f" "-l" ] ++ lib.optionals (dkim.configFile != null) [ "-x" dkim.configFile ];
in
{
config = mkIf (cfg.dkimSigning && cfg.enable) {
services.opendkim = {
enable = true;
selector = cfg.dkimSelector;
keyPath = cfg.dkimKeyDirectory;
domains = "csl:${builtins.concatStringsSep "," cfg.domains}";
configFile = pkgs.writeText "opendkim.conf" (''
Canonicalization ${cfg.dkimHeaderCanonicalization}/${cfg.dkimBodyCanonicalization}
UMask 0002
Socket ${dkim.socket}
KeyTable file:${keyTable}
SigningTable file:${signingTable}
'' + (lib.optionalString cfg.debug ''
Syslog yes
SyslogSuccess yes
LogWhy yes
''));
};
users.users = optionalAttrs (config.services.postfix.user == "postfix") {
postfix.extraGroups = [ "${dkimGroup}" ];
};
systemd.services.opendkim = {
preStart = lib.mkForce createAllCerts;
serviceConfig = {
ExecStart = lib.mkForce "${pkgs.opendkim}/bin/opendkim ${escapeShellArgs args}";
PermissionsStartOnly = lib.mkForce false;
};
};
systemd.tmpfiles.rules = [
"d '${cfg.dkimKeyDirectory}' - ${dkimUser} ${dkimGroup} - -"
];
};
}
+46
View File
@@ -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 <http://www.gnu.org/licenses/>
{ 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
'';
};
}
+149 -332
View File
@@ -14,140 +14,84 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
options,
pkgs,
lib,
...
}:
{ config, pkgs, lib, ... }:
with (import ./common.nix {
inherit
config
options
lib
pkgs
;
});
with (import ./common.nix { inherit config pkgs lib; });
let
inherit (lib.strings) concatStringsSep;
cfg = config.mailserver;
iniFormat = pkgs.formats.iniWithGlobalSection { };
# 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.accounts
)
);
regex_valiases_postfix = mergeLookupTables (
lib.flatten (
lib.mapAttrsToList (
name: value:
let
to = name;
in
map (from: { "${from}" = to; }) value.aliasesRegexp
) cfg.accounts
)
);
valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
(name: value:
let to = name;
in map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name))
cfg.loginAccounts));
regex_valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
(name: value:
let to = name;
in map (from: {"${from}" = to;}) value.aliasesRegexp)
cfg.loginAccounts));
# catchAllPostfix :: Map String [String]
catchAllPostfix = mergeLookupTables (
lib.flatten (
lib.mapAttrsToList (
name: value:
let
to = name;
in
map (from: { "@${from}" = to; }) value.catchAll
) cfg.accounts
)
);
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.aliases;
extra_valiases_postfix = attrsToLookupTable cfg.extraVirtualAliases;
# forwards :: Map String [String]
forwards = attrsToLookupTable cfg.forwards;
# lookupTableToString :: Map String [String] -> String
lookupTableToString =
attrs:
let
valueToString = value: lib.concatStringsSep ", " value;
in
lib.concatStringsSep "\n" (
lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs
);
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;
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;
regex_valiases_file = let
content = lookupTableToString regex_valiases_postfix;
in builtins.toFile "regex_valias" content;
# denied_recipients_postfix :: [ String ]
denied_recipients_postfix = map (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") (
lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.accounts)
);
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${
lib.optionalString (cfg.rejectSenderMessage != "") " ${cfg.rejectSenderMessage}"
}"
) 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);
@@ -159,71 +103,71 @@ 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
);
regex_vaccounts_file = builtins.toFile "regex_vaccounts" (lookupTableToString regex_valiases_postfix);
submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" (
''
# Removes sensitive headers from mails handed in via the submission port.
# See https://thomas-leister.de/mailserver-debian-stretch/
# Uses "pcre" style regex.
submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" (''
# Removes sensitive headers from mails handed in via the submission port.
# See https://thomas-leister.de/mailserver-debian-stretch/
# Uses "pcre" style regex.
/^Received:/ IGNORE
/^X-Originating-IP:/ IGNORE
/^X-Mailer:/ IGNORE
/^User-Agent:/ IGNORE
/^X-Enigmail:/ IGNORE
''
+ lib.optionalString cfg.rewriteMessageId ''
/^Received:/ IGNORE
/^X-Originating-IP:/ IGNORE
/^X-Mailer:/ IGNORE
/^User-Agent:/ IGNORE
/^X-Enigmail:/ IGNORE
'' + lib.optionalString cfg.rewriteMessageId ''
# Replaces the user submitted hostname with the server's FQDN to hide the
# user's host or network.
# Replaces the user submitted hostname with the server's FQDN to hide the
# user's host or network.
/^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}>
''
);
/^Message-ID:\s+<(.*?)@.*?>/ REPLACE Message-ID: <$1@${cfg.fqdn}>
'');
smtpdMilters = [ "unix:/run/rspamd/rspamd-milter.sock" ];
inetSocket = addr: port: "inet:[${toString port}@${addr}]";
unixSocket = sock: "unix:${sock}";
smtpdMilters =
(lib.optional cfg.dkimSigning "unix:/run/opendkim/opendkim.sock")
++ [ "unix:/run/rspamd/rspamd-milter.sock" ];
policyd-spf = pkgs.writeText "policyd-spf.conf" cfg.policydSPFExtraConfig;
mappedFile = name: "hash:/var/lib/postfix/conf/${name}";
mappedRegexFile = name: "pcre:/var/lib/postfix/conf/${name}";
submissionOptions = {
smtpd_tls_security_level = "encrypt";
smtpd_sasl_auth_enable = "yes";
smtpd_sasl_type = "dovecot";
smtpd_sasl_path = "/run/dovecot2/auth";
smtpd_sasl_security_options = "noanonymous";
smtpd_sasl_local_domain = "$myhostname";
smtpd_client_restrictions = "permit_sasl_authenticated,reject";
smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}${
lib.optionalString (regex_valiases_postfix != { }) ",pcre:/etc/postfix/regex_vaccounts"
}";
smtpd_sender_restrictions = "reject_sender_login_mismatch";
smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject";
cleanup_service_name = "submission-header-cleanup";
};
submissionOptions =
{
smtpd_tls_security_level = "encrypt";
smtpd_sasl_auth_enable = "yes";
smtpd_sasl_type = "dovecot";
smtpd_sasl_path = "/run/dovecot2/auth";
smtpd_sasl_security_options = "noanonymous";
smtpd_sasl_local_domain = "$myhostname";
smtpd_client_restrictions = "permit_sasl_authenticated,reject";
smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}${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.caFile}
tls_ca_cert_file = ${cfg.ldap.tlsCAFile}
tls_require_cert = yes
search_base = ${cfg.ldap.base}
scope = ${cfg.ldap.scope}
search_base = ${cfg.ldap.searchBase}
scope = ${cfg.ldap.searchScope}
bind = yes
bind_dn = ${cfg.ldap.bind.dn}
'';
# Enforce a mapping between SMTP user and envelope sender address
ldapSenderLoginMap = pkgs.writeText "ldap-sender-login-map.cf" ''
${commonLdapConfig}
query_filter = ${cfg.ldap.postfix.filter}
result_attribute = ${cfg.ldap.attributes.username}
result_attribute = ${cfg.ldap.postfix.mailAttribute}
'';
ldapSenderLoginMapFile = "/run/postfix/ldap-sender-login-map.cf";
appendPwdInSenderLoginMap = appendLdapBindPwd {
@@ -234,11 +178,10 @@ let
destination = ldapSenderLoginMapFile;
};
# Check whether a recipient address exists, before accepting mail for it
ldapVirtualMailboxMap = pkgs.writeText "ldap-virtual-mailbox-map.cf" ''
${commonLdapConfig}
query_filter = ${cfg.ldap.postfix.filter}
result_attribute = ${cfg.ldap.attributes.username}
result_attribute = ${cfg.ldap.postfix.uidAttribute}
'';
ldapVirtualMailboxMapFile = "/run/postfix/ldap-virtual-mailbox-map.cf";
appendPwdInVirtualMailboxMap = appendLdapBindPwd {
@@ -250,66 +193,20 @@ let
};
in
{
config = lib.mkIf cfg.enable {
# SMTP TLS error reporting (RFC 8460)
services.tlsrpt = {
inherit (cfg.tlsrpt) enable;
configurePostfix = true;
reportd.settings = {
organization_name = cfg.systemName;
contact_info = "${cfg.systemContact}";
sender_address = "noreply-tlsrpt@${cfg.systemDomain}";
};
};
# SMTP client policy mapping for DANE (RFC 6698) and MTA-STS (RFC 8461)
services.postfix-tlspol = {
enable = true;
configurePostfix = true;
};
# Sender Rewriting Scheme (https://www.libsrs2.net/srs/srs.pdf)
services.postsrsd = {
inherit (cfg.srs) enable;
configurePostfix = true;
settings = {
domains = lib.unique (
[
cfg.fqdn
cfg.sendingFqdn
cfg.systemDomain
]
++ cfg.domains
);
separator = "=";
srs-domain = cfg.srs.domain;
};
};
security.acme.certs = lib.mkIf withACME {
${cfg.x509.useACMEHost} = {
reloadServices = [ "postfix.service" ];
};
};
systemd.services.postfix.reloadTriggers = lib.mkIf (!withACME) [
x509CertificateFile
x509PrivateKeyFile
];
config = with cfg; lib.mkIf enable {
systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable {
preStart = ''
${appendPwdInVirtualMailboxMap}
${appendPwdInSenderLoginMap}
'';
restartTriggers = [
appendPwdInVirtualMailboxMap
appendPwdInSenderLoginMap
];
restartTriggers = [ appendPwdInVirtualMailboxMap appendPwdInSenderLoginMap ];
};
services.postfix = {
enable = true;
hostname = "${sendingFqdn}";
networksStyle = "host";
mapFiles."valias" = valiases_file;
mapFiles."regex_valias" = regex_valiases_file;
mapFiles."vaccounts" = vaccounts_file;
@@ -317,40 +214,36 @@ in
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]);
settings.main = {
myhostname = cfg.sendingFqdn;
mydestination = ""; # disable local mail delivery
config = {
# 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 = cfg.messageSizeLimit;
message_size_limit = toString cfg.messageSizeLimit;
# virtual mail system
virtual_uid_maps = "static:5000";
virtual_gid_maps = "static:5000";
virtual_mailbox_base = mailDirectory;
virtual_mailbox_domains = vhosts_file;
virtual_mailbox_maps = [
(mappedFile "valias")
]
++ lib.optionals cfg.ldap.enable [
] ++ lib.optionals (cfg.ldap.enable) [
"ldap:${ldapVirtualMailboxMapFile}"
]
++ lib.optionals (regex_valiases_postfix != { }) [
] ++ lib.optionals (regex_valiases_postfix != {}) [
(mappedRegexFile "regex_valias")
];
virtual_alias_maps = lib.mkAfter (
lib.optionals (regex_valiases_postfix != { }) [
(mappedRegexFile "regex_valias")
]
);
virtual_alias_maps = lib.mkAfter (lib.optionals (regex_valiases_postfix != {}) [
(mappedRegexFile "regex_valias")
]);
virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp";
# Avoid leakage of X-Original-To, X-Delivered-To headers between recipients
lmtp_destination_recipient_limit = "1";
@@ -359,164 +252,88 @@ in
smtpd_sasl_path = "/run/dovecot2/auth";
smtpd_sasl_auth_enable = true;
smtpd_relay_restrictions = [
"permit_mynetworks"
"permit_sasl_authenticated"
"reject_unauth_destination"
"permit_mynetworks" "permit_sasl_authenticated" "reject_unauth_destination"
];
policy-spf_time_limit = "3600s";
# reject selected senders
smtpd_sender_restrictions = [
"check_sender_access ${mappedFile "reject_senders"}"
];
# quota and spf checking
smtpd_recipient_restrictions = [
# reject selected recipients
"check_recipient_access ${mappedFile "denied_recipients"}"
"check_recipient_access ${mappedFile "reject_recipients"}"
]
++ lib.optionals cfg.quota.enable [
# 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 = [
"${x509PrivateKeyFile}"
"${x509CertificateFile}"
];
# 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;
# 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";
# TLS versions supported for the SMTP server
smtpd_tls_protocols = ">=TLSv1";
smtpd_tls_mandatory_protocols = ">=TLSv1";
# 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";
# Enable DNSSEC/DANE support for outgoing SMTP connections
# https://www.postfix.org/postconf.5.html#smtp_tls_security_level
smtp_dns_support_level = "dnssec";
smtp_tls_security_level = "dane";
# 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";
# 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";
tls_config_file =
let
mkGroupString = groups: concatStringsSep " / " (map (concatStringsSep ":") groups);
in
iniFormat.generate "postfix-openssl.cnf" {
globalSection.postfix = "postfix_settings";
sections = {
postfix_settings.ssl_conf = "postfix_ssl_settings";
postfix_ssl_settings.system_default = "baseline_postfix_settings";
baseline_postfix_settings = {
# Allow all TLSv1.3 cipher suites
Ciphersuites = concatStringsSep ":" [
"TLS_AES_256_GCM_SHA384"
"TLS_AES_128_GCM_SHA256"
"TLS_CHACHA20_POLY1305_SHA256"
];
# Full list: openssl list -tls-groups
# 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
Groups = mkGroupString [
[ "*X25519MLKEM768" ]
[ "*X25519" ]
[ "SecP256r1MLKEM768" ]
[
"P-256"
"P-384"
]
];
SignatureAlgorithms = concatStringsSep ":" [
# Full list: openssl list -tls-signature-algorithms
# Reduced to algorithms with key material supported in CA/B
# baseline requirements and excluding deprecated algorithms
# like SHA1.
# EcDSA certificates
# https://cabforum.org/working-groups/server/baseline-requirements/requirements/#71312-ecdsa
"ecdsa_secp256r1_sha256"
"ecdsa_secp384r1_sha384"
"ecdsa_secp521r1_sha512"
# RSA certificates
# https://cabforum.org/working-groups/server/baseline-requirements/requirements/#71311-rsa
"rsa_pss_rsae_sha256"
"rsa_pss_rsae_sha384"
"rsa_pss_rsae_sha512"
"rsa_pss_pss_sha256"
"rsa_pss_pss_sha384"
"rsa_pss_pss_sha512"
"rsa_pkcs1_sha256"
"rsa_pkcs1_sha384"
"rsa_pkcs1_sha512"
];
};
};
};
tls_config_name = "postfix";
# Algorithm selection happens through `tls_config_file` instead.
tls_eecdh_auto_curves = [ ];
tls_ffdhe_auto_groups = [ ];
# Require AEAD & ECDHE for TLSv1.2.
tls_high_cipherlist = concatStringsSep ":" [
"ECDHE-ECDSA-AES256-GCM-SHA384"
"ECDHE-RSA-AES256-GCM-SHA384"
"ECDHE-ECDSA-AES128-GCM-SHA256"
"ECDHE-RSA-AES128-GCM-SHA256"
"ECDHE-ECDSA-CHACHA20-POLY1305"
"ECDHE-RSA-CHACHA20-POLY1305"
];
# 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.dkim.enable [ "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}";
# Fix for https://www.postfix.org/smtp-smuggling.html
smtpd_forbid_bare_newline = cfg.smtpdForbidBareNewline;
smtpd_forbid_bare_newline_exclusions = "$mynetworks";
};
submissionOptions = submissionOptions;
submissionsOptions = submissionOptions;
settings.master = {
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";
private = false;
chroot = false;
maxproc = 0;
command = "cleanup";
args = [
"-o"
"header_checks=pcre:${submissionHeaderCleanupRules}"
];
args = ["-o" "header_checks=pcre:${submissionHeaderCleanupRules}"];
};
};
};
+5 -14
View File
@@ -14,19 +14,11 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
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;
@@ -61,7 +52,7 @@ in
retain hourly ${toString cfg.backup.retain.hourly}
retain daily ${toString cfg.backup.retain.daily}
retain weekly ${toString cfg.backup.retain.weekly}
backup ${cfg.storage.path}/ localhost/
backup ${cfg.mailDirectory}/ localhost/
'';
};
};
+64 -240
View File
@@ -14,217 +14,67 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
pkgs,
lib,
...
}:
{ config, pkgs, lib, ... }:
let
cfg = config.mailserver;
postfixCfg = config.services.postfix;
rspamdCfg = config.services.rspamd;
rspamdPkg = config.services.rspamd.package;
rspamdUser = config.services.rspamd.user;
rspamdGroup = config.services.rspamd.group;
createDkimKeypair =
{
domain,
selector,
type,
bits,
...
}:
let
privkey = "${cfg.dkim.keyDirectory}/${domain}.${selector}.key";
pubkey = "${cfg.dkim.keyDirectory}/${domain}.${selector}.txt";
in
pkgs.writeShellScript "dkim-keygen-${domain}-${selector}" ''
if [ ! -f "${privkey}" ]
then
${lib.getExe' rspamdPkg "rspamadm"} dkim_keygen ${
lib.cli.toCommandLineShellGNU { } {
inherit
domain
selector
type
bits
privkey
;
}
} > "${pubkey}"
chmod 0644 "${pubkey}"
echo "Generated key for domain ${domain} and selector ${selector}"
fi
'';
mailDomains = lib.unique (
# primary mailserver domains
config.mailserver.domains
# all dkim domains, even extra domains specified
++ lib.attrNames cfg.dkim.domains
# and the srs domain, if one is configured
++ lib.optionals (cfg.srs.domain != null) [ cfg.srs.domain ]
);
dkimKeys = lib.concatMap (
domain:
let
configuredSelectors = config.mailserver.dkim.domains.${domain}.selectors or { };
finalSelectors =
if configuredSelectors == { } then
# synthesize default dkim key, if none configured
{
"${config.mailserver.dkim.defaults.selector}" = {
keyType = null;
keyLength = null;
keyFile = null;
};
}
else
configuredSelectors;
in
lib.mapAttrsToList (selector: settings: rec {
inherit domain selector;
keyFile = settings.keyFile;
keyPath = if keyFile != null then keyFile else "${cfg.dkim.keyDirectory}/${domain}.${selector}.key";
bits =
if settings.keyLength != null then
settings.keyLength
else
config.mailserver.dkim.defaults.keyLength;
type =
if settings.keyType != null then settings.keyType else config.mailserver.dkim.defaults.keyType;
}) finalSelectors
) mailDomains;
dkimKeysToGenerate = lib.filter (key: key.keyFile == null) dkimKeys;
dkimKeysByDomain = lib.groupBy (item: item.domain) dkimKeys;
rspamdSocket = "rspamd.service";
in
{
config = lib.mkIf cfg.enable {
environment.systemPackages = lib.mkBefore [
(pkgs.runCommand "rspamc-wrapped"
{
nativeBuildInputs = with pkgs; [ makeWrapper ];
}
''
makeWrapper ${lib.getExe' rspamdPkg "rspamc"} $out/bin/rspamc \
--add-flags "-h /run/rspamd/worker-controller.sock"
''
)
];
config = with cfg; lib.mkIf enable {
services.rspamd = {
enable = true;
debug = cfg.debug.rspamd;
inherit debug;
locals = {
"milter_headers.conf" = {
text = ''
use = [ "authentication-results" ];
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.dkim.enable};
# Only sign explicitly configured domains
try_fallback = false;
# Allow for usernames w/o domain part
allow_username_mismatch = true;
# Don't normalize DKIM key selection for subdomains
use_esld = false;
domain {
${lib.concatStringsSep "\n\n" (
map (domain: ''
${domain} {
selectors [
${lib.concatStringsSep ",\n" (
map (selector: ''
{
path: "${selector.keyPath}";
selector: "${selector.selector}";
}'') dkimKeysByDomain.${domain}
)}
]
}
'') (lib.attrNames dkimKeysByDomain)
)}
}
'';
};
"dmarc.conf" = {
text = ''
${lib.optionalString cfg.dmarcReporting.enable ''
"milter_headers.conf" = { text = ''
extended_spam_headers = true;
''; };
"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;
''; };
"dmarc.conf" = { text = ''
${lib.optionalString cfg.dmarcReporting.enable ''
reporting {
enabled = true;
email = "noreply-dmarc@${cfg.systemDomain}";
domain = "${cfg.systemDomain}";
org_name = "${cfg.systemName}";
from_name = "${cfg.systemName}";
msgid_from = "${cfg.systemDomain}";
${lib.optionalString (cfg.dmarcReporting.excludeDomains != [ ]) ''
exclude_domains = ${builtins.toJSON cfg.dmarcReporting.excludeDomains};
''}
email = "${cfg.dmarcReporting.email}";
domain = "${cfg.dmarcReporting.domain}";
org_name = "${cfg.dmarcReporting.organizationName}";
from_name = "${cfg.dmarcReporting.fromName}";
msgid_from = "dmarc-rua";
}''}
'';
};
};
overrides = {
"options.inc" = {
text = ''
local_addrs = [::1/128, 127.0.0.0/8]
'';
};
''; };
};
workers.rspamd_proxy = {
type = "rspamd_proxy";
bindSockets = [
{
socket = "/run/rspamd/rspamd-milter.sock";
mode = "0664";
}
];
bindSockets = [{
socket = "/run/rspamd/rspamd-milter.sock";
mode = "0664";
}];
count = 1; # Do not spawn too many processes of this type
extraConfig = ''
milter = yes; # Enable milter mode
@@ -239,13 +89,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
'';
@@ -253,51 +101,28 @@ in
};
services.redis.servers.rspamd.enable = lib.mkDefault cfg.redis.configureLocally;
systemd.tmpfiles.settings."10-rspamd.conf" = {
"${cfg.dkim.keyDirectory}" = {
d = {
# Create /var/dkim owned by rspamd user/group
user = rspamdUser;
group = rspamdGroup;
};
Z = {
# Recursively adjust permissions in /var/dkim
user = rspamdUser;
group = rspamdGroup;
};
};
services.redis.servers.rspamd = {
enable = lib.mkDefault true;
port = lib.mkDefault 6380;
};
systemd.services.rspamd = {
requires = [ "redis-rspamd.service" ] ++ (lib.optional cfg.virusScanning "clamav-daemon.service");
after = [ "redis-rspamd.service" ] ++ (lib.optional cfg.virusScanning "clamav-daemon.service");
serviceConfig = lib.mkMerge [
{
SupplementaryGroups = [ config.services.redis.servers.rspamd.group ];
}
(lib.optionalAttrs cfg.dkim.enable {
ExecStartPre = map createDkimKeypair dkimKeysToGenerate;
ReadWritePaths = [ cfg.dkim.keyDirectory ];
})
];
};
systemd.services.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable {
systemd.services.rspamd-dmarc-reporter = lib.optionalAttrs (cfg.dmarcReporting.enable) {
# Explicitly select yesterday's date to work around broken
# default behaviour when called without a date.
# https://github.com/rspamd/rspamd/issues/4062
script = toString [
(lib.getExe' rspamdPkg "rspamadm")
"dmarc_report"
"$(date -d 'yesterday' '+%Y%m%d')"
];
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 = [ ];
AmbientCapabilities = [];
CapabilityBoundingSet = "";
DevicePolicy = "closed";
IPAddressAllow = "localhost";
@@ -318,17 +143,10 @@ in
ProcSubset = "pid";
ProtectSystem = "strict";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SupplementaryGroups = lib.optionals cfg.redis.configureLocally [
config.services.redis.servers.rspamd.group
];
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
@@ -338,7 +156,7 @@ in
};
};
systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable {
systemd.timers.rspamd-dmarc-reporter = lib.optionalAttrs (cfg.dmarcReporting.enable) {
description = "Daily delivery of aggregated DMARC reports";
wantedBy = [
"timers.target"
@@ -351,6 +169,12 @@ in
};
};
systemd.services.postfix = {
after = [ rspamdSocket ];
requires = [ rspamdSocket ];
};
users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ];
};
}
+56 -45
View File
@@ -14,61 +14,72 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix {
inherit
config
options
lib
pkgs
;
});
{ config, pkgs, lib, ... }:
let
cfg = config.mailserver;
certificateDeps = lib.optionals withACME [
"acme-order-renew-${cfg.x509.useACMEHost}.service"
];
certificatesDeps =
if cfg.certificateScheme == "manual" then
[]
else if cfg.certificateScheme == "selfsigned" then
[ "mailserver-selfsigned-certificate.service" ]
else
[ "acme-finished-${cfg.fqdn}.target" ];
in
{
config = lib.mkIf cfg.enable {
config = with cfg; lib.mkIf enable {
# Create self signed certificate
systemd.services.mailserver-selfsigned-certificate = lib.mkIf (cfg.certificateScheme == "selfsigned") {
after = [ "local-fs.target" ];
script = ''
# Create certificates if they do not exist yet
dir="${cfg.certificateDirectory}"
fqdn="${cfg.fqdn}"
[[ $fqdn == /* ]] && fqdn=$(< "$fqdn")
key="$dir/key-${cfg.fqdn}.pem";
cert="$dir/cert-${cfg.fqdn}.pem";
systemd.services.dovecot = {
wants = certificateDeps;
after = certificateDeps;
preStart =
let
directories = lib.strings.escapeShellArgs (
[ cfg.storage.path ] ++ lib.optional (cfg.indexDir != null) cfg.indexDir
);
in
''
# Create mail directory and set permissions. See
# <https://doc.dovecot.org/main/core/config/shared_mailboxes.html#filesystem-permissions-1>.
# Prevent world-readable paths, even temporarily.
umask 007
mkdir -p ${directories}
chgrp "${cfg.storage.group}" ${directories}
chmod 02770 ${directories}
'';
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 (
[ mailDirectory ]
++ lib.optional (cfg.indexDir != null) cfg.indexDir
);
in ''
# Create mail directory and set permissions. See
# <http://wiki2.dovecot.org/SharedMailboxes/Permissions>.
# Prevent world-readable paths, even temporarily.
umask 007
mkdir -p ${directories}
chgrp "${vmailGroupName}" ${directories}
chmod 02770 ${directories}
'';
};
# Postfix requires dovecot lmtp socket, dovecot auth socket and certificate to work
systemd.services.postfix = {
wants = certificateDeps;
after = [
"dovecot.service"
]
++ lib.optional cfg.dkim.enable "rspamd.service"
++ certificateDeps;
requires = [ "dovecot.service" ] ++ lib.optional cfg.dkim.enable "rspamd.service";
wants = certificatesDeps;
after = [ "dovecot2.service" ]
++ lib.optional cfg.dkimSigning "opendkim.service"
++ certificatesDeps;
requires = [ "dovecot2.service" ]
++ lib.optional cfg.dkimSigning "opendkim.service";
};
};
}
+78 -38
View File
@@ -14,51 +14,91 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
config,
lib,
...
}:
{ config, pkgs, lib, ... }:
with config.mailserver;
let
cfg = config.mailserver;
in
{
config = lib.mkIf cfg.enable {
vmail_user = {
name = vmailUserName;
isSystemUser = true;
uid = vmailUID;
home = mailDirectory;
createHome = true;
group = vmailGroupName;
};
virtualMailUsersActivationScript = pkgs.writeScript "activate-virtual-mail-users" ''
#!${pkgs.stdenv.shell}
set -euo pipefail
# Prevent world-readable paths, even temporarily.
umask 007
# Create directory to store user sieve scripts if it doesn't exist
if (! test -d "${sieveDirectory}"); then
mkdir "${sieveDirectory}"
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}"
chmod 770 "${sieveDirectory}"
fi
# Copy user's sieve script to the correct location (if it exists). If it
# is null, remove the file.
${lib.concatMapStringsSep "\n" ({ name, sieveScript }:
if lib.isString sieveScript then ''
if (! test -d "${sieveDirectory}/${name}"); then
mkdir -p "${sieveDirectory}/${name}"
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}"
chmod 770 "${sieveDirectory}/${name}"
fi
cat << 'EOF' > "${sieveDirectory}/${name}/default.sieve"
${sieveScript}
EOF
chown "${vmailUserName}:${vmailGroupName}" "${sieveDirectory}/${name}/default.sieve"
'' else ''
if (test -f "${sieveDirectory}/${name}/default.sieve"); then
rm "${sieveDirectory}/${name}/default.sieve"
fi
if (test -f "${sieveDirectory}/${name}.svbin"); then
rm "${sieveDirectory}/${name}/default.svbin"
fi
'') (map (user: { inherit (user) name sieveScript; })
(lib.attrValues loginAccounts))}
'';
in {
config = lib.mkIf enable {
# assert that all accounts provide a password
assertions = map (acct: {
assertion =
lib.length (
lib.filter (value: value != null) [
acct.hashedPassword
acct.hashedPasswordFile
acct.passwordFile
]
) == 1;
message = "Login account ${acct.name} must provide exactly one of password file, hashed password, or hashed password file";
}) (lib.attrValues cfg.accounts);
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));
# 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 cfg.accounts
)
);
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)));
users.groups.${cfg.storage.group} = {
inherit (cfg.storage) gid;
# set the vmail gid to a specific value
users.groups = {
"${vmailGroupName}" = { gid = vmailUID; };
};
users.users.${cfg.storage.owner} = lib.mkForce {
inherit (cfg.storage)
group
uid
;
name = cfg.storage.owner;
isSystemUser = true;
home = cfg.storage.path;
createHome = true;
# define all users
users.users = {
"${vmail_user.name}" = lib.mkForce vmail_user;
};
systemd.services.activate-virtual-mail-users = {
wantedBy = [ "multi-user.target" ];
before = [ "dovecot2.service" ];
serviceConfig = {
ExecStart = virtualMailUsersActivationScript;
};
enable = true;
};
};
}
-146
View File
@@ -1,146 +0,0 @@
#!/usr/bin/env nix-shell
#!nix-shell -i python3 -p python3
import argparse
import os
import shutil
import sys
from enum import Enum
from pathlib import Path
from pwd import getpwnam
class FolderLayout(Enum):
Default = 1
Folder = 2
def check_user(vmail_root: Path):
owner = vmail_root.owner()
owner_uid = getpwnam(owner).pw_uid
if os.geteuid() == owner_uid:
return
try:
print(
f"Trying to switch effective user id to {owner_uid} ({owner})",
file=sys.stderr,
)
os.seteuid(owner_uid)
return
except PermissionError:
print(
f"Failed switching to virtual mail user. Please run this script under it, for example by using `sudo -u {owner}`)",
file=sys.stderr,
)
sys.exit(1)
def is_maildir_related(path: Path, layout: FolderLayout) -> bool:
if path.name in [
"subscriptions",
# https://doc.dovecot.org/2.3/admin_manual/mailbox_formats/maildir/#imap-uid-mapping
"dovecot-uidlist",
# https://doc.dovecot.org/2.3/admin_manual/mailbox_formats/maildir/#imap-keywords
"dovecot-keywords",
]:
return True
if not path.is_dir():
return False
if path.name in ["cur", "new", "tmp"]:
return True
if layout is FolderLayout.Default and path.name.startswith("."):
return True
if layout is FolderLayout.Folder:
if path.name in ["mail"]:
return False
return True
return False
def mkdir(dst: Path, dry_run: bool = True):
print(f'mkdir "{dst}"')
if not dry_run:
# u+rwx, setgid
dst.mkdir(mode=0o2700)
def move(src: Path, dst: Path, dry_run: bool = True):
print(f'mv "{src}" "{dst}"')
if not dry_run:
src.rename(dst)
def delete(dst: Path, dry_run: bool = True):
if not dst.exists():
return
if dst.is_dir():
print(f'rm --recursive "{dst}"')
if not dry_run:
shutil.rmtree(dst)
else:
print(f'rm "{dst}"')
if not dry_run:
dst.unlink()
def main(vmail_root: Path, layout: FolderLayout, dry_run: bool = True):
maildirs = {path.parent for path in vmail_root.glob("*/*/cur")}
maybe_delete = []
# The old maildir will be the new home directory
for homedir in maildirs:
maildir = homedir / "mail"
mkdir(maildir, dry_run)
for path in homedir.iterdir():
if is_maildir_related(path, layout):
move(path, maildir / path.name, dry_run)
else:
maybe_delete.append(path)
# Files that are part of the previous home directory, but now obsolete
for path in [
vmail_root / ".dovecot.lda-dupes",
vmail_root / ".dovecot.lda-dupes.locks",
]:
delete(path, dry_run)
# The remaining files are likely obsolete, but should still be checked with care
for path in maybe_delete:
print(f"# rm {str(path)}")
if dry_run:
print("\nNo changes were made.")
print("Run the script with `--execute` to apply the listed changes.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="""
NixOS Mailserver Migration #3: Dovecot mail directory migration
(https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-mail-directory-migration)
"""
)
parser.add_argument(
"vmail_root", type=Path, help="Path to the `mailserver.mailDirectory`"
)
parser.add_argument(
"--layout",
choices=["default", "folder"],
required=True,
help="Folder layout: 'default' unless `mailserver.useFsLayout` was enabled, then'folder'",
)
parser.add_argument(
"--execute", action="store_true", help="Actually perform changes"
)
args = parser.parse_args()
layout = FolderLayout.Default if args.layout == "default" else FolderLayout.Folder
check_user(args.vmail_root)
main(args.vmail_root, layout, not args.execute)
-346
View File
@@ -1,346 +0,0 @@
#!/usr/bin/env nix-shell
#!nix-shell -i python3 -p "python3.withPackages (ps: with ps; [ ldap3 ])"
import argparse
import os
import sys
from pathlib import Path
from pwd import getpwnam
from typing import Literal, cast
from ldap3 import BASE, LEVEL, SUBTREE, Connection, Server
from ldap3.core.exceptions import LDAPException
LDAPSearchScope = Literal["BASE", "LEVEL", "SUBTREE"]
EXIT_OK = 0
EXIT_ERROR = 1
EXIT_LDAP_STARTTLS = 2
EXIT_LDAP_BIND = 3
GREEN = "32"
YELLOW = "33"
RED = "31"
BOLD = "1"
NO_COLOR = "NO_COLOR" in os.environ
def color(text, code):
if NO_COLOR:
return text
return f"\033[{code}m{text}\033[0m"
def check_user(vmail_root: Path):
owner = vmail_root.owner()
owner_uid = getpwnam(owner).pw_uid
if os.geteuid() == owner_uid:
return
try:
print(f"Trying to switch effective user id to {owner_uid} ({owner})")
os.seteuid(owner_uid)
return
except PermissionError:
print(
f"Failed switching to virtual mail user. Please run this script under it, for example by using `sudo -u {owner}`)"
)
sys.exit(1)
def move(*, src: Path, dst: Path, dry_run: bool = True) -> bool:
print(f'mv "{src}" "{dst}"')
if not dry_run:
try:
src.rename(dst)
except OSError as exc:
print(f"Rename failed ({src=!s}, {dst=!s}): {exc}")
return False
return True
def main(
*,
vmail_root: Path,
ldap_uri: str,
ldap_starttls: bool,
ldap_bind_dn: str,
ldap_bind_pw: str,
ldap_base: str,
ldap_scope: LDAPSearchScope,
ldap_filter: str,
ldap_attr_uuid: str,
dry_run: bool = True,
verbose: bool = False,
):
# Begin with LDAP connection for fast feedback
server = Server(ldap_uri)
conn = Connection(server, ldap_bind_dn, ldap_bind_pw)
if ldap_starttls:
try:
if ldap_starttls:
conn.start_tls()
except LDAPException as exc:
print(color(f"LDAP connection setup failed: {exc!r}", RED))
sys.exit(EXIT_LDAP_STARTTLS)
if not conn.bind():
err = conn.result
print(
color(
f"""
LDAP bind failed for {ldap_bind_dn}@{ldap_uri}
Result: {err.get("result")} ({err.get("description")})
Message: {err.get("message")!r}""",
RED,
)
)
sys.exit(EXIT_LDAP_BIND)
# Find existing dovecot home directories and collect account identifier
print(
color(
f"\nEnumerate accounts based on existing home directories in {(vmail_root / 'ldap')!s}",
BOLD,
)
)
skipped = 0
accounts = set()
homedirs = vmail_root.glob("ldap/*")
for path in homedirs:
if not path.is_dir():
print(f"- Not a directory ({path=!s}) (skipping)")
skipped += 1
continue
elif not (path / "mail").is_dir():
print(f"- No maildir in home ({path=!s}) (skipping)")
skipped += 1
continue
account = path.name
accounts.add(account)
if verbose:
print(f"- Home directory found ({path=!s}, {account=})")
print(
color(
f"\nFinding matching LDAP entries to retrieve `{ldap_attr_uuid}` attribute",
BOLD,
)
)
no_entry = 0
multiple_entries = 0
plan = {}
for account in sorted(accounts):
filter = ldap_filter % account
conn.search(
search_base=ldap_base,
search_filter=filter,
search_scope=ldap_scope,
attributes=[ldap_attr_uuid],
)
if conn.response is None:
print(f"- LDAP search produced no result for {filter}")
count = len(conn.entries)
if count < 1:
print(f"- No LDAP entry found ({account=}, {filter=}) (skipping)")
no_entry += 1
continue
elif count > 1:
print(f"- Multiple LDAP entries found ({account=}, {filter=}) (skipping)")
multiple_entries += 1
continue
else:
entry = conn.entries[0]
uuid = str(entry[ldap_attr_uuid].value)
if verbose:
print(f"- LDAP entry mapped ({account=}, {uuid=})")
plan.update({account: uuid})
print(color("\nThe following operations will be executed:", BOLD))
moved = 0
moves_failed = 0
for src, dst in plan.items():
_src = vmail_root / "ldap" / src
_dst = vmail_root / "ldap" / dst
if not move(src=_src, dst=_dst, dry_run=dry_run):
moves_failed += 1
else:
moved += 1
print(
color(
"\nMigration summary",
BOLD,
)
)
if any([skipped, no_entry, multiple_entries, not accounts, moves_failed]):
print("""
We strongly recommend reviewing and remediating all potential issues before
running with `--execute`. Specific details can be found further up.""")
if moved:
print(f"""
- {color(f"{moved} home directories were migrated successfully.", GREEN)} {"(dry run)" if dry_run else ""}
This is great news, they are now UUID-based and will be immune to username changes!""")
if skipped and accounts:
print(f"""
- {color(f"{skipped} paths in {(vmail_root / 'ldap')!s} were skipped.", YELLOW)}
These were not a directory or did not contain a maildir. They should be
reviewed but can most likely be deleted.""")
if no_entry:
print(f"""
- {color(f"{no_entry} LDAP queries found no entry.", YELLOW)}
This could be a problem, because we cannot migrate home directories without
finding the LDAP entry and retrieving its {ldap_attr_uuid} field. In practice
this can happen if an LDAP account was deleted but its mail home directory
remained.""")
if multiple_entries:
print(f"""
- {color(f"{multiple_entries} LDAP queries returned multiple entries.", RED)}
This is a problem, because we cannot decide which LDAP entry owns the home
directory.""")
if not accounts:
print(f"""
- {color("No home directories were found.", RED)}
Make sure you are passing the correct `vmail_root` argument. It must match
your `mailserver.mailDirectory` setting.""")
if moves_failed:
print(f"""
- {color("{moves_failed} home directories could not be renamed", RED)}
No reason to panic, but the script tried to rename a home directory and that
triggered and error. Check further up what went wrong.""")
if dry_run:
print(f"\n{color('No changes were made.', YELLOW)}")
print("Run the script with `--execute` to apply the listed changes.")
sys.exit(EXIT_OK if moves_failed == 0 else EXIT_ERROR)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="""
NixOS Mailserver Migration #4: Dovecot LDAP UUID-based home directories
(https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#dovecot-ldap-uuid-based-home-directory)
"""
)
parser.add_argument(
"vmail_root", type=Path, help="Path to the `mailserver.mailDirectory`"
)
parser.add_argument(
"--ldap-uri",
type=str,
required=True,
help="URI for your LDAP server; ldaps://ldap1.example.com (TLS) or ldap://ldap1.example.com (Plain)",
)
parser.add_argument(
"--ldap-starttls",
action="store_true",
help="Enable StartTLS on plain LDAP connections",
)
parser.add_argument(
"--ldap-bind-dn",
type=str,
required=True,
help="The distinguished user allow to bind and search the LDAP server",
)
parser.add_argument(
"--ldap-bind-pw-file",
type=Path,
required=True,
help="Path to a file containing the bind password for the LDAP DN",
)
parser.add_argument(
"--ldap-base",
type=str,
required=True,
help="Base DN below which to search for LDAP accounts",
)
parser.add_argument(
"--ldap-scope",
choices=[
"sub",
"base",
"one",
],
default="sub",
help="Scope relative to the base DN",
)
parser.add_argument(
"--ldap-filter",
default="(mail=%s)",
help="LDAP query that filters for an account by the name in /var/vmail/ldap/<name> field, e.g. mail=%%s or uid=%%s if the name is not an email address.",
)
parser.add_argument(
"--ldap-attr-uuid",
default="entryUUID",
help="UUID attribute that uniquely identifies an LDAP account across login name changes",
)
parser.add_argument(
"--execute", action="store_true", help="Actually perform changes"
)
parser.add_argument("--verbose", action="store_true", help="Print more details")
args = parser.parse_args()
if args.ldap_filter.count("%s") != 1:
print(
"The --ldap-filter argument must contain exactly one '%s' as a placeholder for the primary email address.",
)
sys.exit(1)
def read_ldap_bind_pw():
try:
with open(args.ldap_bind_pw_file) as fd:
return fd.read().strip()
except OSError as exc:
print(f"Unable to read LDAP bind password file: {exc}")
sys.exit(1)
ldap_bind_pw = None
if os.geteuid() == 0:
# if we're root, read before priv drop
ldap_bind_pw = read_ldap_bind_pw()
check_user(args.vmail_root)
if ldap_bind_pw is None:
ldap_bind_pw = read_ldap_bind_pw()
ldap_scope: LDAPSearchScope = cast(
LDAPSearchScope,
{
"sub": SUBTREE,
"base": BASE,
"one": LEVEL,
}[args.ldap_scope],
)
main(
vmail_root=args.vmail_root,
ldap_uri=args.ldap_uri,
ldap_starttls=args.ldap_starttls,
ldap_bind_dn=args.ldap_bind_dn,
ldap_bind_pw=ldap_bind_pw,
ldap_base=args.ldap_base,
ldap_scope=ldap_scope,
ldap_filter=args.ldap_filter,
ldap_attr_uuid=args.ldap_attr_uuid,
dry_run=not args.execute,
verbose=args.verbose,
)
-235
View File
@@ -1,235 +0,0 @@
#!/usr/bin/env nix-shell
#!nix-shell -i python3 -p python3
import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path
from pwd import getpwnam
EXIT_OK = 0
EXIT_ERROR = 1
GREEN = "32"
YELLOW = "33"
RED = "31"
BOLD = "1"
NO_COLOR = "NO_COLOR" in os.environ
def color(text, code):
if NO_COLOR:
return text
return f"\033[{code}m{text}\033[0m"
def check_user(sieve_root: Path):
owner = sieve_root.owner()
owner_uid = getpwnam(owner).pw_uid
if os.geteuid() == owner_uid:
return
try:
print(f"Trying to switch effective user id to {owner_uid} ({owner})")
os.seteuid(owner_uid)
return
except PermissionError:
print(
f"Failed switching to virtual mail user. Please run this script under it, for example by using `sudo -u {owner}`)"
)
sys.exit(1)
def doveadm_get_user_home(user: str) -> Path:
output = subprocess.check_output(
["doveadm", "user", "-f", "home", user], text=True, stderr=subprocess.DEVNULL
)
homedir = Path(output.strip())
return homedir
def move(src: Path, dst: Path, dry_run: bool = True) -> bool:
print(f'mv "{src}" "{dst}"')
if not dry_run:
try:
shutil.move(src, dst)
except OSError as exc:
print(f"Rename failed ({src=!s}, {dst=!s}): {exc}")
return False
return True
def symlink(target: Path, link: Path, dry_run: bool = True) -> bool:
print(f'ln --symbolic --relative "{target}" "{link}"')
if not dry_run:
try:
target_relative = target.relative_to(link.parent)
link.symlink_to(target_relative)
except (OSError, ValueError) as exc:
print(f"Symlinking failed ({target=!s}, {link=!s}): {exc}")
return False
return True
def main(sieve_root: Path, dry_run: bool = True):
print(
color(
f"\nFind accounts based on existing Sieve script directories in {sieve_root!s}",
BOLD,
)
)
skipped = 0
accounts = set()
for path in sieve_root.glob("*"):
if not path.is_dir():
print(f"- Not a directory ({path=!s}) (skipping)")
skipped += 1
continue
elif not set(path.glob("scripts/*.sieve")):
print(f"- No Sieve scripts in directory ({path=!s}) (skipping)")
skipped += 1
continue
account = path.name
accounts.add(account)
print(f"- Sieve directory found ({path=!s}, {account=})")
print(
color(
f"\nLookup home directory of accounts based on the remaining Sieve directories found in {sieve_root!s}",
BOLD,
)
)
lookup_failed = 0
homedirs = {}
for account in accounts:
try:
homedir = doveadm_get_user_home(account)
except subprocess.CalledProcessError as exc:
print(f"- Home directory lookup failed ({account=}): {exc}")
lookup_failed += 1
continue
print(f"- Home directory retrieved ({account=}, {homedir=!s})")
homedirs.update({account: homedir})
print(
color(
"\nEnumerate Sieve directories of accounts",
BOLD,
)
)
plan = {}
for account, homedir in homedirs.items():
sieve_src = sieve_root / account / "scripts"
sieve_dst = homedir / "sieve"
plan.update({sieve_src: sieve_dst})
active = sieve_root / account / "active.sieve"
link = homedir / ".dovecot.sieve"
# An account may have Sieve scripts but none enabled,
# e.g. an out-of-office auto-reply but currently in-office.
if not active.is_symlink():
print(
f"- Account has Sieve scripts but none enabled ({account=}, {active=!s})"
)
continue
active_relative = active.resolve().relative_to(sieve_src)
target = sieve_dst / active_relative
plan.update({target: link})
print(
f"- Account has Sieve scripts and one enabled ({account=}, {sieve_src=!s})"
)
print(color("\nThe following operations will be executed:", BOLD))
moved = 0
moves_failed = 0
for src, dst in plan.items():
if src.is_dir():
if not move(src=src, dst=dst, dry_run=dry_run):
moves_failed += 1
else:
moved += 1
else:
if not symlink(target=src, link=dst, dry_run=dry_run):
moves_failed += 1
else:
moved += 1
print(
color(
"\nMigration summary",
BOLD,
)
)
if any([skipped, lookup_failed, not accounts, moves_failed]):
print("""
We strongly recommend reviewing and remediating all potential issues before
running with `--execute`. Specific details can be found further up.""")
if moved:
print(f"""
- {color(f"{moved} Sieve script directories were migrated successfully.", GREEN)} {"(dry run)" if dry_run else ""}""")
if skipped and accounts:
print(f"""
- {color(f"{skipped} paths in {(sieve_root)!s} were skipped.", YELLOW)}
These were not a directory or did not contain a ./scripts directory.
They should be reviewed but can most likely be deleted.""")
if lookup_failed:
print(f"""
- {color(f"{lookup_failed} account lookups failed.", YELLOW)}
This could be a problem, because we cannot migrate the Sieve script
directory into the home directory without finding the owner of the
directory. In practice this can happen if an account was deleted but
its Sieve script directory remained.""")
if not accounts:
print(f"""
- {color("No Sieve script directories were found.", RED)}
Make sure you are passing the correct `sieve_root` argument. It must match
your `mailserver.sieveDirectory` setting. In practise this may also happen
if simply no account has Sieve scripts.""")
if moves_failed:
print(f"""
- {color(f"{moves_failed} Sieve script directories could not be renamed", RED)}
No reason to panic, but the script tried to rename a Sieve script directory
and that triggered and error. Check further up what went wrong.""")
if dry_run:
print(f"\n{color('No changes were made.', YELLOW)}")
print("Run the script with `--execute` to apply the listed changes.")
sys.exit(EXIT_OK if moves_failed == 0 else EXIT_ERROR)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="""
NixOS Mailserver Migration #5: Sieve script directory migration
(https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#sieve-script-directory-migration)
"""
)
parser.add_argument(
"sieve_root", type=Path, help="Path to the `mailserver.sieveDirectory`"
)
parser.add_argument(
"--execute", action="store_true", help="Actually perform changes"
)
args = parser.parse_args()
check_user(args.sieve_root)
main(
sieve_root=args.sieve_root,
dry_run=not args.execute,
)
+31
View File
@@ -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";
};
};
};
}
+9
View File
@@ -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;
};
}
-5
View File
@@ -1,5 +0,0 @@
[tool.ruff.lint]
extend-select = ["ISC"]
[tool.ruff.lint.flake8-implicit-str-concat]
allow-multiline = false
+48 -86
View File
@@ -1,7 +1,5 @@
import json
import sys
from textwrap import indent
from typing import Any, Mapping
header = """
# Mailserver options
@@ -11,110 +9,74 @@ header = """
"""
template = """
({key})=
`````{{option}} {key}
{description}
{type}
{default}
{example}
{description}
`````
"""
f = open(sys.argv[1])
options = json.load(f)
groups = [
"mailserver.accounts",
"mailserver.x509",
"mailserver.storage",
"mailserver.dkim",
"mailserver.srs",
"mailserver.dmarcReporting",
"mailserver.tlsrpt",
"mailserver.fullTextSearch",
"mailserver.quota",
"mailserver.redis",
"mailserver.ldap",
"mailserver.monitoring",
"mailserver.backup",
"mailserver.borgbackup",
]
groups = ["mailserver.loginAccounts",
"mailserver.certificate",
"mailserver.dkim",
"mailserver.dmarcReporting",
"mailserver.fullTextSearch",
"mailserver.redis",
"mailserver.ldap",
"mailserver.monitoring",
"mailserver.backup",
"mailserver.borgbackup"]
def render_option_value(opt, attr):
if attr in opt:
if isinstance(opt[attr], dict) and '_type' in opt[attr]:
if opt[attr]['_type'] == 'literalExpression':
if '\n' in opt[attr]['text']:
res = '\n```nix\n' + opt[attr]['text'].rstrip('\n') + '\n```'
else:
res = '```{}```'.format(opt[attr]['text'])
elif opt[attr]['_type'] == 'literalMD':
res = opt[attr]['text']
else:
s = str(opt[attr])
if s == "":
res = '`""`'
elif '\n' in s:
res = '\n```\n' + s.rstrip('\n') + '\n```'
else:
res = '```{}```'.format(s)
res = '- ' + attr + ': ' + res
else:
res = ""
return res
def md_literal(value: str) -> str:
return f"`{value}`"
def md_codefence(value: str, language: str = "nix") -> str:
return indent(
f"\n```{language}\n{value}\n```",
prefix=2 * " ",
)
def render_option_value(option: Mapping[str, Any], key: str) -> str:
if key not in option:
return ""
value = None
if isinstance(option[key], dict) and "_type" in option[key]:
if option[key]["_type"] == "literalExpression":
# multi-line codeblock
if "\n" in option[key]["text"]:
text = option[key]["text"].rstrip("\n")
value = md_codefence(text)
# inline codeblock
else:
value = md_literal(option[key]["text"])
# literal markdown
elif option[key]["_type"] == "literalMD":
value = option[key]["text"]
else:
assert RuntimeError(f"Unhandled option type {option[key]['_type']}")
def print_option(opt):
if isinstance(opt['description'], dict) and '_type' in opt['description']: # mdDoc
description = opt['description']['text']
else:
text = str(option[key])
if text == "":
value = md_literal('""')
elif "\n" in text:
value = md_codefence(text.rstrip("\n"))
else:
value = md_literal(text)
assert value is not None
return f"- {key}: {value}"
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, "defaultText")
if "defaultText" in option
else render_option_value(option, "default"),
example=render_option_value(option, "example"),
)
)
description = opt['description']
print(template.format(
key=opt['name'],
description=description or "",
type="- type: ```{}```".format(opt['type']),
default=render_option_value(opt, 'default'),
example=render_option_value(opt, 'example')))
print(header)
for opt in options:
if any([opt["name"].startswith(c) for c in groups]):
if any([opt['name'].startswith(c) for c in groups]):
continue
print_option(opt)
for c in groups:
print(f"## `{c}`\n")
print('## `{}`'.format(c))
print()
for opt in options:
if opt["name"].startswith(c):
if opt['name'].startswith(c):
print_option(opt)
+87 -157
View File
@@ -1,45 +1,31 @@
import smtplib, sys
import argparse
import email
import email.utils
import imaplib
import smtplib
import time
import os
import uuid
import imaplib
from datetime import datetime, timedelta
from typing import cast
import email
import time
RETRY = 100
def _send_mail(smtp_host, smtp_port, smtp_username, from_addr, from_pwd, to_addr, subject, starttls):
print("Sending mail with subject '{}'".format(subject))
message = "\n".join([
"From: {from_addr}",
"To: {to_addr}",
"Subject: {subject}",
"",
"This validates our mail server can send to Gmail :/"]).format(
from_addr=from_addr,
to_addr=to_addr,
subject=subject)
def _send_mail(
smtp_host,
smtp_port,
smtp_username,
from_addr,
from_pwd,
to_addr,
subject,
starttls,
ssl,
):
print(f"Sending mail with subject '{subject}'")
message = "\n".join(
[
f"From: {from_addr}",
f"To: {to_addr}",
f"Subject: {subject}",
f"Message-ID: {uuid.uuid4()}@mail-check.py",
f"Date: {email.utils.formatdate()}",
"",
"This validates our mail server can send to Gmail :/",
]
)
retry = RETRY
smtp_class = smtplib.SMTP_SSL if ssl else smtplib.SMTP
while True:
try:
with smtp_class(smtp_host, port=smtp_port) as smtp:
with smtplib.SMTP(smtp_host, port=smtp_port) as smtp:
try:
if starttls:
smtp.starttls()
@@ -51,9 +37,7 @@ def _send_mail(
except smtplib.SMTPResponseException as e:
if e.smtp_code == 451: # service unavailable error
print(e)
elif (
e.smtp_code == 454
): # smtplib.SMTPResponseException: (454, b'4.3.0 Try again later')
elif e.smtp_code == 454: # smtplib.SMTPResponseException: (454, b'4.3.0 Try again later')
print(e)
else:
raise
@@ -71,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(f"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
@@ -92,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:")
@@ -157,110 +126,71 @@ def _read_mail(
print("DKIM and SPF verification failed")
exit(4)
def send_and_read(args):
src_pwd = None
if args.src_password_file is not None:
src_pwd = args.src_password_file.readline().rstrip()
dst_pwd = args.dst_password_file.readline().rstrip()
if args.imap_username != "":
if args.imap_username != '':
imap_username = args.imap_username
else:
imap_username = args.to_addr
subject = f"{uuid.uuid4()}"
subject = "{}".format(uuid.uuid4())
_send_mail(
smtp_host=args.smtp_host,
smtp_port=args.smtp_port,
smtp_username=args.smtp_username,
from_addr=args.from_addr,
from_pwd=src_pwd,
to_addr=args.to_addr,
subject=subject,
starttls=args.smtp_starttls,
ssl=args.smtp_ssl,
)
_read_mail(
imap_host=args.imap_host,
imap_port=args.imap_port,
imap_username=imap_username,
to_pwd=dst_pwd,
subject=subject,
ignore_dkim_spf=args.ignore_dkim_spf,
)
_send_mail(smtp_host=args.smtp_host,
smtp_port=args.smtp_port,
smtp_username=args.smtp_username,
from_addr=args.from_addr,
from_pwd=src_pwd,
to_addr=args.to_addr,
subject=subject,
starttls=args.smtp_starttls)
_read_mail(imap_host=args.imap_host,
imap_port=args.imap_port,
imap_username=imap_username,
to_pwd=dst_pwd,
subject=subject,
ignore_dkim_spf=args.ignore_dkim_spf)
def read(args):
_read_mail(
imap_host=args.imap_host,
imap_port=args.imap_port,
imap_username=args.imap_username,
to_pwd=args.imap_password,
subject=args.subject,
ignore_dkim_spf=args.ignore_dkim_spf,
show_body=args.show_body,
delete=False,
)
_read_mail(imap_host=args.imap_host,
imap_port=args.imap_port,
to_addr=args.imap_username,
to_pwd=args.imap_password,
subject=args.subject,
ignore_dkim_spf=args.ignore_dkim_spf,
show_body=args.show_body,
delete=False)
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
parser_send_and_read = subparsers.add_parser(
"send-and-read",
description="Send a email with a subject containing a random UUID and then try to read this email from the recipient INBOX.",
)
parser_send_and_read.add_argument("--smtp-host", type=str)
parser_send_and_read.add_argument("--smtp-port", type=str, default=25)
parser_send_and_read.add_argument("--smtp-starttls", action="store_true")
parser_send_and_read.add_argument("--smtp-ssl", action="store_true")
parser_send_and_read.add_argument(
"--smtp-username",
type=str,
default="",
help="username used for smtp login. If not specified, the from-addr value is used",
)
parser_send_and_read.add_argument("--from-addr", type=str)
parser_send_and_read.add_argument("--imap-host", required=True, type=str)
parser_send_and_read.add_argument("--imap-port", type=str, default=993)
parser_send_and_read.add_argument("--to-addr", type=str, required=True)
parser_send_and_read.add_argument(
"--imap-username",
type=str,
default="",
help="username used for imap login. If not specified, the to-addr value is used",
)
parser_send_and_read.add_argument("--src-password-file", type=argparse.FileType("r"))
parser_send_and_read.add_argument(
"--dst-password-file", required=True, type=argparse.FileType("r")
)
parser_send_and_read.add_argument(
"--ignore-dkim-spf",
action="store_true",
help="to ignore the dkim and spf verification on the read mail",
)
parser_send_and_read = subparsers.add_parser('send-and-read', description="Send a email with a subject containing a random UUID and then try to read this email from the recipient INBOX.")
parser_send_and_read.add_argument('--smtp-host', type=str)
parser_send_and_read.add_argument('--smtp-port', type=str, default=25)
parser_send_and_read.add_argument('--smtp-starttls', action='store_true')
parser_send_and_read.add_argument('--smtp-username', type=str, default='', help="username used for smtp login. If not specified, the from-addr value is used")
parser_send_and_read.add_argument('--from-addr', type=str)
parser_send_and_read.add_argument('--imap-host', required=True, type=str)
parser_send_and_read.add_argument('--imap-port', type=str, default=993)
parser_send_and_read.add_argument('--to-addr', type=str, required=True)
parser_send_and_read.add_argument('--imap-username', type=str, default='', help="username used for imap login. If not specified, the to-addr value is used")
parser_send_and_read.add_argument('--src-password-file', type=argparse.FileType('r'))
parser_send_and_read.add_argument('--dst-password-file', required=True, type=argparse.FileType('r'))
parser_send_and_read.add_argument('--ignore-dkim-spf', action='store_true', help="to ignore the dkim and spf verification on the read mail")
parser_send_and_read.set_defaults(func=send_and_read)
parser_read = subparsers.add_parser(
"read",
description="Search for an email with a subject containing 'subject' in the INBOX.",
)
parser_read.add_argument("--imap-host", type=str, default="localhost")
parser_read.add_argument("--imap-port", type=str, default=993)
parser_read.add_argument("--imap-username", required=True, type=str)
parser_read.add_argument("--imap-password", required=True, type=str)
parser_read.add_argument(
"--ignore-dkim-spf",
action="store_true",
help="to ignore the dkim and spf verification on the read mail",
)
parser_read.add_argument(
"--show-body", action="store_true", help="print mail text/plain payload"
)
parser_read.add_argument("subject", type=str)
parser_read = subparsers.add_parser('read', description="Search for an email with a subject containing 'subject' in the INBOX.")
parser_read.add_argument('--imap-host', type=str, default="localhost")
parser_read.add_argument('--imap-port', type=str, default=993)
parser_read.add_argument('--imap-username', required=True, type=str)
parser_read.add_argument('--imap-password', required=True, type=str)
parser_read.add_argument('--ignore-dkim-spf', action='store_true', help="to ignore the dkim and spf verification on the read mail")
parser_read.add_argument('--show-body', action='store_true', help="print mail text/plain payload")
parser_read.add_argument('subject', type=str)
parser_read.set_defaults(func=read)
args = parser.parse_args()
+10 -9
View File
@@ -1,9 +1,10 @@
(import (
let
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
in
fetchTarball {
url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
sha256 = lock.nodes.flake-compat.locked.narHash;
}
) { src = ./.; }).shellNix
(import
(
let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in
fetchTarball {
url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
sha256 = lock.nodes.flake-compat.locked.narHash;
}
)
{ src = ./.; }
).shellNix
+122 -140
View File
@@ -14,115 +14,97 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
lib,
blobs,
...
}:
{ pkgs ? import <nixpkgs> {}, blobs}:
{
pkgs.nixosTest {
name = "clamav";
nodes = {
server =
{ pkgs, ... }:
{
imports = [
../default.nix
./lib/config.nix
];
server = { config, pkgs, lib, ... }:
{
imports = [
../default.nix
./lib/config.nix
];
virtualisation.memorySize = 1500;
virtualisation.memorySize = 1500;
environment.systemPackages = with pkgs; [ netcat ];
services.rsyslogd = {
enable = true;
defaultConfig = ''
*.* /dev/console
'';
};
services.clamav.updater.enable = lib.mkForce false;
systemd.services.old-clam = {
before = [ "clamav-daemon.service" ];
requiredBy = [ "clamav-daemon.service" ];
description = "ClamAV virus database";
preStart = ''
mkdir -m 0755 -p /var/lib/clamav
chown clamav:clamav /var/lib/clamav
'';
script = ''
cp ${blobs}/clamav/main.cvd /var/lib/clamav/
cp ${blobs}/clamav/daily.cvd /var/lib/clamav/
cp ${blobs}/clamav/bytecode.cvd /var/lib/clamav/
chown clamav:clamav /var/lib/clamav/*
'';
serviceConfig = {
Type = "oneshot";
PrivateTmp = "yes";
PrivateDevices = "yes";
};
};
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [
"example.com"
"example2.com"
];
virusScanning = true;
accounts = {
"user1@example.com" = {
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
aliases = [ "postmaster@example.com" ];
catchAll = [ "example.com" ];
services.rsyslogd = {
enable = true;
defaultConfig = ''
*.* /dev/console
'';
};
"user@example2.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
};
};
enableImap = true;
};
environment.etc = {
"root/eicar.com.txt".text = "X5O!P%@AP[4PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
services.clamav.updater.enable = lib.mkForce false;
systemd.services.old-clam = {
before = [ "clamav-daemon.service" ];
requiredBy = [ "clamav-daemon.service" ];
description = "ClamAV virus database";
preStart = ''
mkdir -m 0755 -p /var/lib/clamav
chown clamav:clamav /var/lib/clamav
'';
script = ''
cp ${blobs}/clamav/main.cvd /var/lib/clamav/
cp ${blobs}/clamav/daily.cvd /var/lib/clamav/
cp ${blobs}/clamav/bytecode.cvd /var/lib/clamav/
chown clamav:clamav /var/lib/clamav/*
'';
serviceConfig = {
Type = "oneshot";
PrivateTmp = "yes";
PrivateDevices = "yes";
};
};
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [ "example.com" "example2.com" ];
virusScanning = true;
loginAccounts = {
"user1@example.com" = {
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
aliases = [ "postmaster@example.com" ];
catchAll = [ "example.com" ];
};
"user@example2.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
};
};
enableImap = true;
};
environment.etc = {
"root/eicar.com.txt".text = "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
};
};
};
client =
{ nodes, pkgs, ... }:
let
serverIP = nodes.server.networking.primaryIPAddress;
clientIP = nodes.client.networking.primaryIPAddress;
client = { nodes, config, pkgs, ... }: let
serverIP = nodes.server.config.networking.primaryIPAddress;
clientIP = nodes.client.config.networking.primaryIPAddress;
grep-ip = pkgs.writeScriptBin "grep-ip" ''
#!${pkgs.stdenv.shell}
echo grep '${clientIP}' "$@" >&2
exec grep '${clientIP}' "$@"
'';
in
{
in {
imports = [
./lib/config.nix
./lib/config.nix
];
environment.systemPackages = with pkgs; [
fetchmail
msmtp
procmail
findutils
grep-ip
fetchmail msmtp procmail findutils grep-ip
];
environment.etc = {
"root/.fetchmailrc" = {
text = ''
poll ${serverIP} with proto IMAP
user 'user1@example.com' there with password 'user1' is 'root' here
mda procmail
poll ${serverIP} with proto IMAP
user 'user1@example.com' there with password 'user1' is 'root' here
mda procmail
'';
mode = "0700";
};
@@ -144,74 +126,74 @@
password user2
'';
};
"root/virus-email".text =
# mail
''
From: User2 <user@example2.com>
Content-Type: multipart/mixed;
boundary="Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607"
Mime-Version: 1.0 (Mac OS X Mail 11.3 \(3445.6.18\))
Subject: Testy McTest
Message-Id: <94550DD9-1FF1-4ED1-9F09-8812FF2E59AA@example.com>
Date: Sat, 12 May 2018 14:15:44 +0200
To: User1 <user1@example.com>
X-Mailer: Apple Mail (2.3445.6.18)
"root/virus-email".text = ''
From: User2 <user@example2.com>
Content-Type: multipart/mixed;
boundary="Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607"
Mime-Version: 1.0 (Mac OS X Mail 11.3 \(3445.6.18\))
Subject: Testy McTest
Message-Id: <94550DD9-1FF1-4ED1-9F09-8812FF2E59AA@example.com>
Date: Sat, 12 May 2018 14:15:44 +0200
To: User1 <user1@example.com>
X-Mailer: Apple Mail (2.3445.6.18)
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
Content-Transfer-Encoding: 7bit
Content-Type: text/plain;
charset=us-ascii
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
Content-Transfer-Encoding: 7bit
Content-Type: text/plain;
charset=us-ascii
Hello
Hello
I have attached a dangerous virus.
I have attached a dangerous virus.
Mfg.
User2
Mfg.
User2
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
Content-Disposition: attachment;
filename=eicar.com.txt
Content-Type: text/plain;
x-unix-mode=0644;
name="eicar.com.txt"
Content-Transfer-Encoding: 7bit
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607
Content-Disposition: attachment;
filename=eicar.com.txt
Content-Type: text/plain;
x-unix-mode=0644;
name="eicar.com.txt"
Content-Transfer-Encoding: 7bit
X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607--
'';
"root/safe-email".text =
# mail
''
From: User <user@example2.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user@example2.com to user1
Reply-To:
X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607--
'';
"root/safe-email".text = ''
From: User <user@example2.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user@example2.com to user1
Reply-To:
Hello User1,
Hello User1,
how are you doing today?
how are you doing today?
XOXO User1
'';
XOXO User1
'';
};
};
};
};
testScript =
# python
''
testScript = { nodes, ... }:
''
start_all()
server.wait_for_unit("multi-user.target")
client.wait_for_unit("multi-user.target")
server.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
server.wait_for_open_unix_socket("/run/clamav/clamd.ctl")
# TODO put this blocking into the systemd units? I am not sure if rspamd already waits for the clamd socket.
server.wait_until_succeeds(
"set +e; timeout 1 ${nodes.server.nixpkgs.pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
server.wait_until_succeeds(
"set +e; timeout 1 ${nodes.server.nixpkgs.pkgs.netcat}/bin/nc -U /run/clamav/clamd.ctl < /dev/null; [ $? -eq 124 ]"
)
client.execute("cp -p /etc/root/.* ~/")
client.succeed("mkdir -p ~/mail")
@@ -240,7 +222,7 @@
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'
'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
@@ -249,7 +231,7 @@
with subtest("no warnings or errors"):
server.fail("journalctl -u postfix | grep -i error >&2")
server.fail("journalctl -u postfix | grep -i warning >&2")
server.fail("journalctl -u dovecot | grep -i error >&2")
server.fail("journalctl -u dovecot | grep -i warning >&2")
server.fail("journalctl -u dovecot2 | grep -i error >&2")
server.fail("journalctl -u dovecot2 | grep -i warning >&2")
'';
}
+258 -332
View File
@@ -14,103 +14,81 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
{ pkgs ? import <nixpkgs> {}, ...}:
pkgs.nixosTest {
name = "external";
nodes = {
server =
{ pkgs, ... }:
{
imports = [
../default.nix
./lib/config.nix
];
environment.systemPackages = with pkgs; [
netcat
openssl
];
virtualisation.memorySize = 1024;
services.rsyslogd = {
enable = true;
defaultConfig = ''
*.* /dev/console
'';
};
mailserver = {
enable = true;
debug.dovecot = true; # enabled for sieve script logging
fqdn = "mail.example.com";
domains = [
"example.com"
"example2.com"
];
rewriteMessageId = true;
dkim = {
defaults.keyLength = 1535;
domains."example2.com".selectors = {
"dkim-rsa" = {
# rsa 1535 bits via defaults
};
"dkim-ed25519" = {
keyType = "ed25519";
keyLength = null;
};
"dkim-file" = {
keyFile = "/run/rspamd/dkim-test.key";
};
};
};
dmarcReporting.enable = true;
accounts = {
"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 = "1K";
};
};
aliases = {
"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;
fallback = false;
};
virtualisation.memorySize = 1024;
services.rsyslogd = {
enable = true;
defaultConfig = ''
*.* /dev/console
'';
};
mailserver = {
enable = true;
debug = true;
fqdn = "mail.example.com";
domains = [ "example.com" "example2.com" ];
rewriteMessageId = true;
dkimKeyBits = 1535;
dmarcReporting = {
enable = true;
domain = "example.com";
organizationName = "ACME Corp";
};
loginAccounts = {
"user1@example.com" = {
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
aliases = [ "postmaster@example.com" ];
catchAll = [ "example.com" ];
};
"user2@example.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
aliases = [ "chuck@example.com" ];
};
"user@example2.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
};
"lowquota@example.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
quota = "1B";
};
};
extraVirtualAliases = {
"single-alias@example.com" = "user1@example.com";
"multi-alias@example.com" = [ "user1@example.com" "user2@example.com" ];
};
enableImap = true;
enableImapSsl = true;
fullTextSearch = {
enable = true;
autoIndex = true;
# special use depends on https://github.com/NixOS/nixpkgs/pull/93201
autoIndexExclude = [ (if (pkgs.lib.versionAtLeast pkgs.lib.version "21") then "\\Junk" else "Junk") ];
enforced = "yes";
# fts-xapian warns when memory is low, which makes the test fail
memoryLimit = 100000;
};
};
};
# by default quota can be exceeded once with this amount (default: 10M)
# this is required to make the quota subtest hard fail on the first attempt.
services.dovecot2.settings.quota_storage_grace = "0";
};
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
@@ -121,119 +99,101 @@
echo grep '^Message-ID:.*@mail.example.com>$' "$@" >&2
exec grep '^Message-ID:.*@mail.example.com>$' "$@"
'';
test-imap-spam =
pkgs.writeScriptBin "imap-mark-spam"
# python
''
#!${pkgs.python3.interpreter}
import imaplib
test-imap-spam = pkgs.writeScriptBin "imap-mark-spam" ''
#!${pkgs.python3.interpreter}
import imaplib
with imaplib.IMAP4_SSL('${serverIP}') as imap:
imap.login('user1@example.com', 'user1')
imap.select()
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
with imaplib.IMAP4_SSL('${serverIP}') as imap:
imap.login('user1@example.com', 'user1')
imap.select()
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.copy(','.join(msg_ids), 'Junk')
for num in msg_ids:
imap.store(num, '+FLAGS', '\\Deleted')
imap.expunge()
imap.copy(','.join(msg_ids), 'Junk')
for num in msg_ids:
imap.store(num, '+FLAGS', '\\Deleted')
imap.expunge()
imap.select('Junk')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.select('Junk')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.close()
'';
test-imap-ham =
pkgs.writeScriptBin "imap-mark-ham"
# python
''
#!${pkgs.python3.interpreter}
import imaplib
imap.close()
'';
test-imap-ham = pkgs.writeScriptBin "imap-mark-ham" ''
#!${pkgs.python3.interpreter}
import imaplib
with imaplib.IMAP4_SSL('${serverIP}') as imap:
imap.login('user1@example.com', 'user1')
imap.select('Junk')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
with imaplib.IMAP4_SSL('${serverIP}') as imap:
imap.login('user1@example.com', 'user1')
imap.select('Junk')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.copy(','.join(msg_ids), 'INBOX')
for num in msg_ids:
imap.store(num, '+FLAGS', '\\Deleted')
imap.expunge()
imap.copy(','.join(msg_ids), 'INBOX')
for num in msg_ids:
imap.store(num, '+FLAGS', '\\Deleted')
imap.expunge()
imap.select('INBOX')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.select('INBOX')
status, [response] = imap.search(None, 'ALL')
msg_ids = response.decode("utf-8").split(' ')
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
imap.close()
'';
search =
pkgs.writeScriptBin "search"
# python
''
#!${pkgs.python3.interpreter}
import imaplib
import sys
imap.close()
'';
search = pkgs.writeScriptBin "search" ''
#!${pkgs.python3.interpreter}
import imaplib
import sys
[_, mailbox, needle] = sys.argv
[_, mailbox, needle] = sys.argv
with imaplib.IMAP4_SSL('${serverIP}') as imap:
imap.login('user1@example.com', 'user1')
imap.select(mailbox)
status, [response] = imap.search(None, 'BODY', repr(needle))
msg_ids = [ i for i in response.decode("utf-8").split(' ') if i ]
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
status, response = imap.fetch(msg_ids[0], '(RFC822)')
assert status == "OK"
assert needle in repr(response)
imap.close()
'';
in
{
with imaplib.IMAP4_SSL('${serverIP}') as imap:
imap.login('user1@example.com', 'user1')
imap.select(mailbox)
status, [response] = imap.search(None, 'BODY', repr(needle))
msg_ids = [ i for i in response.decode("utf-8").split(' ') if i ]
print(msg_ids)
assert status == 'OK'
assert len(msg_ids) == 1
status, response = imap.fetch(msg_ids[0], '(RFC822)')
assert status == "OK"
assert needle in repr(response)
imap.close()
'';
in {
imports = [
./lib/config.nix
./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";
};
@@ -278,149 +238,120 @@
password user1
'';
};
"root/email1".text =
# mail
''
Message-ID: <12345qwerty@host.local.network>
From: User2 <user2@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2 to user1
Reply-To:
"root/email1".text = ''
Message-ID: <12345qwerty@host.local.network>
From: User2 <user2@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2 to user1
Reply-To:
Hello User1,
Hello User1,
how are you doing today?
'';
"root/email2".text =
# mail
''
Message-ID: <232323abc@host.local.network>
From: User <user@example2.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user@example2.com to user1
Reply-To:
how are you doing today?
'';
"root/email2".text = ''
Message-ID: <232323abc@host.local.network>
From: User <user@example2.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user@example2.com to user1
Reply-To:
Hello User1,
Hello User1,
how are you doing today? I have this exciting text for you, that helps fill
your quota.
how are you doing today?
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod
tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At
vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren,
no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit
amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut
labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam
et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata
sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur
sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore
magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo
dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est
Lorem ipsum dolor sit amet.
XOXO User1
'';
"root/email3".text = ''
Message-ID: <asdfghjkl42@host.local.network>
From: Postmaster <postmaster@example.com>
To: Chuck <chuck@example.com>
Cc:
Bcc:
Subject: This is a test Email from postmaster\@example.com to chuck
Reply-To:
XOXO User1
'';
"root/email3".text =
# mail
''
Message-ID: <asdfghjkl42@host.local.network>
From: Postmaster <postmaster@example.com>
To: Chuck <chuck@example.com>
Cc:
Bcc:
Subject: This is a test Email from postmaster@example.com to chuck
Reply-To:
Hello Chuck,
Hello Chuck,
I think I may have misconfigured the mail server
XOXO Postmaster
'';
"root/email4".text = ''
Message-ID: <sdfsdf@host.local.network>
From: Single Alias <single-alias@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from single-alias\@example.com to user1
Reply-To:
I think I may have misconfigured the mail server
XOXO Postmaster
'';
"root/email4".text =
# mail
''
Message-ID: <sdfsdf@host.local.network>
From: Single Alias <single-alias@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from single-alias@example.com to user1
Reply-To:
Hello User1,
Hello User1,
how are you doing today?
how are you doing today?
XOXO User1 aka Single Alias
'';
"root/email5".text = ''
Message-ID: <789asdf@host.local.network>
From: User2 <user2@example.com>
To: Multi Alias <multi-alias@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2\@example.com to multi-alias
Reply-To:
XOXO User1 aka Single Alias
'';
"root/email5".text =
# mail
''
Message-ID: <789asdf@host.local.network>
From: User2 <user2@example.com>
To: Multi Alias <multi-alias@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2@example.com to multi-alias
Reply-To:
Hello Multi Alias,
Hello Multi Alias,
how are we doing today?
how are we doing today?
XOXO User1
'';
"root/email6".text = ''
Message-ID: <123457qwerty@host.local.network>
From: User2 <user2@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2 to user1
Reply-To:
XOXO User1
'';
"root/email6".text =
# mail
''
Message-ID: <123457qwerty@host.local.network>
From: User2 <user2@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2 to user1
Reply-To:
Hello User1,
Hello User1,
this email contains the needle:
576a4565b70f5a4c1a0925cabdb587a6
'';
"root/email7".text = ''
Message-ID: <1234578qwerty@host.local.network>
From: User2 <user2@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2 to user1
Reply-To:
this email contains the needle:
576a4565b70f5a4c1a0925cabdb587a6
'';
"root/email7".text =
# mail
''
Message-ID: <1234578qwerty@host.local.network>
From: User2 <user2@example.com>
To: User1 <user1@example.com>
Cc:
Bcc:
Subject: This is a test Email from user2 to user1
Reply-To:
Hello User1,
Hello User1,
this email does not contain the needle :(
'';
this email does not contain the needle :(
'';
};
};
};
};
testScript =
# python
''
testScript = { nodes, ... }:
''
start_all()
server.wait_for_unit("multi-user.target")
client.wait_for_unit("multi-user.target")
server.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
server.succeed("rspamadm dkim_keygen > /run/rspamd/dkim-test.key")
server.succeed("chown rspamd: /run/rspamd/dkim-test.key")
# TODO put this blocking into the systemd units?
server.wait_until_succeeds(
"set +e; timeout 1 ${nodes.server.nixpkgs.pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
client.execute("cp -p /etc/root/.* ~/")
client.succeed("mkdir -p ~/mail")
@@ -436,7 +367,7 @@
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"
"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" ]')
@@ -457,39 +388,37 @@
with subtest("dkim has user-specified size"):
server.succeed(
"openssl rsa -in /var/dkim/example2.com.dkim-rsa.key -text -noout | grep 'Private-Key: (1535 bit'"
"openssl rsa -in /var/dkim/example.com.mail.key -text -noout | grep 'Private-Key: (1535 bit'"
)
with subtest("dkim signing, multiple domains"):
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"
"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 's=dkim-rsa' ~/mail/*")
client.succeed("grep 's=dkim-ed25519' ~/mail/*")
client.succeed("grep 's=dkim-file' ~/mail/*")
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"
"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("domain catch-all"):
with subtest("catchAlls"):
client.execute("rm ~/mail/*")
# send email from chuck to non-existent account
# 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"
"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
@@ -498,18 +427,18 @@
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"
"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 received the mail that was intended for chuck.
# if this succeeds, it means that user1 recieved the mail that was intended for chuck.
client.fail("fetchmail --nosslcertck -v")
with subtest("Test sending from alias address (mailserver.aliases)"):
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"
"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
@@ -518,7 +447,7 @@
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"
"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
@@ -528,10 +457,8 @@
client.execute("rm ~/mail/*")
client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc")
server.log(server.succeed("doveadm quota get -u lowquota@example.com"))
client.succeed(
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lowquota@example.com < /etc/root/email2 >&2"
"msmtp -a test3 --tls=on --tls-certcheck=off --auth=on lowquota\@example.com < /etc/root/email2 >&2"
)
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 0 when it retrieves mail
@@ -540,23 +467,23 @@
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"
"msmtp -a test --tls=on --tls-certcheck=off --auth=on user1\@example.com < /etc/root/email1 >&2"
)
# give the mail server some time to process the mail
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
client.succeed("imap-mark-spam >&2")
server.wait_until_succeeds("journalctl -u dovecot | grep -i rspamd-learn-spam.sh >&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 dovecot | grep -i rspamd-learn-ham.sh >&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"
"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"
"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" ]')
@@ -566,9 +493,11 @@
# should fail because this folder is not indexed
client.fail("search Junk a >&2")
# check that search really goes through the indexer
server.succeed("journalctl -u dovecot | grep 'fts-flatcurve(INBOX): Query ' >&2")
server.succeed(
"journalctl -u dovecot2 | grep -E 'indexer-worker.* Done indexing .INBOX.' >&2"
)
# check that Junk is not indexed
server.fail("journalctl -u dovecot | grep 'fts-flatcurve(JUNK): Indexing ' >&2")
server.fail("journalctl -u dovecot2 | grep 'indexer-worker' | grep -i 'JUNK' >&2")
with subtest("dmarc reporting"):
server.systemctl("start rspamd-dmarc-reporter.service")
@@ -576,13 +505,10 @@
with subtest("no warnings or errors"):
server.fail("journalctl -u postfix | grep -i error >&2")
server.fail("journalctl -u postfix | grep -i warning >&2")
server.fail("journalctl -u dovecot | grep -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2")
server.fail("journalctl -u dovecot2 | grep -i error >&2")
# harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html
server.fail(
"journalctl -u dovecot | \
grep -v 'Expunged message reappeared, giving a new UID' | \
grep -v 'Time moved forwards' | \
grep -i warning >&2"
"journalctl -u dovecot2 |grep -v 'Expunged message reappeared, giving a new UID'| grep -v 'FTS Xapian: Box is empty' | grep -i warning >&2"
)
'';
}
+145 -274
View File
@@ -14,10 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
{
pkgs,
...
}:
{ pkgs ? import <nixpkgs> {}, ...}:
let
sendMail = pkgs.writeTextFile {
@@ -30,295 +27,169 @@ let
'';
};
hashPassword =
password:
pkgs.runCommand "password-${password}-hashed"
{
buildInputs = [ pkgs.mkpasswd ];
inherit password;
}
''
mkpasswd -s <<<"$password" > $out
'';
hashPasswordWithScheme =
password:
pkgs.runCommand "password-${password}-hashed-with-scheme"
{
buildInputs = [ pkgs.dovecot ];
inherit password;
}
''
printf "$password\n$password\n" | doveadm -O pw -s SSHA256 > $out
'';
hashPassword = password: pkgs.runCommand
"password-${password}-hashed"
{ buildInputs = [ pkgs.mkpasswd ]; inherit password; } ''
mkpasswd -sm bcrypt <<<"$password" > $out
'';
hashedPasswordFile = hashPassword "my-password";
hashedPasswordFileWithScheme = hashPasswordWithScheme "my-password";
passwordFile = pkgs.writeText "password" "my-password";
in
{
pkgs.nixosTest {
name = "internal";
nodes = {
machine =
{ pkgs, lib, ... }:
{
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} $@
'')];
systemd.tmpfiles.settings."mailserver-test-passwords" = {
"/run/passwords/user3" = {
f = {
argument = "my-password";
mode = "0600";
};
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [ "example.com" "domain.com" ];
localDnsResolver = false;
loginAccounts = {
"user1@example.com" = {
hashedPasswordFile = hashedPasswordFile;
};
"user2@example.com" = {
hashedPasswordFile = hashedPasswordFile;
aliasesRegexp = [''/^user2.*@domain\.com$/''];
};
"send-only@example.com" = {
hashedPasswordFile = hashPassword "send-only";
sendOnly = true;
};
};
systemd.services.dovecot.serviceConfig.CacheDirectory = "dovecot";
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [
"example.com"
"domain.com"
];
localDnsResolver = false;
accounts = {
"user1@example.com" = {
hashedPasswordFile = hashedPasswordFile;
};
"user2@example.com" = {
hashedPasswordFile = hashedPasswordFile;
aliasesRegexp = [ ''/^user2.*@domain\.com$/'' ];
};
"user3@example.com" = {
passwordFile = "/run/passwords/user3";
sieveScript = lib.readFile ./lib/redirect.sieve;
};
"user4@example.com" = {
hashedPasswordFile = hashedPasswordFileWithScheme;
};
"send-only@example.com" = {
hashedPasswordFile = hashPassword "send-only";
sendOnly = true;
};
};
forwards = {
# user2@example.com is a local account and its mails are
# also forwarded to user1@example.com
"user2@example.com" = "user1@example.com";
};
storage = {
gid = 5000;
group = "vmail";
};
indexDir = "/var/cache/dovecot/fts";
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 =
{
nodes,
...
}:
# python
''
machine.start()
machine.wait_for_unit("multi-user.target")
machine.wait_for_unit("dovecot.service")
testScript = ''
machine.start()
machine.wait_for_unit("multi-user.target")
machine.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
# Regression test for https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues/205
with subtest("mail forwarded can are locally kept"):
# A mail sent to user2@example.com is in the user1@example.com mailbox
machine.succeed(
" ".join(
[
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--imap-host localhost",
"--imap-username user1@example.com",
"--from-addr user1@example.com",
"--to-addr user2@example.com",
"--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}",
"--ignore-dkim-spf",
]
)
)
# A mail sent to user2@example.com 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@example.com",
"--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}",
"--ignore-dkim-spf",
]
)
)
# Regression test for https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues/205
with subtest("mail forwarded can are locally kept"):
# A mail sent to user2@example.com via explicit TLS is in the user1@example.com mailbox
machine.succeed(
" ".join(
[
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--imap-host localhost",
"--imap-username user1@example.com",
"--from-addr user1@example.com",
"--to-addr user2@example.com",
"--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}",
"--ignore-dkim-spf",
]
)
)
# A mail sent to user2@example.com via implicit TLS is in the user2@example.com mailbox
machine.succeed(
" ".join(
[
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-host localhost",
"--imap-host localhost",
"--imap-username user2@example.com",
"--from-addr user1@example.com",
"--to-addr user2@example.com",
"--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}",
"--ignore-dkim-spf",
]
)
)
with subtest("regex email alias are received"):
# A mail sent to user2-regex-alias@domain.com 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("regex email alias are received"):
# A mail sent to user2-regex-alias@domain.com via explicit TLS is in the user2@example.com mailbox
machine.succeed(
" ".join(
[
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--imap-host localhost",
"--imap-username user2@example.com",
"--from-addr user1@example.com",
"--to-addr user2-regex-alias@domain.com",
"--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}",
"--ignore-dkim-spf",
]
)
)
with subtest("user can send from regex email alias"):
# A mail sent 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("user can send from regex email alias"):
# A mail sent to user1@example.com from user2-regex-alias@domain.com by
# user2@example.com via implicit TLS is in the user1@example.com mailbox
machine.succeed(
" ".join(
[
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-host localhost",
"--imap-host localhost",
"--smtp-username user2@example.com",
"--from-addr user2-regex-alias@domain.com",
"--to-addr user1@example.com",
"--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}",
"--ignore-dkim-spf",
]
)
)
with subtest("vmail gid is set correctly"):
machine.succeed("getent group vmail | grep 5000")
with subtest("vmail gid is set correctly"):
machine.succeed("getent group vmail | grep 5000")
with subtest("mail to send only accounts is rejected"):
machine.wait_for_open_port(25)
# TODO put this blocking into the systemd units
machine.wait_until_succeeds(
"set +e; timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
machine.succeed(
"cat ${sendMail} | ${pkgs.netcat-gnu}/bin/nc localhost 25 | grep -q '554 5.5.0 Error'"
)
with subtest("Check dovecot maildir and index locations"):
# If these paths change we need a migration
machine.succeed("doveadm user -f home user1@example.com | grep ${nodes.machine.mailserver.storage.path}/example.com/user1")
machine.succeed("doveadm user -f mail_path user1@example.com | grep ${nodes.machine.mailserver.storage.path}/example.com/user1/mail")
machine.succeed("doveadm user -f mail_index_path user1@example.com | grep ${nodes.machine.mailserver.indexDir}/example.com/user1")
with subtest("rspamd controller serves web ui"):
machine.succeed(
"set +o pipefail; ${pkgs.curl}/bin/curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q '<body>'"
)
with subtest("mail to send only accounts is rejected"):
machine.wait_for_open_port(25)
# TODO put this blocking into the systemd units
machine.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
machine.succeed(
"cat ${sendMail} | nc localhost 25 | grep -q '554 5.5.0 Error'"
)
with subtest("rspamd controller serves web ui"):
machine.succeed(
"set +o pipefail; curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q '<body>'"
)
with subtest("user with plaintext password file can send and receive"):
machine.succeed(
" ".join(
[
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--imap-host localhost",
"--imap-username user3@example.com",
"--from-addr user3@example.com",
"--to-addr user3@example.com",
"--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}",
"--ignore-dkim-spf",
]
)
)
with subtest("user with scheme-prefixed hashedPasswordFile can send and receive"):
machine.succeed(
" ".join(
[
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--imap-host localhost",
"--imap-username user4@example.com",
"--from-addr user4@example.com",
"--to-addr user4@example.com",
"--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}",
"--ignore-dkim-spf",
]
)
)
with subtest("user's static Sieve script is being executed"):
# user3@example.com has a cfg.sieveScript that forwards every
# mail sent from user1@example.com back to user1@example.com
machine.succeed(
" ".join(
[
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--imap-host localhost",
"--imap-username user1@example.com",
"--from-addr user1@example.com",
"--to-addr user3@example.com",
"--src-password-file ${passwordFile}",
"--dst-password-file ${passwordFile}",
"--ignore-dkim-spf",
]
)
)
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'"
)
'';
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 | ${pkgs.openssl}/bin/openssl s_client -connect localhost:993 | grep 'New, TLS'"
)
'';
}
+162 -358
View File
@@ -1,379 +1,183 @@
{
pkgs,
...
{ pkgs ? import <nixpkgs> {}
, ...
}:
let
hashPassword =
password:
pkgs.runCommand "password-${password}-hashed"
{
buildInputs = [ pkgs.mkpasswd ];
inherit password;
}
''
mkpasswd -s <<<"$password" > $out
'';
let
bindPassword = "unsafegibberish";
alicePassword = "testalice";
bobPassword = "testbob";
carolPassword = "testcarol";
malloryPassword = "testmallory";
in
{
pkgs.nixosTest {
name = "ldap";
nodes = {
machine =
{ pkgs, lib, ... }:
{
imports = [
../default.nix
./lib/config.nix
];
machine = { config, pkgs, ... }: {
imports = [
./../default.nix
./lib/config.nix
];
environment.systemPackages = [
(pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
'')
];
virtualisation.memorySize = 1024;
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" =
#ldif
''
dn: dc=example
objectClass: domain
dc: example
dn: cn=mail,dc=example
objectClass: organizationalRole
objectClass: simpleSecurityObject
objectClass: top
cn: mail
# unsafegibberish
userPassword: {SSHA}JNr6l3s/RHo1LKRXqFsJg8sXznyRid8L
dn: ou=users,dc=example
objectClass: organizationalUnit
ou: users
dn: cn=alice,ou=users,dc=example
entryUUID: c52f777b-a6e8-4507-80f9-c4de47e8520d
objectClass: inetOrgPerson
uid: alice
sn: Foo
mail: alice@example.com
# testalice
userPassword: {SSHA}gkJq4Dm4jfIKjxviR0WD63wMt0Ti6zMB
dn: cn=bob,ou=users,dc=example
entryUUID: f3b4e8ea-087f-42cc-95f0-cbfd99386092
objectClass: inetOrgPerson
objectClass: posixAccount
uid: bob
uidNumber: 9999
gidNumber: 9999
sn: Bar
mail: bob@example.com
homeDirectory: /home/bob
# testbob
userPassword: {SSHA}qqUveZGZrDrjYFnREXLDZc//y89RppVN
dn: cn=carol,ou=users,dc=example
entryUUID: 41240499-27e2-4fa2-be4f-4113a77661b1
objectClass: inetOrgPerson
uid: carol
sn: Baz
mail: carol@example.com
# testcarol
userPassword: {SSHA}69HOuP+OPWE+3+tDucFZxzXDC7p4e3ML
dn: cn=frank,ou=users,dc=example
entryUUID: ca16f594-f6b2-418f-87d3-0d02d746461f
objectClass: inetOrgPerson
uid: frank
sn: Moo
mail: frank@example.com
# testfrank
userPassword: {SSHA}xqtMl8/uJ6HEFWDzLYpAE+Wq7FvKrtkm
'';
};
systemd.services.dovecot.serviceConfig = {
CacheDirectory = "dovecot";
StateDirectory = "dovecot";
};
mailserver = {
enable = true;
fqdn = "mail.example.com";
domains = [ "example.com" ];
localDnsResolver = false;
storage.path = "/var/lib/dovecot/vmail";
indexDir = "/var/cache/dovecot/indices";
aliases = {
# Steal frank@example.com from LDAP user frank
"frank@example.com" = "mallory@example.com";
};
accounts = {
# Colliding local account takes precedence over LDAP account with
# same address.
"carol@example.com" = {
hashedPasswordFile = hashPassword carolPassword;
};
# Another account used as a virtual alias target to steal
# frank@example.com from the LDAP user frank
"mallory@example.com" = {
hashedPasswordFile = hashPassword malloryPassword;
};
};
ldap = {
enable = true;
uris = [
"ldap://"
];
bind = {
dn = "cn=mail,dc=example";
passwordFile = "/etc/bind-password";
};
base = "ou=users,dc=example";
scope = "sub";
attributes = {
# disable auth bind
password = "userPassword";
};
};
forwards = {
"bob_fw@example.com" = "bob@example.com";
};
};
specialisation.auth_bind = {
inheritParentConfig = true;
configuration = {
mailserver = {
ldap = {
attributes = {
# enable auth bind
password = lib.mkForce null;
};
};
};
services.openldap.settings.children = {
"olcDatabase={1}mdb" = {
attrs = {
olcAccess = [
# disallow access to userPassword
''
to * attrs=userPassword
by anonymous auth
by * none
''
# default policy (same as if we would specify none as all)
''
to *
by * read
''
];
};
};
};
};
};
services.openssh = {
enable = true;
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";
};
vmailGroupName = "vmail";
vmailUID = 5000;
enableImap = false;
};
};
};
testScript =
{
nodes,
...
}:
# python
''
import sys
import re
testScript = ''
import sys
import re
machine.start()
machine.wait_for_unit("multi-user.target")
machine.start()
machine.wait_for_unit("multi-user.target")
# if the schema is broken, fail fast. helps during development.
machine.wait_for_unit("openldap.service")
# 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
machine.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
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")
# This function retrieves the ldap table file from a postconf
# command.
# A key lookup is achieved 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_match = re.match('.* =.*ldap:(.*)', conf)
if not ldap_table_path_match:
raise RuntimeError(f"Failed to match LDAP table in '{postconf_cmdline}' response")
ldap_table_path = ldap_table_path_match.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
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 postmap lookups"):
test_lookup("postconf virtual_mailbox_maps", "alice@example.com", "alice")
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "alice@example.com", "alice")
with subtest("Test doveadm lookups"):
machine.succeed("doveadm user -u alice@example.com")
machine.succeed("doveadm user -u bob@example.com")
test_lookup("postconf virtual_mailbox_maps", "bob@example.com", "bob")
test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "bob@example.com", "bob")
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 doveadm lookups"):
machine.succeed("doveadm user -u alice@example.com")
machine.succeed("doveadm user -u bob@example.com")
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'")
machine.succeed("doveadm user -u alice")
machine.log(machine.succeed("doveadm user -u bob"))
machine.succeed("doveadm user -f uid bob@example.com | grep ${toString nodes.machine.mailserver.storage.uid}")
machine.succeed("doveadm user -f gid bob@example.com | grep ${toString nodes.machine.mailserver.storage.uid}")
machine.succeed("doveadm user -f home bob@example.com | grep ${nodes.machine.mailserver.storage.path}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092")
machine.succeed("doveadm user -f mail_path bob@example.com | grep ${nodes.machine.mailserver.storage.path}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092")
machine.succeed("doveadm user -f mail_index_path bob@example.com | grep ${nodes.machine.mailserver.indexDir}/ldap/f3b4e8ea-087f-42cc-95f0-cbfd99386092")
with subtest("Files containing secrets are only readable by root"):
machine.succeed("ls -l /run/postfix/*.cf | grep -e '-rw------- 1 root root'")
with subtest("Test account/mail address binding via explicit TLS"):
machine.fail(" ".join([
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--smtp-username alice",
"--imap-host localhost",
"--imap-username bob",
"--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'")
with subtest("Test mail delivery via implicit TLS"):
machine.succeed(" ".join([
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-host localhost",
"--smtp-username alice",
"--imap-host localhost",
"--imap-username bob",
"--from-addr alice@example.com",
"--to-addr bob@example.com",
"--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${bobPassword}')",
"--ignore-dkim-spf"
]))
with subtest("Test mail forwarding via explicit TLS works"):
machine.succeed(" ".join([
"mail-check send-and-read",
"--smtp-port 587",
"--smtp-starttls",
"--smtp-host localhost",
"--smtp-username alice",
"--imap-host localhost",
"--imap-username bob",
"--from-addr alice@example.com",
"--to-addr bob_fw@example.com",
"--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${bobPassword}')",
"--ignore-dkim-spf"
]))
with subtest("Test cannot send mail via implicit TLS from forwarded address"):
machine.fail(" ".join([
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-host localhost",
"--smtp-username bob",
"--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'")
with subtest("Local addresses take priority over those learnt from LDAP"):
# carol@example.com is routed to the local user account
machine.succeed(" ".join([
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-host localhost",
"--smtp-username alice", # LDAP user
"--imap-host localhost",
"--imap-username carol@example.com", # Local user
"--from-addr alice@example.com",
"--to-addr carol@example.com",
"--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${carolPassword}')",
"--ignore-dkim-spf"
]))
# frank@example.com gets routed to mallory@example.com due to a virtual alias
machine.succeed(" ".join([
"mail-check send-and-read",
"--smtp-port 465",
"--smtp-ssl",
"--smtp-host localhost",
"--smtp-username alice", # LDAP user
"--imap-host localhost",
"--imap-username mallory@example.com", # Local user
"--from-addr alice@example.com",
"--to-addr frank@example.com",
"--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${malloryPassword}')",
"--ignore-dkim-spf"
]))
with subtest("LDAP Authentication Binds"):
machine.succeed("/run/booted-system/specialisation/auth_bind/bin/switch-to-configuration test")
machine.wait_for_unit("openldap.service")
machine.succeed("doveadm auth test alice '${alicePassword}'")
'';
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"
]))
'';
}
-11
View File
@@ -1,11 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIBizCCATGgAwIBAgIUN4ncJfMVIQSSurMkdE73x4aefTMwCgYIKoZIzj0EAwIw
GzEZMBcGA1UEAwwQdGVzdC5sb2NhbGRvbWFpbjAeFw0yNTEwMTgyMTQ4MTNaFw0z
NTEwMTYyMTQ4MTNaMBsxGTAXBgNVBAMMEHRlc3QubG9jYWxkb21haW4wWTATBgcq
hkjOPQIBBggqhkjOPQMBBwNCAARCJUj4j7eC/7Xso3REUscqHlWPvW9zvl5I6TIy
zEXFsWxM0QxMuNW4oXE56UiCyJklcpk0JfQUGat+kKQqSUJyo1MwUTAdBgNVHQ4E
FgQUW3CnmBf3n/Y30vfj3ERsIQnXu9QwHwYDVR0jBBgwFoAUW3CnmBf3n/Y30vfj
3ERsIQnXu9QwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAhwAi
K4xdr8KxD5xRvvzShheh48i8X7NtBIQ3bd01Jx4CIG/kYTDK5nDZri7UYOMsgz2l
iWss56p2dGWTL7LrBHgM
-----END CERTIFICATE-----
+1 -37
View File
@@ -1,39 +1,3 @@
{
lib,
...
}:
{
# Testing eval failures that result from stateVersion assertion is out of scope
mailserver.stateVersion = 999;
# Keep testing submission with explicit TLS
mailserver.enableSubmission = true;
# Certificate created for testing purposes from RFC9500 private key
# https://datatracker.ietf.org/doc/rfc9500/
# openssl req -x509 -new -key key.pem \
# -subj "/CN=test.localdomain" \
# -sha256 -days 3650 \
# -out cert.pem
mailserver.x509 = {
certificateFile = "${./cert.pem}";
privateKeyFile = "${./key.pem}";
};
# Enable second CPU core
virtualisation.cores = lib.mkDefault 2;
services.rspamd = {
# Don't make tests block on DNS requests that will never succeed
locals."options.inc".text = ''
dns {
nameservers = ["127.0.0.1"];
timeout = 0.0s;
retransmits = 0;
}
'';
# Relax `local_addrs` definition to default for tests, so mail doesn't get flagged as spam
overrides."options.inc".enable = false;
};
security.dhparams.defaultBitSize = 1024; # minimum size required by dovecot
}
-5
View File
@@ -1,5 +0,0 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIObLW92AqkWunJXowVR2Z5/+yVPBaFHnEedDk5WJxk/BoAoGCCqGSM49
AwEHoUQDQgAEQiVI+I+3gv+17KN0RFLHKh5Vj71vc75eSOkyMsxFxbFsTNEMTLjV
uKFxOelIgsiZJXKZNCX0FBmrfpCkKklCcg==
-----END EC PRIVATE KEY-----
-3
View File
@@ -1,3 +0,0 @@
if address :is "from" "user1@example.com" {
redirect "user1@example.com";
}
+31
View File
@@ -0,0 +1,31 @@
# nixos-mailserver: a simple mail server
# Copyright (C) 2016-2018 Robin Raymond
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>
import <nixpkgs/nixos/tests/make-test-python.nix> {
nodes.machine =
{ config, pkgs, ... }:
{
imports = [
./../default.nix
];
};
testScript =
''
machine.wait_for_unit("multi-user.target");
'';
}
+51 -77
View File
@@ -1,41 +1,26 @@
# This tests is used to test features requiring several mail domains.
{
lib,
pkgs,
...
}:
{ pkgs ? import <nixpkgs> {}, ...}:
let
hashPassword =
password:
pkgs.runCommand "password-${password}-hashed"
{
buildInputs = [ pkgs.mkpasswd ];
inherit password;
}
hashPassword = password: pkgs.runCommand
"password-${password}-hashed"
{ buildInputs = [ pkgs.mkpasswd ]; inherit password; }
''
mkpasswd -s <<<"$password" > $out
mkpasswd -sm bcrypt <<<"$password" > $out
'';
password = pkgs.writeText "password" "password";
password = pkgs.writeText "password" "password";
domainGenerator =
domain:
{ pkgs, ... }:
{
imports = [
../default.nix
./lib/config.nix
];
environment.systemPackages = with pkgs; [ netcat ];
domainGenerator = domain: { config, pkgs, ... }: {
imports = [../default.nix];
virtualisation.memorySize = 1024;
mailserver = {
enable = true;
fqdn = "mail.${domain}";
domains = [ domain ];
localDnsResolver = false;
accounts = {
loginAccounts = {
"user@${domain}" = {
hashedPasswordFile = hashPassword "password";
};
@@ -45,71 +30,60 @@ let
};
services.dnsmasq = {
enable = true;
settings.mx-host = [
"domain1.com,domain1,10"
"domain2.com,domain2,10"
];
# Fixme: once nixos-22.11 has been removed, could be replaced by
# settings.mx-host = [ "domain1.com,domain1,10" "domain2.com,domain2,10" ];
extraConfig = ''
mx-host=domain1.com,domain1,10
mx-host=domain2.com,domain2,10
'';
};
# breaks the test, due to running into DNS timeouts
services.postfix-tlspol.configurePostfix = lib.mkForce false;
};
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 =
# python
''
start_all()
testScript = ''
start_all()
for domain in [domain1, domain2]:
domain.wait_for_unit("multi-user.target")
domain.wait_for_unit("dovecot.service")
domain1.wait_for_unit("multi-user.target")
domain2.wait_for_unit("multi-user.target")
for host in [domain1, domain2]:
host.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
# TODO put this blocking into the systemd units?
domain1.wait_until_succeeds(
"set +e; timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
domain2.wait_until_succeeds(
"set +e; timeout 1 ${pkgs.netcat}/bin/nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
# user@domain1.com sends a mail to user@domain2.com via explicit TLS
client.succeed(
"mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
)
# user@domain1.com sends a mail to user@domain2.com
client.succeed(
"mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
)
# Send a mail to the address forwarded via implicit TLS and check it is in the recipient mailbox
client.succeed(
"mail-check send-and-read --smtp-port 465 --smtp-ssl --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr non-local@domain1.com --imap-username user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
)
'';
# Send a mail to the address forwarded and check it is in the recipient mailbox
client.succeed(
"mail-check send-and-read --smtp-port 587 --smtp-starttls --smtp-host domain1 --from-addr user@domain1.com --imap-host domain2 --to-addr non-local@domain1.com --imap-username user@domain2.com --src-password-file ${password} --dst-password-file ${password} --ignore-dkim-spf"
)
'';
}
Executable
+7
View File
@@ -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