1 Commits

Author SHA1 Message Date
Jakub Skokan 284a1e4041 Allow TLSv1 for compatibility with older devices 2025-05-25 21:06:21 +02:00
70 changed files with 2767 additions and 6218 deletions
-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
+3 -3
View File
@@ -10,9 +10,9 @@ hydra-pr:
variables: variables:
jobset: $CI_MERGE_REQUEST_IID jobset: $CI_MERGE_REQUEST_IID
hydra-main: hydra-master:
extends: .hydra-cli extends: .hydra-cli
only: only:
- main - master
variables: variables:
jobset: main jobset: master
+12 -12
View File
@@ -1,11 +1,11 @@
{ nixpkgs, pulls, ... }: { nixpkgs, pulls, ... }:
let let
pkgs = import nixpkgs { }; pkgs = import nixpkgs {};
prs = builtins.fromJSON (builtins.readFile pulls); prs = builtins.fromJSON (builtins.readFile pulls);
prJobsets = pkgs.lib.mapAttrs (num: info: { prJobsets = pkgs.lib.mapAttrs (num: info:
enabled = 1; { enabled = 1;
hidden = false; hidden = false;
description = "PR ${num}: ${info.title}"; description = "PR ${num}: ${info.title}";
checkinterval = 300; checkinterval = 300;
@@ -15,7 +15,8 @@ let
keepnr = 1; keepnr = 1;
type = 1; type = 1;
flake = "gitlab:simple-nixos-mailserver/nixos-mailserver/merge-requests/${info.iid}/head"; flake = "gitlab:simple-nixos-mailserver/nixos-mailserver/merge-requests/${info.iid}/head";
}) prs; }
) prs;
mkFlakeJobset = branch: { mkFlakeJobset = branch: {
description = "Build ${branch} branch of Simple NixOS MailServer"; description = "Build ${branch} branch of Simple NixOS MailServer";
checkinterval = 300; checkinterval = 300;
@@ -30,9 +31,9 @@ let
}; };
desc = prJobsets // { desc = prJobsets // {
"main" = mkFlakeJobset "main"; "master" = mkFlakeJobset "master";
"nixos-26.05" = mkFlakeJobset "nixos-26.05"; "nixos-24.11" = mkFlakeJobset "nixos-24.11";
"nixos-25.11" = mkFlakeJobset "nixos-25.11"; "nixos-25.05" = mkFlakeJobset "nixos-25.05";
}; };
log = { log = {
@@ -40,14 +41,13 @@ let
jobsets = desc; jobsets = desc;
}; };
in in {
{ jobsets = pkgs.runCommand "spec-jobsets.json" {} ''
jobsets = pkgs.runCommand "spec-jobsets.json" { } '' cat >$out <<EOF
cat >$out <<'EOF'
${builtins.toJSON desc} ${builtins.toJSON desc}
EOF EOF
# This is to get nice .jobsets build logs on Hydra # This is to get nice .jobsets build logs on Hydra
cat >tmp <<'EOF' cat >tmp <<EOF
${builtins.toJSON log} ${builtins.toJSON log}
EOF EOF
${pkgs.jq}/bin/jq . tmp ${pkgs.jq}/bin/jq . tmp
+2 -2
View File
@@ -12,12 +12,12 @@
"type": 0, "type": 0,
"inputs": { "inputs": {
"nixexpr": { "nixexpr": {
"value": "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver main", "value": "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver master",
"type": "git", "type": "git",
"emailresponsible": false "emailresponsible": false
}, },
"nixpkgs": { "nixpkgs": {
"value": "https://github.com/NixOS/nixpkgs nixpkgs-unstable", "value": "https://github.com/NixOS/nixpkgs 0f920b05cbcdb8c0f3c5c4a8ea29f1f0065c7033 ",
"type": "git", "type": "git",
"emailresponsible": false "emailresponsible": false
}, },
+5 -7
View File
@@ -5,22 +5,20 @@
version: 2 version: 2
build: build:
os: ubuntu-24.04 os: ubuntu-22.04
tools: tools:
python: "3" python: "3"
apt_packages: apt_packages:
- curl - nix
- proot - proot
jobs: jobs:
pre_install: pre_install:
- curl -L https://github.com/DavHau/nix-portable/releases/latest/download/nix-portable-$(uname -m) > ./nix-portable - mkdir -p ~/.nix ~/.config/nix
- chmod +x ./nix-portable - echo "experimental-features = nix-command flakes" > ~/.config/nix/nix.conf
- ./nix-portable nix build --print-build-logs .#optionsDoc - proot -b ~/.nix:/nix /bin/sh -c "nix build -L .#optionsDoc && cp -v result docs/options.md"
- ./nix-portable nix store cat $(readlink result) > docs/options.md
sphinx: sphinx:
configuration: docs/conf.py configuration: docs/conf.py
fail_on_warning: true
formats: formats:
- pdf - 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"
+20 -16
View File
@@ -1,32 +1,34 @@
# ![Simple Nixos MailServer][logo] # ![Simple Nixos MailServer][logo]
![license](https://img.shields.io/badge/license-GPL3-brightgreen.svg) ![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 ## Release branches
We publish a branch for each NixOS release. Only matching branch versions are For each NixOS release, we publish a branch. You then have to use the
supported. SNM branch corresponding to your NixOS version.
* For NixOS 26.05 * For NixOS 25.05
* Use the [`nixos-26.05`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-25.11) branch * Use the [SNM branch `nixos-25.05`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-25.05)
* [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-26.05/) * [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-25.05/)
* [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-26.05/release-notes.html#nixos-26-05) * [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-25.05/release-notes.html#nixos-25-05)
* For NixOS 24.11
* Use the [SNM branch `nixos-24.11`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/nixos-24.11)
* [Documentation](https://nixos-mailserver.readthedocs.io/en/nixos-24.11/)
* [Release notes](https://nixos-mailserver.readthedocs.io/en/nixos-24.11/release-notes.html#nixos-24-11)
* For NixOS unstable * For NixOS unstable
* Use the [`main`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/main) branch * Use the [SNM branch `master`](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/tree/master)
* [Documentation](https://nixos-mailserver.readthedocs.io/en/latest/) * [Documentation](https://nixos-mailserver.readthedocs.io/en/latest/)
## Features ## Features
* [x] Continuous Integration Testing * [x] Continous Integration Testing
* [x] Multiple Domains * [x] Multiple Domains
* Postfix * Postfix
* [x] SMTP on port 25 * [x] SMTP on port 25
* [x] Submission TLS on port 465 * [x] Submission TLS on port 465
* [x] Submission StartTLS on port 587 * [x] Submission StartTLS on port 587
* [x] LMTP with Dovecot * [x] LMTP with Dovecot
* [x] DANE and MTA-STS validation
* [x] SMTP TLS Reports ([RFC 8460](https://www.rfc-editor.org/rfc/rfc8460))
* Dovecot * Dovecot
* [x] Maildir folders * [x] Maildir folders
* [x] IMAP with TLS on port 993 * [x] IMAP with TLS on port 993
@@ -42,8 +44,6 @@ supported.
* [x] Via ClamAV * [x] Via ClamAV
* DKIM Signing * DKIM Signing
* [x] Via Rspamd * [x] Via Rspamd
* [x] Automatic key generation
* [x] Multiple selectors per Domain
* User Management * User Management
* [x] Declarative user management * [x] Declarative user management
* [x] Declarative password management * [x] Declarative password management
@@ -55,8 +55,6 @@ supported.
* User Aliases * User Aliases
* [x] Regular aliases * [x] Regular aliases
* [x] Catch all aliases * [x] Catch all aliases
* Improve the Forwarding Experience
* [x] [Sender Rewriting Scheme](https://en.wikipedia.org/wiki/Sender_Rewriting_Scheme)
### In the future ### In the future
@@ -64,8 +62,14 @@ supported.
* [ ] [Autoconfig](https://web.archive.org/web/20210624004729/https://developer.mozilla.org/en-US/docs/Mozilla/Thunderbird/Autoconfiguration) * [ ] [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) * [ ] [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) * [ ] [Mobileconfig](https://support.apple.com/guide/profile-manager/distribute-profiles-manually-pmdbd71ebc9/mac)
* DKIM Signing
* [ ] Allow per domain selectors
* [ ] Allow passing DKIM signing keys
* Improve the Forwarding Experience * Improve the Forwarding Experience
* [ ] Support [ARC](https://en.wikipedia.org/wiki/Authenticated_Received_Chain) signing with [Rspamd](https://rspamd.com/doc/modules/arc.html) * [ ] Support [ARC](https://en.wikipedia.org/wiki/Authenticated_Received_Chain) signing with [Rspamd](https://rspamd.com/doc/modules/arc.html)
* [ ] Support [SRS](https://en.wikipedia.org/wiki/Sender_Rewriting_Scheme) with [postsrsd](https://github.com/roehling/postsrsd)
* User management
* [ ] Allow local and LDAP user to coexist
* OpenID Connect * OpenID Connect
* Depends on relevant clients adding support, e.g. [Thunderbird](https://bugzilla.mozilla.org/show_bug.cgi?id=1602166) * Depends on relevant clients adding support, e.g. [Thunderbird](https://bugzilla.mozilla.org/show_bug.cgi?id=1602166)
@@ -86,7 +90,7 @@ See the [How to Develop SNM](https://nixos-mailserver.readthedocs.io/en/latest/h
## Contributors ## 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 ### Alternative Implementations
+418 -871
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 # You can set these variables from the command line, and also
# from the environment for the first two. # from the environment for the first two.
SPHINXOPTS ?= --fail-on-warning SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build SPHINXBUILD ?= sphinx-build
SOURCEDIR = . SOURCEDIR = .
BUILDDIR = _build 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 Autodiscovery
============= =============
`RFC6186`_ defines how email clients can automatically discover a mail server's `RFC6186 <https://www.rfc-editor.org/rfc/rfc6186>`_ allows supporting email clients to automatically discover SMTP / IMAP addresses
SMTP and IMAP endpoints. To enable this, the following DNS records must be of the mailserver. For that, the following records are required:
configured:
.. csv-table:: Resource record set ================= ==== ==== ======== ====== ==== =================
:header: "Name", "TTL", "Type", "Priority", "Weight", "Port", "Value" Record TTL Type Priority Weight Port Value
:widths: 30, 5, 5, 5, 5, 5, 20 ================= ==== ==== ======== ====== ==== =================
_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. Please note that only a few MUAs currently implement this. For vendor-specific
_imaps._tcp.example.com., 3600, SRV, 10, 1, 993, mail.example.com. 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 where you have the server config (but that is already in a git
repository right?) repository right?)
Next you need to backup ``/var/vmail`` or whatever you have specified for the Next you need to backup ``/var/vmail`` or whatever you have specified
option :option:`mailserver.storage.path`. This is where all the mails reside. for the option ``mailDirectory``. This is where all the mails reside.
Good options are a cron job with ``rsync`` or ``scp``. But really anything Good options are a cron job with ``rsync`` or ``scp``. But really
works, as it is simply a folder with plenty of files in it. If your backup anything works, as it is simply a folder with plenty of files in it. If
solution does not preserve the owner of the files dont forget to ``chown`` them your backup solution does not preserve the owner of the files dont
to ``virtualMail:virtualMail`` if you copy them back (or whatever you specified forget to ``chown`` them to ``virtualMail:virtualMail`` if you copy them
as :option:`mailserver.storage.owner`, and :option:`mailserver.storage.group`). 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 ``dkimKeyDirectory``). If you should lose those dont
Finally you can (optionally) make a backup of ``/var/dkim`` (or whatever you worry, new ones will be created on the fly. But you will need to repeat
specified as :option:`mailserver.dkim.keyDirectory`). If you should lose those step ``B)5`` and correct all the ``dkim`` keys.
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.
+1 -1
View File
@@ -20,7 +20,7 @@
project = "NixOS Mailserver" project = "NixOS Mailserver"
copyright = "2022, NixOS Mailserver Contributors" copyright = "2022, NixOS Mailserver Contributors"
author = "NixOS Mailserver Contributors" author = "NixOS Mailserver Contributors"
version = "26.05"
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
-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 .. code:: nix
mailserver.accounts = { mailserver.loginAccounts = {
"user@example.com" = { "user@example.com" = {
aliases = [ "@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 If you're using `flakes <https://wiki.nixos.org/wiki/Flakes>`__, you can use
serve as an example to get started: 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: outputs = { self, nixpkgs, simple-nixos-mailserver }: {
nixosConfigurations = {
.. code-block:: console hostname = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
nix flake lock modules = [
nixos-rebuild --target-host root@mail.example.com --flake .#hostname switch simple-nixos-mailserver.nixosModule
{
mailserver = {
enable = true;
# ...
};
}
];
};
};
};
}
+24 -26
View File
@@ -4,7 +4,7 @@ Full text search
By default, when your IMAP client searches for an email containing some By default, when your IMAP client searches for an email containing some
text in its *body*, dovecot will read all your email sequentially. This 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 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_flatcurve``.
Enabling full text search Enabling full text search
~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -20,50 +20,48 @@ To enable indexing for full text search here is an example configuration.
enable = true; enable = true;
# index new email as they arrive # index new email as they arrive
autoIndex = true; autoIndex = true;
# only query index enforced = "body";
fallback = false;
}; };
}; };
} }
Disabling the :option:`mailserver.fullTextSearch.fallback` option tells dovecot The ``enforced`` parameter tells dovecot to fail any body search query that cannot
to fail any body search query that cannot use an index. This prevents Dovecot to use an index. This prevents dovecot to fall back to the IO-intensive brute
fall back to the IO-intensive brute force search. force search.
If you set :option:`mailserver.fullTextSearch.autoIndex` to ``false``, indices If you set ``autoIndex`` to ``false``, indices will be created when the IMAP client
will be created when the IMAP client issues a search query, so latency will issues a search query, so latency will be high.
be high.
Resource requirements Resource requirements
~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~
Indices created by the full text search feature can take more disk space than Indices created by the full text search feature can take more disk
the emails themselves. By default, they are kept within the maildir. When space than the emails themselves. By default, they are kept in the
enabling the full text search feature, it is recommended to move indices in a emails location. When enabling the full text search feature, it is
different location, such as (``/var/lib/dovecot/indices``) by configuring recommended to move indices in a different location, such as
:option:`mailserver.indexDir`. (``/var/lib/dovecot/indices``) by using the option
``mailserver.indexDir``.
.. warning:: .. warning::
When the value of the :option:`mailserver.indexDir` option is changed, all When the value of the ``indexDir`` option is changed, all dovecot
dovecot indices needs to be recreated: clients would need to resynchronize. 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 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 hours. If the indexer worker is killed or segfaults during indexation, it can
that it tried to allocate more memory than allowed. You can increase the default be that it tried to allocate more memory than allowed. You can increase the memory
memory limit through :option:`mailserver.fullTextSearch.memoryLimit`. limit by eg ``mailserver.fullTextSearch.memoryLimit = 2000`` (in MiB).
Mitigating resources requirements Mitigating resources requirements
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can: You can:
* exclude some headers from indexation with :option:`mailserver.fullTextSearch.headerExcludes` * exclude some headers from indexation with ``mailserver.fullTextSearch.headerExcludes``
* disable expensive token normalisation in :option:`mailserver.fullTextSearch.filters` * disable expensive token normalisation in ``mailserver.fullTextSearch.filters``
* disable automatic indexation for individual mailboxes by overriding * disable automatic indexation for some folders with
`fts_autoindex`_ on the mailbox level. This is exposed via ``mailserver.fullTextSearch.autoIndexExclude``. Folders can be specified by
:option:`mailserver.mailboxes`, where all default mailboxes are defined. name (``"Trash"``), by special use (``"\\Junk"``) or with a wildcard.
.. _fts_autoindex: https://doc.dovecot.org/main/core/plugins/fts.html#fts_autoindex
+2 -43
View File
@@ -38,8 +38,8 @@ You can then run the testsuite via
$ nix flake check -L $ nix flake check -L
Since Nix doesn't guarantee your machine have enough resources to run Since Nix doesn't garantee your machine have enough resources to run
all test VMs in parallel, some tests can fail. You would then have to all test VMs in parallel, some tests can fail. You would then haev to
run tests manually. For instance: run tests manually. For instance:
:: ::
@@ -64,44 +64,3 @@ To build the documentation, you need to enable `Nix Flakes
$ nix build .#documentation $ nix build .#documentation
$ xdg-open result/index.html $ xdg-open result/index.html
Manual migrations
-----------------
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.
.. code-block:: nix
{
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.
'';
}
];
}
The setup guide should always reference the latest `stateVersion`, since we
don't require any migration steps for new setups.
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.
+6 -25
View File
@@ -14,42 +14,23 @@ Welcome to NixOS Mailserver's documentation!
:maxdepth: 2 :maxdepth: 2
setup-guide setup-guide
advanced-configurations
howto-develop howto-develop
faq faq
release-notes release-notes
options options
migrations
.. toctree:: .. toctree::
:maxdepth: 1 :maxdepth: 1
:caption: Account backends
ldap
.. toctree::
:maxdepth: 1
:caption: Features
dkim
fts
srs
.. toctree::
:maxdepth: 0
:caption: How-to :caption: How-to
autodiscovery
backup-guide backup-guide
flakes add-radicale
add-roundcube
rspamd-tuning rspamd-tuning
fts
.. toctree:: flakes
:maxdepth: 0 autodiscovery
:caption: Integrations ldap
radicale
roundcube
Indices and tables 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 All related LDAP options are described in the `LDAP options section
managing a centralized directory of user and group information. It can be used <options.html#mailserver-ldap>`_ and the `LDAP test
to authenticate users and provide a single source of truth for email accounts <https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/master/tests/ldap.nix>`_
and aliases across mail services. 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
-129
View File
@@ -1,135 +1,6 @@
Release Notes 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 NixOS 25.05
----------- -----------
-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
+4 -5
View File
@@ -44,10 +44,9 @@ details the meaning of each symbol. You can tune the weight if a symbol if neede
services.rspamd.locals = { services.rspamd.locals = {
"groups.conf".text = '' "groups.conf".text = ''
symbols "FORGED_RECIPIENTS" { symbols {
weight = 0; "FORGED_RECIPIENTS" { weight = 0; }
} }'';
'';
}; };
Tune action thresholds Tune action thresholds
@@ -94,7 +93,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. 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 **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 .. 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";
};
};
};
}
+159 -232
View File
@@ -1,5 +1,3 @@
.. _setup-guide:
Setup Guide Setup Guide
=========== ===========
@@ -7,306 +5,235 @@ 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 run you through the most important steps to achieve a 10/10 score on
`<https://mail-tester.com>`_. `<https://mail-tester.com>`_.
Requirements What you need is:
~~~~~~~~~~~~
To set up a self-hosted mail server, you need the following: - a server running NixOS with a public IP
- a domain name.
* 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.
.. note:: .. note::
Below we'll assume that your server got assigned the public IP addresses In the following, we consider a server with the public IP ``1.2.3.4``
``192.0.2.1`` (IPv4) and ``2001:db8::1`` (IPv6) and that you control the and the domain ``example.com``.
``example.com`` domain.
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.
Here we set up ``mail.example.com`` as the forward hostname for your mail server Setup DNS A/AAAA records for 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.
Now edit the ``example.com`` zone and create the following DNS records: Add DNS records to the domain ``example.com`` with the following
entries
.. csv-table:: ==================== ===== ==== =============
:header: "Name", "TTL", "Type", "Value" Name (Subdomain) TTL Type Value
:widths: 30, 10, 10, 50 ==================== ===== ==== =============
``mail.example.com`` 10800 A ``1.2.3.4``
``mail.example.com`` 10800 AAAA ``2001::1``
==================== ===== ==== =============
mail.example.com., 3600, A, 192.0.2.1 If your server does not have an IPv6 address, you must skip the `AAAA` record.
mail.example.com., 3600, AAAA, 2001:db8::1
.. note:: You can check this with
If your server does not have an IPv6 address, you must skip the ``AAAA``
record.
Verify DNS record propagation ::
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Before we continue with the next step, we require that the forward DNS record $ nix-shell -p bind --command "host -t A mail.example.com"
has propagated. For that it's best to check an authoritative nameserver for mail.example.com has address 1.2.3.4
``example.com`` so that we don't look at cached DNS records.
.. code-block:: console $ nix-shell -p bind --command "host -t AAAA mail.example.com"
mail.example.com has address 2001::1
# Find the authoritative nameservers for example.com Note that it can take a while until a DNS entry is propagated. This
$ nix-shell -p dig --command "dig NS example.com +short" DNS entry is required for the Let's Encrypt certificate generation
ns1.example.org. (which is used in the below configuration example).
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.
Setup the server Setup the server
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
The following configuration describes a fairly complete mail server, capable The following describes a server setup that is fairly complete. Even
of sending and receiving mail for statically configured accounts. It includes though there are more possible options (see the `NixOS Mailserver
encrypted SMTP and IMAP services for secure delivery and retrieval, and relies options documentation <options.html>`_), these should be the most
on ACME HTTP-01 to automatically obtain and maintain a TLS certificate. common ones.
While `more options`_ are available, the configuration below covers the most .. code:: nix
common settings to get your mail server up and running.
.. _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-25.05/nixos-mailserver-nixos-25.05.tar.gz";
# To get the sha256 of the nixos-mailserver tarball, we can use the nix-prefetch-url command:
# release="nixos-25.05"; nix-prefetch-url "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/archive/${release}/nixos-mailserver-${release}.tar.gz" --unpack
sha256 = "0000000000000000000000000000000000000000000000000000";
})
];
.. literalinclude:: ./setup-example.nix mailserver = {
:language: nix enable = true;
fqdn = "mail.example.com";
domains = [ "example.com" ];
After a ``nixos-rebuild switch`` your server should be running all the necessary # A list of all login accounts. To create the password hashes, use
mail services. # 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 Setup all other DNS requirements
will configure reverse DNS so that your IP address points back to your hostname. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If your forward and reverse DNS do not match, many mail servers will reject or Set rDNS (reverse DNS) entry for server
flag your emails as spam, severely impairing delivery. ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Your server provider should allow you to configure reverse DNS (PTR record) Wherever you have rented your server, you should be able to set reverse
records for the IP addresses you control, typically through their control panel DNS entries for the IPs you own:
or account management interface:
- Configure ``192.0.2.1`` to point to ``mail.example.com.`` - Add an entry resolving IPv4 address ``1.2.3.4`` to ``mail.example.com``.
- Configure ``2001:db8::1`` to point to ``mail.example.com.``, if you have IPv6 - Add an entry resolving IPv6 ``2001::1`` to ``mail.example.com``. Again, this
addressing must be skipped if your server does not have an IPv6 address.
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.
.. warning:: .. warning::
We don't recommend setting up a mail server if you are unable to configure We don't recommend setting up a mail server if you are not able to
reverse DNS on your public IP addresses because mails would inevitable be set a reverse DNS on your public IP because sent emails would be
marked as spam. Note that many residential ISP providers don't allow you to mostly marked as spam. Note that many residential ISP providers
set a reverse DNS entry and prohibit sending mail through policy blocklists don't allow you to set a reverse DNS entry.
like Spamhaus PBL.
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" $ nix-shell -p bind --command "host 1.2.3.4"
mail.example.com. 4.3.2.1.in-addr.arpa domain name pointer mail.example.com.
$ nix-shell -p dig --command "dig -x 2001:db8::1 +short" $ nix-shell -p bind --command "host 2001::1"
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.0.0.0.0.1.0.0.2.ip6.arpa domain name pointer mail.example.com.
Note that it can take a while until a DNS entry is propagated.
Set a ``MX`` record
^^^^^^^^^^^^^^^^^^^
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. Note that it can take a while until a DNS entry is propagated.
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.
.. code-block:: console Set a ``SPF`` record
^^^^^^^^^^^^^^^^^^^^
$ nix-shell -p dig --command "dig @ns1.example.org MX example.com +short" Add a `SPF <https://en.wikipedia.org/wiki/Sender_Policy_Framework>`_
10 mail.example.com. 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 You can check this with
behalf of a domain name.
.. _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 $ nix-shell -p bind --command "host -t TXT example.com"
in the previous step. Finishing with ``-all`` indicates that without any match example.com descriptive text "v=spf1 a:mail.example.com -all"
the mail should be rejected.
.. csv-table:: Note that it can take a while until a DNS entry is propagated.
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
example.com., 86400, TXT, v=spf1 mx -all Set ``DKIM`` signature
^^^^^^^^^^^^^^^^^^^^^^
.. code-block:: console On your server, the ``rspamd`` systemd service generated a file
containing your DKIM public key in the file
``/var/dkim/example.com.mail.txt``. The content of this file looks
like
$ nix-shell -p dig --command "dig TXT example.com +short" ::
v=spf1 mx -all
DKIM record
^^^^^^^^^^^
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.
.. _DKIM: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail
Now, check ``/var/dkim/example.com.mail.txt``, which contains the proposed DNS
record for the ``mail`` DKIM selector.
.. code-block:: none
mail._domainkey IN TXT ( "v=DKIM1; k=rsa; " mail._domainkey IN TXT ( "v=DKIM1; k=rsa; "
"p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv7hSess/UgEjaaq/NDn5KtW2iZzYljhf45DH3tN/kqcJ04JJk/Z1rS7CMJQ/pYZSSnQOju0H25uOtODvhqXPDxDdtCyDSrx54z/38lGNtA76/iWy/ikjb9hEkb2k3HuKex3P4KhhOC1pytDEFnh/T2aBxPNOigc/cpqm1U9RbnAwvArtx9dgOAgiV8rOIgPgyrPw1B3cJG3hgFYU2" "p=<really-long-key>" ) ; ----- DKIM key mail for nixos.org
"GwXMoiFQPgwm7bkjelmThqXozA7jFJfnYt49jjrIYCv8X/nQx9cNpVAv2852mhU/3uuy6sa4MPjT6RiK9BJCMyDnqSpTPCjIubL4VhGCuzp7RPBkayWnlaH0X8PWGq6BQ0eBwIDAQAB"
) ;
Based on the content of this file, we can create the DKIM TXT record for the where ``really-long-key`` is your public key.
``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.
.. csv-table:: Based on the content of this file, we can add a ``DKIM`` record to the
:header: "Name", "TTL", "Type", "Value" domain ``example.com``.
:widths: 30, 10, 10, 50
mail._domainkey.example.com., 86400, TXT, v=DKIM1; k=rsa; p=MIIBIjANBgk...Q0eBwIDAQAB =========================== ===== ==== ================================================
Name (Subdomain) TTL Type Value
=========================== ===== ==== ================================================
mail._domainkey.example.com 10800 TXT ``v=DKIM1; k=rsa; s=email; p=<really-long-key>``
=========================== ===== ==== ================================================
.. code-block:: console You can check this with
$ nix-shell -p dig --command "dig @ns1.example.org TXT mail._domainkey.example.com +short" ::
"v=DKIM1; k=rsa; p=MIIBIjANBgk...Q0eBwIDAQAB"
$ nix-shell -p bind --command "host -t txt mail._domainkey.example.com"
mail._domainkey.example.com descriptive text "v=DKIM1;p=<really-long-key>"
DMARC record Note that it can take a while until a DNS entry is propagated.
^^^^^^^^^^^^
Finally, DMARC lets you define a policy for how strictly SPF and DKIM should be Set a ``DMARC`` record
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:: Add a ``DMARC`` record to the domain ``example.com``.
:header: "Name", "TTL", "Type", "Value"
:widths: 30, 10, 10, 50
_dmarc.example.com., 86400, TXT, v=DMARC1; p=none; ======================== ===== ==== ====================
Name (Subdomain) TTL Type Value
======================== ===== ==== ====================
_dmarc.example.com 10800 TXT ``v=DMARC1; p=none``
======================== ===== ==== ====================
Verify propagation one final time. You can check this with
.. code-block:: console ::
$ nix-shell -p dig --command "dig @ns1.example.org TXT _dmarc.example.com +short" $ nix-shell -p bind --command "host -t TXT _dmarc.example.com"
"v=DMARC1; p=none" _dmarc.example.com descriptive text "v=DMARC1; p=none"
Note that it can take a while until a DNS entry is propagated.
Test your Setup Test your Setup
~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~
Write an email to your aunt — shes been waiting far too long for your reply, Write an email to your aunt (who has been waiting for your reply far too
and this is your chance to finally make her day. Or, if you prefer a less long), and sign up for some of the finest newsletters the Internet has.
emotional test, send a message to `mail-tester.com`_ to see how your outgoing Maybe you want to sign up for the `SNM Announcement
mail scores. List <https://www.freelists.org/list/snm>`__?
You can also let `MXToolbox`_ take a peek at your setup. If you followed the Besides that, you can send an email to
steps carefully, everything should be working perfectly! `mail-tester.com <https://www.mail-tester.com/>`__ and see how you
score, and let `mxtoolbox.com <http://mxtoolbox.com/>`__ take a look at
.. _mail-tester.com: https://mail-tester.com/ your setup, but if you followed the steps closely then everything should
.. _MXToolbox: https://mxtoolbox.com/ be awesome!
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/
-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
+30 -13
View File
@@ -19,15 +19,15 @@
"flake-compat": { "flake-compat": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1767039857, "lastModified": 1747046372,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
"owner": "NixOS", "owner": "edolstra",
"repo": "flake-compat", "repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "edolstra",
"repo": "flake-compat", "repo": "flake-compat",
"type": "github" "type": "github"
} }
@@ -43,11 +43,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1778507602, "lastModified": 1742649964,
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=", "narHash": "sha256-DwOTp7nvfi8mRfuL1escHDXabVXFGT1VlPD1JHrtrco=",
"owner": "cachix", "owner": "cachix",
"repo": "git-hooks.nix", "repo": "git-hooks.nix",
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a", "rev": "dcf5072734cb576d2b0c59b2ac44f5050b5eac82",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -79,16 +79,32 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1779622335, "lastModified": 1747179050,
"narHash": "sha256-ViA62qtL5za7V3d5I8OA9q9JcFhsVAiL5jVHwEclWqk=", "narHash": "sha256-qhFMmDkeJX9KJwr5H32f1r7Prs7XbQWtO0h3V0a0rFY=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "705e9929918b43bd7b715dc0a878ac870449bb03", "rev": "adaa24fbf46737f3f1b5497bf64bae750f82942e",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "NixOS", "owner": "NixOS",
"ref": "nixos-26.05-small", "ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-25_05": {
"locked": {
"lastModified": 1747610100,
"narHash": "sha256-rpR5ZPMkWzcnCcYYo3lScqfuzEw5Uyfh+R0EKZfroAc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ca49c4304acf0973078db0a9d200fd2bae75676d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }
@@ -98,7 +114,8 @@
"blobs": "blobs", "blobs": "blobs",
"flake-compat": "flake-compat", "flake-compat": "flake-compat",
"git-hooks": "git-hooks", "git-hooks": "git-hooks",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs",
"nixpkgs-25_05": "nixpkgs-25_05"
} }
} }
}, },
+33 -62
View File
@@ -4,7 +4,7 @@
inputs = { inputs = {
flake-compat = { flake-compat = {
# for shell.nix compat # for shell.nix compat
url = "github:NixOS/flake-compat"; url = "github:edolstra/flake-compat";
flake = false; flake = false;
}; };
git-hooks = { git-hooks = {
@@ -12,22 +12,15 @@
inputs.flake-compat.follows = "flake-compat"; inputs.flake-compat.follows = "flake-compat";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05-small"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs-25_05.url = "github:NixOS/nixpkgs/nixos-25.05";
blobs = { blobs = {
url = "gitlab:simple-nixos-mailserver/blobs"; url = "gitlab:simple-nixos-mailserver/blobs";
flake = false; flake = false;
}; };
}; };
outputs = outputs = { self, blobs, git-hooks, nixpkgs, nixpkgs-25_05, ... }: let
{
self,
blobs,
git-hooks,
nixpkgs,
...
}:
let
lib = nixpkgs.lib; lib = nixpkgs.lib;
system = "x86_64-linux"; system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
@@ -37,6 +30,11 @@
nixpkgs = nixpkgs; nixpkgs = nixpkgs;
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
} }
{
name = "25.05";
nixpkgs = nixpkgs-25_05;
pkgs = nixpkgs-25_05.legacyPackages.${system};
}
]; ];
testNames = [ testNames = [
"clamav" "clamav"
@@ -46,16 +44,13 @@
"multiple" "multiple"
]; ];
genTest = genTest = testName: release: let
testName: release:
let
pkgs = release.pkgs; pkgs = release.pkgs;
nixos-lib = import (release.nixpkgs + "/nixos/lib") { nixos-lib = import (release.nixpkgs + "/nixos/lib") {
inherit (pkgs) lib; inherit (pkgs) lib;
}; };
in in {
{ name = "${testName}-${builtins.replaceStrings ["."] ["_"] release.name}";
name = "${testName}-${builtins.replaceStrings [ "." ] [ "_" ] release.name}";
value = nixos-lib.runTest { value = nixos-lib.runTest {
hostPkgs = pkgs; hostPkgs = pkgs;
imports = [ ./tests/${testName}.nix ]; imports = [ ./tests/${testName}.nix ];
@@ -70,13 +65,13 @@
# external-21_05 = <derivation>; # external-21_05 = <derivation>;
# ... # ...
# } # }
allTests = lib.listToAttrs (lib.flatten (map (t: map (r: genTest t r) releases) testNames)); allTests = lib.listToAttrs (
lib.flatten (map (t: map (r: genTest t r) releases) testNames));
mailserverModule = import ./.; mailserverModule = import ./.;
# Generate a MarkDown file describing the options of the NixOS mailserver module # Generate a MarkDown file describing the options of the NixOS mailserver module
optionsDoc = optionsDoc = let
let
eval = lib.evalModules { eval = lib.evalModules {
modules = [ modules = [
mailserverModule mailserverModule
@@ -84,23 +79,21 @@
_module.check = false; _module.check = false;
mailserver = { mailserver = {
fqdn = "mx.example.com"; fqdn = "mx.example.com";
systemDomain = "example.com";
domains = [ domains = [
"example.com" "example.com"
]; ];
dmarcReporting = {
organizationName = "Example Corp";
domain = "example.com";
};
}; };
} }
]; ];
}; };
options = builtins.toFile "options.json" ( options = builtins.toFile "options.json" (builtins.toJSON
builtins.toJSON ( (lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver")
lib.filter (opt: opt.visible && !opt.internal && lib.head opt.loc == "mailserver") ( (lib.optionAttrSetToDocList eval.options)));
lib.optionAttrSetToDocList eval.options in pkgs.runCommand "options.md" { buildInputs = [pkgs.python3Minimal]; } ''
)
)
);
in
pkgs.runCommand "options.md" { buildInputs = [ pkgs.python3Minimal ]; } ''
echo "Generating options.md from ${options}" echo "Generating options.md from ${options}"
python ${./scripts/generate-options.py} ${options} > $out python ${./scripts/generate-options.py} ${options} > $out
echo $out echo $out
@@ -108,23 +101,15 @@
documentation = pkgs.stdenv.mkDerivation { documentation = pkgs.stdenv.mkDerivation {
name = "documentation"; name = "documentation";
src = lib.sourceByRegex ./docs [ src = lib.sourceByRegex ./docs ["logo\\.png" "conf\\.py" "Makefile" ".*\\.rst"];
"logo\\.png" buildInputs = [(
"conf\\.py" pkgs.python3.withPackages (p: with p; [
"Makefile"
".*\\.nix"
".*\\.rst"
];
buildInputs = [
(pkgs.python3.withPackages (
p: with p; [
sphinx sphinx
sphinx-rtd-theme sphinx_rtd_theme
myst-parser myst-parser
linkify-it-py linkify-it-py
] ])
)) )];
];
buildPhase = '' buildPhase = ''
cp ${optionsDoc} options.md cp ${optionsDoc} options.md
# Workaround for https://github.com/sphinx-doc/sphinx/issues/3451 # Workaround for https://github.com/sphinx-doc/sphinx/issues/3451
@@ -136,8 +121,7 @@
''; '';
}; };
in in {
{
nixosModules = rec { nixosModules = rec {
mailserver = mailserverModule; mailserver = mailserverModule;
default = mailserver; default = mailserver;
@@ -150,13 +134,12 @@
checks.${system} = allTests // { checks.${system} = allTests // {
pre-commit = git-hooks.lib.${system}.run { pre-commit = git-hooks.lib.${system}.run {
src = ./.; src = ./.;
package = pkgs.prek;
hooks = { hooks = {
# docs # docs
markdownlint = { markdownlint = {
enable = true; enable = true;
settings.configuration = { settings.configuration = {
# Max line length, doesn't seem to correctly account for lines containing links # Max line length, doesn't seem to correclty account for lines containing links
# https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md # https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md
MD013 = false; MD013 = false;
}; };
@@ -168,15 +151,8 @@
files = "\\.rst$"; files = "\\.rst$";
}; };
# spell checking
typos = {
enable = true;
settings.configPath = ".typos.toml";
};
# nix # nix
deadnix.enable = true; deadnix.enable = true;
nixfmt.enable = true;
# python # python
pyright.enable = true; pyright.enable = true;
@@ -207,16 +183,11 @@
}; };
devShells.${system}.default = pkgs.mkShellNoCC { devShells.${system}.default = pkgs.mkShellNoCC {
inputsFrom = [ documentation ]; inputsFrom = [ documentation ];
packages = packages = with pkgs; [
with pkgs;
[
glab glab
] ] ++ self.checks.${system}.pre-commit.enabledPackages;
++ self.checks.${system}.pre-commit.enabledPackages;
shellHook = self.checks.${system}.pre-commit.shellHook; shellHook = self.checks.${system}.pre-commit.shellHook;
}; };
devShell.${system} = self.devShells.${system}.default; # compatibility devShell.${system} = self.devShells.${system}.default; # compatibility
formatter.${system} = pkgs.nixfmt-tree;
}; };
} }
+15 -143
View File
@@ -1,146 +1,18 @@
{ config, lib, ... }:
{ {
config, assertions = lib.optionals config.mailserver.ldap.enable [
lib, {
... assertion = config.mailserver.loginAccounts == {};
}: message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.loginAccounts";
}
let {
mailserverRelease = "26.05"; assertion = config.mailserver.extraVirtualAliases == {};
nixpkgsRelease = lib.trivial.release; message = "When the LDAP support is enable (mailserver.ldap.enable = true), it is not possible to define mailserver.extraVirtualAliases";
releaseMismatch = }
config.mailserver.enableNixpkgsReleaseCheck && mailserverRelease != nixpkgsRelease; ] ++ lib.optionals (config.mailserver.enable && config.mailserver.certificateScheme != "acme") [
in {
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";
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.
'';
}
]
);
} }
+16 -32
View File
@@ -14,43 +14,28 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib, ... }:
config,
lib,
...
}:
let let
cfg = config.mailserver.borgbackup; cfg = config.mailserver.borgbackup;
methodFragment = lib.optional (cfg.compression.method != null) cfg.compression.method; methodFragment = lib.optional (cfg.compression.method != null) cfg.compression.method;
autoFragment = autoFragment =
if cfg.compression.auto && cfg.compression.method == null then if cfg.compression.auto && cfg.compression.method == null
throw "compression.method must be set when using auto." then throw "compression.method must be set when using auto."
else else lib.optional cfg.compression.auto "auto";
lib.optional cfg.compression.auto "auto";
levelFragment = levelFragment =
if cfg.compression.level != null && cfg.compression.method == null then if cfg.compression.level != null && cfg.compression.method == null
throw "compression.method must be set when using compression.level." then throw "compression.method must be set when using compression.level."
else else lib.optional (cfg.compression.level != null) (toString cfg.compression.level);
lib.optional (cfg.compression.level != null) (toString cfg.compression.level); compressionFragment = lib.concatStringsSep "," (lib.flatten [autoFragment methodFragment levelFragment]);
compressionFragment = lib.concatStringsSep "," (
lib.flatten [
autoFragment
methodFragment
levelFragment
]
);
compression = lib.optionalString (compressionFragment != "") "--compression ${compressionFragment}"; compression = lib.optionalString (compressionFragment != "") "--compression ${compressionFragment}";
encryptionFragment = cfg.encryption.method; encryptionFragment = cfg.encryption.method;
passphraseFile = lib.escapeShellArg cfg.encryption.passphraseFile; passphraseFile = lib.escapeShellArg cfg.encryption.passphraseFile;
passphraseFragment = lib.optionalString (cfg.encryption.method != "none") ( passphraseFragment = lib.optionalString (cfg.encryption.method != "none")
if cfg.encryption.passphraseFile != null then (if cfg.encryption.passphraseFile != null then ''env BORG_PASSPHRASE="$(cat ${passphraseFile})"''
''env BORG_PASSPHRASE="$(cat ${passphraseFile})"'' else throw "passphraseFile must be set when using encryption.");
else
throw "passphraseFile must be set when using encryption."
);
locations = lib.escapeShellArgs cfg.locations; locations = lib.escapeShellArgs cfg.locations;
name = lib.escapeShellArg cfg.name; name = lib.escapeShellArg cfg.name;
@@ -66,15 +51,14 @@ let
borgScript = '' borgScript = ''
export BORG_REPO=${repoLocation} export BORG_REPO=${repoLocation}
${cmdPreexec} ${cmdPreexec}
${passphraseFragment} ${lib.getExe' config.services.borgbackup.package "borg"} init ${extraInitArgs} --encryption ${encryptionFragment} || true ${passphraseFragment} ${pkgs.borgbackup}/bin/borg init ${extraInitArgs} --encryption ${encryptionFragment} || true
${passphraseFragment} ${lib.getExe' config.services.borgbackup.package "borg"} create ${extraCreateArgs} ${compression} ::${name} ${locations} ${passphraseFragment} ${pkgs.borgbackup}/bin/borg create ${extraCreateArgs} ${compression} ::${name} ${locations}
${cmdPostexec} ${cmdPostexec}
''; '';
in in {
{
config = lib.mkIf (config.mailserver.enable && cfg.enable) { config = lib.mkIf (config.mailserver.enable && cfg.enable) {
environment.systemPackages = [ environment.systemPackages = with pkgs; [
config.services.borgbackup.package borgbackup
]; ];
systemd.services.borgbackup = { systemd.services.borgbackup = {
+25 -46
View File
@@ -14,64 +14,43 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib }:
config,
pkgs,
lib,
...
}:
let let
cfg = config.mailserver; cfg = config.mailserver;
in 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 = # key :: PATH
if withACME then keyPath = if cfg.certificateScheme == "manual"
"${config.security.acme.certs.${cfg.x509.useACMEHost}.directory}/fullchain.pem" then cfg.keyFile
else else if cfg.certificateScheme == "selfsigned"
cfg.x509.certificateFile; 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 = passwordFiles = let
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; mkHashFile = name: hash: pkgs.writeText "${builtins.hashString "sha256" name}-password-hash" hash;
in in
lib.mapAttrs ( lib.mapAttrs (name: value:
name: value: if value.hashedPasswordFile == null then
if value.hashedPasswordFile != null then
value.hashedPasswordFile
else if value.hashedPassword != null then
builtins.toString (mkHashFile name value.hashedPassword) builtins.toString (mkHashFile name value.hashedPassword)
else else value.hashedPasswordFile) cfg.loginAccounts;
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
);
# Appends the LDAP bind password to files to avoid writing this # Appends the LDAP bind password to files to avoid writing this
# password into the Nix store. # password into the Nix store.
appendLdapBindPwd = appendLdapBindPwd = {
{ name, file, prefix, suffix ? "", passwordFile, destination
name, }: pkgs.writeScript "append-ldap-bind-pwd-in-${name}" ''
file,
prefix,
suffix ? "",
passwordFile,
destination,
}:
pkgs.writeScript "append-ldap-bind-pwd-in-${name}"
# bash
''
#!${pkgs.stdenv.shell} #!${pkgs.stdenv.shell}
set -euo pipefail set -euo pipefail
-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
];
}
+298 -449
View File
@@ -14,45 +14,73 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { options, config, pkgs, lib, ... }:
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix { with (import ./common.nix { inherit config pkgs lib; });
inherit
config
options
pkgs
lib
;
});
let let
inherit (lib)
attrNames
concatMapStringsSep
filterAttrs
mapAttrs'
mkForce
mkIf
mkMerge
nameValuePair
;
cfg = config.mailserver; cfg = config.mailserver;
passwdDir = "/run/dovecot2"; passwdDir = "/run/dovecot2";
passwdFile = "${passwdDir}/passwd"; passwdFile = "${passwdDir}/passwd";
userdbFile = "${passwdDir}/userdb"; userdbFile = "${passwdDir}/userdb";
# This file contains the ldap bind password
ldapConfFile = "${passwdDir}/dovecot-ldap.conf.ext";
boolToYesNo = x: if x then "yes" else "no";
listToLine = lib.concatStringsSep " ";
listToMultiAttrs = keyPrefix: attrs: lib.listToAttrs (lib.imap1 (n: x: {
name = "${keyPrefix}${if n==1 then "" else toString n}";
value = x;
}) attrs);
genPasswdScript = maildirLayoutAppendix = lib.optionalString cfg.useFsLayout ":LAYOUT=fs";
pkgs.writeScript "generate-password-file" maildirUTF8FolderNames = lib.optionalString cfg.useUTF8FolderNames ":UTF-8";
# bash
'' # maildir in format "/${domain}/${user}"
dovecotMaildir =
"maildir:${cfg.mailDirectory}/%{domain}/%{username}${maildirLayoutAppendix}${maildirUTF8FolderNames}"
+ (lib.optionalString (cfg.indexDir != null)
":INDEX=${cfg.indexDir}/%{domain}/%{username}"
);
postfixCfg = config.services.postfix;
ldapConfig = pkgs.writeTextFile {
name = "dovecot-ldap.conf.ext.template";
text = ''
ldap_version = 3
uris = ${lib.concatStringsSep " " cfg.ldap.uris}
${lib.optionalString cfg.ldap.startTls ''
tls = yes
''}
tls_require_cert = hard
tls_ca_cert_file = ${cfg.ldap.tlsCAFile}
dn = ${cfg.ldap.bind.dn}
sasl_bind = no
auth_bind = yes
base = ${cfg.ldap.searchBase}
scope = ${mkLdapSearchScope cfg.ldap.searchScope}
${lib.optionalString (cfg.ldap.dovecot.userAttrs != null) ''
user_attrs = ${cfg.ldap.dovecot.userAttrs}
''}
user_filter = ${cfg.ldap.dovecot.userFilter}
${lib.optionalString (cfg.ldap.dovecot.passAttrs != "") ''
pass_attrs = ${cfg.ldap.dovecot.passAttrs}
''}
pass_filter = ${cfg.ldap.dovecot.passFilter}
'';
};
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} #!${pkgs.stdenv.shell}
set -euo pipefail set -euo pipefail
@@ -65,16 +93,7 @@ let
# Prevent world-readable password files, even temporarily. # Prevent world-readable password files, even temporarily.
umask 077 umask 077
prepend_scheme() { for f in ${builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.loginAccounts)}; do
case "$1" in
{*}*) printf '%s' "$1" ;;
*) printf '{CRYPT}%s' "$1" ;;
esac
}
for f in ${
builtins.toString (lib.mapAttrsToList (name: _: passwordFiles."${name}") cfg.accounts)
}; do
if [ ! -f "$f" ]; then if [ ! -f "$f" ]; then
echo "Expected password hash file $f does not exist!" echo "Expected password hash file $f does not exist!"
exit 1 exit 1
@@ -82,258 +101,114 @@ let
done done
cat <<EOF > ${passwdFile} cat <<EOF > ${passwdFile}
${lib.concatStringsSep "\n" ( ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: _:
lib.mapAttrsToList ( "${name}:${"$(head -n 1 ${passwordFiles."${name}"})"}::::::"
name: _: ) cfg.loginAccounts)}
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 EOF
chown dovecot2:dovecot2 ${passwdFile}
cat <<EOF > ${userdbFile} cat <<EOF > ${userdbFile}
${lib.concatStringsSep "\n" ( ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value:
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}:::::::" "${name}:::::::"
+ lib.optionalString (value.quota != null) "userdb_quota/user/storage_size=${value.quota}" + lib.optionalString (value.quota != null) "userdb_quota_rule=*:storage=${value.quota}"
) cfg.accounts ) cfg.loginAccounts)}
)}
EOF EOF
chown dovecot2:dovecot2 ${userdbFile}
''; '';
junkMailboxes = builtins.attrNames ( junkMailboxes = builtins.attrNames (lib.filterAttrs (_: v: v ? "specialUse" && v.specialUse == "Junk") cfg.mailboxes);
lib.filterAttrs (_: v: v ? "special_use" && v.special_use == "\\Junk") cfg.mailboxes
);
junkMailboxNumber = builtins.length junkMailboxes; 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 ""; junkMailboxName = if junkMailboxNumber == 1 then builtins.elemAt junkMailboxes 0 else "";
mkLdapSearchScope = mkLdapSearchScope = scope: (
scope: if scope == "sub" then "subtree"
( else if scope == "one" then "onelevel"
if scope == "sub" then else scope
"subtree"
else if scope == "one" then
"onelevel"
else
scope
); );
dovecotModules = [
pkgs.dovecot_pigeonhole
] ++ lib.optional cfg.fullTextSearch.enable pkgs.dovecot-fts-flatcurve;
# Remove and assume `false` after NixOS 25.05
haveDovecotModulesOption = options.services.dovecot2 ? "modules" && (options.services.dovecot2.modules.visible or true);
ftsPluginSettings = {
fts = "flatcurve";
fts_languages = listToLine cfg.fullTextSearch.languages;
fts_tokenizers = listToLine [ "generic" "email-address" ];
fts_tokenizer_email_address = "maxlen=100"; # default 254 too large for Xapian
fts_flatcurve_substring_search = boolToYesNo cfg.fullTextSearch.substringSearch;
fts_filters = listToLine cfg.fullTextSearch.filters;
fts_header_excludes = listToLine cfg.fullTextSearch.headerExcludes;
fts_autoindex = boolToYesNo cfg.fullTextSearch.autoIndex;
fts_enforced = cfg.fullTextSearch.enforced;
} // (listToMultiAttrs "fts_autoindex_exclude" cfg.fullTextSearch.autoIndexExclude);
in in
{ {
config = lib.mkIf cfg.enable { config = with cfg; lib.mkIf enable {
assertions = [ assertions = [
{ {
assertion = junkMailboxNumber == 1; 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 = warnings =
lib.optional (lib.optional (
( (builtins.length cfg.fullTextSearch.languages > 1) &&
(builtins.length cfg.fullTextSearch.languages > 1) (builtins.elem "stopwords" cfg.fullTextSearch.filters)
&& (builtins.elem "stopwords" cfg.fullTextSearch.filters) ) ''
)
''
Using stopwords in `mailserver.fullTextSearch.filters` with multiple Using stopwords in `mailserver.fullTextSearch.filters` with multiple
languages in `mailserver.fullTextSearch.languages` configured WILL languages in `mailserver.fullTextSearch.languages` configured WILL
cause some searches to fail. cause some searches to fail.
The recommended solution is to NOT use the stopword filter when The recommended solution is to NOT use the stopword filter when
multiple languages are present in the configuration. multiple languages are present in the configuration.
''; '')
;
security.acme.certs = lib.mkIf withACME { # for sieve-test. Shelling it in on demand usually doesnt' work, as it reads
${cfg.x509.useACMEHost} = { # the global config and tries to open shared libraries configured in there,
reloadServices = [ "dovecot.service" ]; # which are usually not compatible.
};
};
# Dovecot modules
environment.systemPackages = [ environment.systemPackages = [
pkgs.dovecot_pigeonhole pkgs.dovecot_pigeonhole
]; ] ++ lib.optionals (!haveDovecotModulesOption) dovecotModules;
# For compatibility with python imaplib # For compatibility with python imaplib
environment.etc."dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules"; environment.etc = lib.mkIf (!haveDovecotModulesOption) {
"dovecot/modules".source = "/run/current-system/sw/lib/dovecot/modules";
};
services.dovecot2 = { services.dovecot2 = lib.mkMerge [{
enable = true; enable = true;
package = pkgs.dovecot; # pin over stateVersion logic in nixox 26.05 enableImap = enableImap || enableImapSsl;
enablePAM = mkForce false; enablePop3 = enablePop3 || enablePop3Ssl;
enablePAM = false;
enableQuota = true;
mailGroup = vmailGroupName;
mailUser = vmailUserName;
mailLocation = dovecotMaildir;
sslServerCert = certificatePath;
sslServerKey = keyPath;
enableLmtp = true;
mailPlugins.globally.enable = lib.optionals cfg.fullTextSearch.enable [
"fts"
"fts_flatcurve"
];
protocols = lib.optional cfg.enableManageSieve "sieve";
sieve.pipeBins = map lib.getExe [ pluginSettings = {
(pkgs.writeShellScriptBin "rspamd-learn-ham.sh" "exec ${lib.getExe' config.services.rspamd.package "rspamc"} -h /run/rspamd/worker-controller.sock learn_ham") sieve = "file:${cfg.sieveDirectory}/%{user}/scripts;active=${cfg.sieveDirectory}/%{user}/active.sieve";
(pkgs.writeShellScriptBin "rspamd-learn-spam.sh" "exec ${lib.getExe' config.services.rspamd.package "rspamc"} -h /run/rspamd/worker-controller.sock learn_spam") sieve_default = "file:${cfg.sieveDirectory}/%{user}/default.sieve";
sieve_default_name = "default";
} // (lib.optionalAttrs cfg.fullTextSearch.enable ftsPluginSettings);
sieve = {
extensions = [
"fileinto"
]; ];
# https://doc.dovecot.org/2.4.3/core/settings/syntax.html scripts.after = builtins.toFile "spam.sieve" ''
# 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";
# server identity
hostname = cfg.fqdn;
# 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;
}
// 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"; require "fileinto";
if header :is "X-Spam" "Yes" { if header :is "X-Spam" "Yes" {
@@ -341,212 +216,186 @@ in
stop; 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" = { pipeBins = map lib.getExe [
# managesieve (pkgs.writeShellScriptBin "rspamd-learn-ham.sh"
type = "personal"; "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_ham")
# Upstream default, but we want to be explicit about it (pkgs.writeShellScriptBin "rspamd-learn-spam.sh"
# https://doc.dovecot.org/main/core/plugins/sieve.html#script-storage-type-personal "exec ${pkgs.rspamd}/bin/rspamc -h /run/rspamd/worker-controller.sock learn_spam")
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; imapsieve.mailbox = [
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 = { name = junkMailboxName;
fts = true; causes = [ "COPY" "APPEND" ];
fts_flatcurve = true; before = ./dovecot/imap_sieve/report-spam.sieve;
}; }
{
"service indexer-worker" = mkIf (cfg.fullTextSearch.memoryLimit != null) { name = "*";
vsz_limit = "${toString cfg.fullTextSearch.memoryLimit} MB"; from = junkMailboxName;
}; causes = [ "COPY" ];
before = ./dovecot/imap_sieve/report-ham.sieve;
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 mailboxes = cfg.mailboxes;
)
)) extraConfig = ''
(mkIf cfg.debug.dovecot { #Extra Config
mail_debug = true; ${lib.optionalString debug ''
# https://doc.dovecot.org/2.4.3/core/config/events/filter.html#common-unified-filter-language mail_debug = yes
log_debug = "category=ssl OR category=auth"; auth_debug = yes
verbose_ssl = yes
''}
${lib.optionalString (cfg.enableImap || cfg.enableImapSsl) ''
service imap-login {
inet_listener imap {
${if cfg.enableImap then ''
port = 143
'' else ''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''}
}
inet_listener imaps {
${if cfg.enableImapSsl then ''
port = 993
ssl = yes
'' else ''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''}
}
}
''}
${lib.optionalString (cfg.enablePop3 || cfg.enablePop3Ssl) ''
service pop3-login {
inet_listener pop3 {
${if cfg.enablePop3 then ''
port = 110
'' else ''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''}
}
inet_listener pop3s {
${if cfg.enablePop3Ssl then ''
port = 995
ssl = yes
'' else ''
# see https://dovecot.org/pipermail/dovecot/2010-March/047479.html
port = 0
''}
}
}
''}
protocol imap {
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
mail_plugins = $mail_plugins imap_sieve
}
service imap {
vsz_limit = ${builtins.toString cfg.imapMemoryLimit} MB
}
protocol pop3 {
mail_max_userip_connections = ${toString cfg.maxConnectionsPerUser}
}
mail_access_groups = ${vmailGroupName}
ssl = required
ssl_min_protocol = TLSv1
ssl_prefer_server_ciphers = no
service lmtp {
unix_listener dovecot-lmtp {
group = ${postfixCfg.group}
mode = 0600
user = ${postfixCfg.user}
}
vsz_limit = ${builtins.toString cfg.lmtpMemoryLimit} MB
}
service quota-status {
inet_listener {
port = 0
}
unix_listener quota-status {
user = postfix
}
vsz_limit = ${builtins.toString cfg.quotaStatusMemoryLimit} MB
}
recipient_delimiter = ${cfg.recipientDelimiter}
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/%{user} 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
}
service indexer-worker {
${lib.optionalString (cfg.fullTextSearch.memoryLimit != null) ''
vsz_limit = ${toString (cfg.fullTextSearch.memoryLimit*1024*1024)}
''}
}
lda_mailbox_autosubscribe = yes
lda_mailbox_autocreate = yes
'';
}
(lib.mkIf haveDovecotModulesOption {
modules = dovecotModules;
}) })
]; ];
};
systemd.services.dovecot = { systemd.services.dovecot2 = {
preStart = '' preStart = ''
${genPasswdScript} ${genPasswdScript}
''; '' + (lib.optionalString cfg.ldap.enable setPwdInLdapConfFile);
reloadTriggers = lib.mkIf (!withACME) [
x509CertificateFile
x509PrivateKeyFile
];
serviceConfig = lib.optionalAttrs cfg.ldap.enable {
LoadCredential = [
"ldap-bind-pw:${cfg.ldap.bind.passwordFile}"
];
};
}; };
systemd.services.postfix.restartTriggers = [ systemd.services.postfix.restartTriggers = [ genPasswdScript ] ++ (lib.optional cfg.ldap.enable [setPwdInLdapConfFile]);
genPasswdScript
];
}; };
} }
+5 -13
View File
@@ -14,23 +14,15 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib, ... }:
config,
pkgs,
lib,
...
}:
let let
cfg = config.mailserver; cfg = config.mailserver;
in in
{ {
config = lib.mkIf cfg.enable { config = with cfg; lib.mkIf enable {
environment.systemPackages = [ environment.systemPackages = with pkgs; [
config.services.dovecot2.package dovecot openssh postfix rspamd
pkgs.openssh ] ++ (if certificateScheme == "selfsigned" then [ openssl ] else []);
config.services.postfix.package
config.services.rspamd.package
];
}; };
} }
+1
View File
@@ -24,3 +24,4 @@ in
services.kresd.enable = true; services.kresd.enable = true;
}; };
} }
+10 -11
View File
@@ -20,19 +20,18 @@ let
cfg = config.mailserver; cfg = config.mailserver;
in in
{ {
config = lib.mkIf (cfg.enable && cfg.openFirewall) { config = with cfg; lib.mkIf (enable && openFirewall) {
networking.firewall = { networking.firewall = {
allowedTCPPorts = [ allowedTCPPorts = [ 25 ]
25 ++ lib.optional enableSubmission 587
] ++ lib.optional enableSubmissionSsl 465
++ lib.optional cfg.enableSubmission 587 ++ lib.optional enableImap 143
++ lib.optional cfg.enableSubmissionSsl 465 ++ lib.optional enableImapSsl 993
++ lib.optional cfg.enableImap 143 ++ lib.optional enablePop3 110
++ lib.optional cfg.enableImapSsl 993 ++ lib.optional enablePop3Ssl 995
++ lib.optional cfg.enablePop3 110 ++ lib.optional enableManageSieve 4190
++ lib.optional cfg.enablePop3Ssl 995 ++ lib.optional (certificateScheme == "acme-nginx") 80;
++ lib.optional cfg.enableManageSieve 4190;
}; };
}; };
} }
+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"
];
};
}
+107 -300
View File
@@ -14,140 +14,84 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib, ... }:
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix { with (import ./common.nix { inherit config pkgs lib; });
inherit
config
options
lib
pkgs
;
});
let let
inherit (lib.strings) concatStringsSep; inherit (lib.strings) concatStringsSep;
cfg = config.mailserver; cfg = config.mailserver;
iniFormat = pkgs.formats.iniWithGlobalSection { };
# Merge several lookup tables. A lookup table is a attribute set where # 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 key is an address (user@example.com) or a domain (@example.com)
# - the value is a list of addresses # - the value is a list of addresses
mergeLookupTables = tables: lib.zipAttrsWith (_: v: lib.flatten v) tables; mergeLookupTables = tables: lib.zipAttrsWith (_: v: lib.flatten v) tables;
# valiases_postfix :: Map String [String] # valiases_postfix :: Map String [String]
valiases_postfix = mergeLookupTables ( valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
lib.flatten ( (name: value:
lib.mapAttrsToList ( let to = name;
name: value: in map (from: {"${from}" = to;}) (value.aliases ++ lib.singleton name))
let cfg.loginAccounts));
to = name; regex_valiases_postfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
in (name: value:
map (from: { "${from}" = to; }) (value.aliases ++ lib.singleton name) let to = name;
) cfg.accounts in map (from: {"${from}" = to;}) value.aliasesRegexp)
) cfg.loginAccounts));
);
regex_valiases_postfix = mergeLookupTables (
lib.flatten (
lib.mapAttrsToList (
name: value:
let
to = name;
in
map (from: { "${from}" = to; }) value.aliasesRegexp
) cfg.accounts
)
);
# catchAllPostfix :: Map String [String] # catchAllPostfix :: Map String [String]
catchAllPostfix = mergeLookupTables ( catchAllPostfix = mergeLookupTables (lib.flatten (lib.mapAttrsToList
lib.flatten ( (name: value:
lib.mapAttrsToList ( let to = name;
name: value: in map (from: {"@${from}" = to;}) value.catchAll)
let cfg.loginAccounts));
to = name;
in
map (from: { "@${from}" = to; }) value.catchAll
) cfg.accounts
)
);
# all_valiases_postfix :: Map String [String] # all_valiases_postfix :: Map String [String]
all_valiases_postfix = mergeLookupTables [ all_valiases_postfix = mergeLookupTables [valiases_postfix extra_valiases_postfix];
valiases_postfix
extra_valiases_postfix
];
# attrsToLookupTable :: Map String (Either String [ String ]) -> Map String [String] # attrsToLookupTable :: Map String (Either String [ String ]) -> Map String [String]
attrsToLookupTable = attrsToLookupTable = aliases: let
aliases: lookupTables = lib.mapAttrsToList (from: to: {"${from}" = to;}) aliases;
let in mergeLookupTables lookupTables;
lookupTables = lib.mapAttrsToList (from: to: { "${from}" = to; }) aliases;
in
mergeLookupTables lookupTables;
# extra_valiases_postfix :: Map String [String] # extra_valiases_postfix :: Map String [String]
extra_valiases_postfix = attrsToLookupTable cfg.aliases; extra_valiases_postfix = attrsToLookupTable cfg.extraVirtualAliases;
# forwards :: Map String [String] # forwards :: Map String [String]
forwards = attrsToLookupTable cfg.forwards; forwards = attrsToLookupTable cfg.forwards;
# lookupTableToString :: Map String [String] -> String # lookupTableToString :: Map String [String] -> String
lookupTableToString = lookupTableToString = attrs: let
attrs:
let
valueToString = value: lib.concatStringsSep ", " value; valueToString = value: lib.concatStringsSep ", " value;
in in lib.concatStringsSep "\n" (lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs);
lib.concatStringsSep "\n" (
lib.mapAttrsToList (name: value: "${name} ${valueToString value}") attrs
);
# valiases_file :: Path # valiases_file :: Path
valiases_file = valiases_file = let
let content = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix]);
content = lookupTableToString (mergeLookupTables [ in builtins.toFile "valias" content;
all_valiases_postfix
catchAllPostfix
]);
in
builtins.toFile "valias" content;
regex_valiases_file = regex_valiases_file = let
let
content = lookupTableToString regex_valiases_postfix; content = lookupTableToString regex_valiases_postfix;
in in builtins.toFile "regex_valias" content;
builtins.toFile "regex_valias" content;
# denied_recipients_postfix :: [ String ] # denied_recipients_postfix :: [ String ]
denied_recipients_postfix = map (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}") ( denied_recipients_postfix = (map
lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.accounts) (acct: "${acct.name} REJECT ${acct.sendOnlyRejectMessage}")
); (lib.filter (acct: acct.sendOnly) (lib.attrValues cfg.loginAccounts)));
denied_recipients_file = builtins.toFile "denied_recipients" ( denied_recipients_file = builtins.toFile "denied_recipients" (lib.concatStringsSep "\n" denied_recipients_postfix);
lib.concatStringsSep "\n" denied_recipients_postfix
);
reject_senders_postfix = map ( reject_senders_postfix = (map
sender: (sender:
"${sender} REJECT${ "${sender} REJECT")
lib.optionalString (cfg.rejectSenderMessage != "") " ${cfg.rejectSenderMessage}" (cfg.rejectSender));
}" reject_senders_file = builtins.toFile "reject_senders" (lib.concatStringsSep "\n" (reject_senders_postfix)) ;
) 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 ] # rejectRecipients :: [ Path ]
reject_recipients_file = builtins.toFile "reject_recipients" ( reject_recipients_file = builtins.toFile "reject_recipients" (lib.concatStringsSep "\n" (reject_recipients_postfix)) ;
lib.concatStringsSep "\n" reject_recipients_postfix
);
# vhosts_file :: Path # vhosts_file :: Path
vhosts_file = builtins.toFile "vhosts" (concatStringsSep "\n" cfg.domains); vhosts_file = builtins.toFile "vhosts" (concatStringsSep "\n" cfg.domains);
@@ -159,12 +103,9 @@ let
# every alias is owned (uniquely) by its user. # every alias is owned (uniquely) by its user.
# The user's own address is already in all_valiases_postfix. # The user's own address is already in all_valiases_postfix.
vaccounts_file = builtins.toFile "vaccounts" (lookupTableToString all_valiases_postfix); vaccounts_file = builtins.toFile "vaccounts" (lookupTableToString all_valiases_postfix);
regex_vaccounts_file = builtins.toFile "regex_vaccounts" ( regex_vaccounts_file = builtins.toFile "regex_vaccounts" (lookupTableToString regex_valiases_postfix);
lookupTableToString regex_valiases_postfix
);
submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" ( submissionHeaderCleanupRules = pkgs.writeText "submission_header_cleanup_rules" (''
''
# Removes sensitive headers from mails handed in via the submission port. # Removes sensitive headers from mails handed in via the submission port.
# See https://thomas-leister.de/mailserver-debian-stretch/ # See https://thomas-leister.de/mailserver-debian-stretch/
# Uses "pcre" style regex. # Uses "pcre" style regex.
@@ -174,22 +115,21 @@ let
/^X-Mailer:/ IGNORE /^X-Mailer:/ IGNORE
/^User-Agent:/ IGNORE /^User-Agent:/ IGNORE
/^X-Enigmail:/ IGNORE /^X-Enigmail:/ IGNORE
'' '' + lib.optionalString cfg.rewriteMessageId ''
+ lib.optionalString cfg.rewriteMessageId ''
# Replaces the user submitted hostname with the server's FQDN to hide the # Replaces the user submitted hostname with the server's FQDN to hide the
# user's host or network. # 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" ]; smtpdMilters = [ "unix:/run/rspamd/rspamd-milter.sock" ];
mappedFile = name: "hash:/var/lib/postfix/conf/${name}"; mappedFile = name: "hash:/var/lib/postfix/conf/${name}";
mappedRegexFile = name: "pcre:/var/lib/postfix/conf/${name}"; mappedRegexFile = name: "pcre:/var/lib/postfix/conf/${name}";
submissionOptions = { submissionOptions =
{
smtpd_tls_security_level = "encrypt"; smtpd_tls_security_level = "encrypt";
smtpd_sasl_auth_enable = "yes"; smtpd_sasl_auth_enable = "yes";
smtpd_sasl_type = "dovecot"; smtpd_sasl_type = "dovecot";
@@ -197,9 +137,7 @@ let
smtpd_sasl_security_options = "noanonymous"; smtpd_sasl_security_options = "noanonymous";
smtpd_sasl_local_domain = "$myhostname"; smtpd_sasl_local_domain = "$myhostname";
smtpd_client_restrictions = "permit_sasl_authenticated,reject"; smtpd_client_restrictions = "permit_sasl_authenticated,reject";
smtpd_sender_login_maps = "hash:/etc/postfix/vaccounts${lib.optionalString cfg.ldap.enable ",ldap:${ldapSenderLoginMapFile}"}${ 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"}";
lib.optionalString (regex_valiases_postfix != { }) ",pcre:/etc/postfix/regex_vaccounts"
}";
smtpd_sender_restrictions = "reject_sender_login_mismatch"; smtpd_sender_restrictions = "reject_sender_login_mismatch";
smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject"; smtpd_recipient_restrictions = "reject_non_fqdn_recipient,reject_unknown_recipient_domain,permit_sasl_authenticated,reject";
cleanup_service_name = "submission-header-cleanup"; cleanup_service_name = "submission-header-cleanup";
@@ -209,21 +147,20 @@ let
server_host = ${lib.concatStringsSep " " cfg.ldap.uris} server_host = ${lib.concatStringsSep " " cfg.ldap.uris}
start_tls = ${if cfg.ldap.startTls then "yes" else "no"} start_tls = ${if cfg.ldap.startTls then "yes" else "no"}
version = 3 version = 3
tls_ca_cert_file = ${cfg.ldap.caFile} tls_ca_cert_file = ${cfg.ldap.tlsCAFile}
tls_require_cert = yes tls_require_cert = yes
search_base = ${cfg.ldap.base} search_base = ${cfg.ldap.searchBase}
scope = ${cfg.ldap.scope} scope = ${cfg.ldap.searchScope}
bind = yes bind = yes
bind_dn = ${cfg.ldap.bind.dn} bind_dn = ${cfg.ldap.bind.dn}
''; '';
# Enforce a mapping between SMTP user and envelope sender address
ldapSenderLoginMap = pkgs.writeText "ldap-sender-login-map.cf" '' ldapSenderLoginMap = pkgs.writeText "ldap-sender-login-map.cf" ''
${commonLdapConfig} ${commonLdapConfig}
query_filter = ${cfg.ldap.postfix.filter} 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"; ldapSenderLoginMapFile = "/run/postfix/ldap-sender-login-map.cf";
appendPwdInSenderLoginMap = appendLdapBindPwd { appendPwdInSenderLoginMap = appendLdapBindPwd {
@@ -234,11 +171,10 @@ let
destination = ldapSenderLoginMapFile; destination = ldapSenderLoginMapFile;
}; };
# Check whether a recipient address exists, before accepting mail for it
ldapVirtualMailboxMap = pkgs.writeText "ldap-virtual-mailbox-map.cf" '' ldapVirtualMailboxMap = pkgs.writeText "ldap-virtual-mailbox-map.cf" ''
${commonLdapConfig} ${commonLdapConfig}
query_filter = ${cfg.ldap.postfix.filter} 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"; ldapVirtualMailboxMapFile = "/run/postfix/ldap-virtual-mailbox-map.cf";
appendPwdInVirtualMailboxMap = appendLdapBindPwd { appendPwdInVirtualMailboxMap = appendLdapBindPwd {
@@ -250,66 +186,20 @@ let
}; };
in in
{ {
config = lib.mkIf cfg.enable { config = with cfg; lib.mkIf 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
];
systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable { systemd.services.postfix-setup = lib.mkIf cfg.ldap.enable {
preStart = '' preStart = ''
${appendPwdInVirtualMailboxMap} ${appendPwdInVirtualMailboxMap}
${appendPwdInSenderLoginMap} ${appendPwdInSenderLoginMap}
''; '';
restartTriggers = [ restartTriggers = [ appendPwdInVirtualMailboxMap appendPwdInSenderLoginMap ];
appendPwdInVirtualMailboxMap
appendPwdInSenderLoginMap
];
}; };
services.postfix = { services.postfix = {
enable = true; enable = true;
hostname = "${sendingFqdn}";
networksStyle = "host";
mapFiles."valias" = valiases_file; mapFiles."valias" = valiases_file;
mapFiles."regex_valias" = regex_valiases_file; mapFiles."regex_valias" = regex_valiases_file;
mapFiles."vaccounts" = vaccounts_file; mapFiles."vaccounts" = vaccounts_file;
@@ -317,51 +207,50 @@ in
mapFiles."denied_recipients" = denied_recipients_file; mapFiles."denied_recipients" = denied_recipients_file;
mapFiles."reject_senders" = reject_senders_file; mapFiles."reject_senders" = reject_senders_file;
mapFiles."reject_recipients" = reject_recipients_file; mapFiles."reject_recipients" = reject_recipients_file;
sslCert = certificatePath;
sslKey = keyPath;
enableSubmission = cfg.enableSubmission; enableSubmission = cfg.enableSubmission;
enableSubmissions = cfg.enableSubmissionSsl; enableSubmissions = cfg.enableSubmissionSsl;
virtual = lookupTableToString (mergeLookupTables [ virtual = lookupTableToString (mergeLookupTables [all_valiases_postfix catchAllPostfix forwards]);
all_valiases_postfix
catchAllPostfix
forwards
]);
settings.main = { config = {
myhostname = cfg.sendingFqdn; # Extra Config
mydestination = ""; # disable local mail delivery mydestination = "";
recipient_delimiter = cfg.recipientDelimiter; recipient_delimiter = cfg.recipientDelimiter;
smtpd_banner = "${cfg.fqdn} ESMTP NO UCE"; smtpd_banner = "${fqdn} ESMTP NO UCE";
disable_vrfy_command = true; disable_vrfy_command = true;
message_size_limit = cfg.messageSizeLimit; message_size_limit = toString cfg.messageSizeLimit;
# virtual mail system # virtual mail system
virtual_uid_maps = "static:5000";
virtual_gid_maps = "static:5000";
virtual_mailbox_base = mailDirectory;
virtual_mailbox_domains = vhosts_file; virtual_mailbox_domains = vhosts_file;
virtual_mailbox_maps = [ virtual_mailbox_maps = [
(mappedFile "valias") (mappedFile "valias")
] ] ++ lib.optionals (cfg.ldap.enable) [
++ lib.optionals cfg.ldap.enable [
"ldap:${ldapVirtualMailboxMapFile}" "ldap:${ldapVirtualMailboxMapFile}"
] ] ++ lib.optionals (regex_valiases_postfix != {}) [
++ lib.optionals (regex_valiases_postfix != { }) [
(mappedRegexFile "regex_valias") (mappedRegexFile "regex_valias")
]; ];
virtual_alias_maps = lib.mkAfter ( virtual_alias_maps = lib.mkAfter (lib.optionals (regex_valiases_postfix != {}) [
lib.optionals (regex_valiases_postfix != { }) [
(mappedRegexFile "regex_valias") (mappedRegexFile "regex_valias")
] ]);
);
virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp"; virtual_transport = "lmtp:unix:/run/dovecot2/dovecot-lmtp";
# Avoid leakage of X-Original-To, X-Delivered-To headers between recipients # Avoid leakage of X-Original-To, X-Delivered-To headers between recipients
lmtp_destination_recipient_limit = "1"; lmtp_destination_recipient_limit = "1";
# Opportunistic DANE support
# https://www.postfix.org/postconf.5.html#smtp_tls_security_level
smtp_dns_support_level = "dnssec";
smtp_tls_security_level = "dane";
# sasl with dovecot # sasl with dovecot
smtpd_sasl_type = "dovecot"; smtpd_sasl_type = "dovecot";
smtpd_sasl_path = "/run/dovecot2/auth"; smtpd_sasl_path = "/run/dovecot2/auth";
smtpd_sasl_auth_enable = true; smtpd_sasl_auth_enable = true;
smtpd_relay_restrictions = [ smtpd_relay_restrictions = [
"permit_mynetworks" "permit_mynetworks" "permit_sasl_authenticated" "reject_unauth_destination"
"permit_sasl_authenticated"
"reject_unauth_destination"
]; ];
# reject selected senders # reject selected senders
@@ -373,135 +262,56 @@ in
# reject selected recipients # reject selected recipients
"check_recipient_access ${mappedFile "denied_recipients"}" "check_recipient_access ${mappedFile "denied_recipients"}"
"check_recipient_access ${mappedFile "reject_recipients"}" "check_recipient_access ${mappedFile "reject_recipients"}"
]
++ lib.optionals cfg.quota.enable [
# quota checking # quota checking
"check_policy_service unix:/run/dovecot2/quota-status" "check_policy_service unix:/run/dovecot2/quota-status"
]; ];
# The X509 private key followed by the corresponding certificate # TLS settings, inspired by https://github.com/jeaye/nix-files
smtpd_tls_chain_files = [ # Submission by mail clients is handled in submissionOptions
"${x509PrivateKeyFile}"
"${x509CertificateFile}"
];
# TLS for incoming mail is optional
smtpd_tls_security_level = "may"; smtpd_tls_security_level = "may";
# But required for authentication attempts # Disable obselete protocols
smtpd_tls_auth_only = true; 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 smtp_tls_ciphers = "high";
smtpd_tls_protocols = ">=TLSv1";
smtpd_tls_mandatory_protocols = ">=TLSv1";
# Require ciphersuites that OpenSSL classifies as "High"
smtpd_tls_ciphers = "high"; smtpd_tls_ciphers = "high";
smtp_tls_mandatory_ciphers = "high";
smtpd_tls_mandatory_ciphers = "high"; smtpd_tls_mandatory_ciphers = "high";
# Enable DNSSEC/DANE support for outgoing SMTP connections # Disable deprecated ciphers
# https://www.postfix.org/postconf.5.html#smtp_tls_security_level smtpd_tls_mandatory_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
smtp_dns_support_level = "dnssec"; smtpd_tls_exclude_ciphers = "MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL";
smtp_tls_security_level = "dane"; 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 tls_preempt_cipherlist = true;
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;
# 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 # Log only a summary message on TLS handshake completion
smtp_tls_loglevel = "1"; smtp_tls_loglevel = "1";
smtpd_tls_loglevel = "1"; smtpd_tls_loglevel = "1";
# Configure a non blocking source of randomness
tls_random_source = "dev:/dev/urandom";
smtpd_milters = smtpdMilters; 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/rspamd/rspamd-milter.sock" ];
milter_protocol = "6"; 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_authen}";
# Fix for https://www.postfix.org/smtp-smuggling.html
smtpd_forbid_bare_newline = cfg.smtpdForbidBareNewline;
smtpd_forbid_bare_newline_exclusions = "$mynetworks";
}; };
submissionOptions = submissionOptions; submissionOptions = submissionOptions;
submissionsOptions = submissionOptions; submissionsOptions = submissionOptions;
settings.master = { masterConfig = {
"lmtp" = { "lmtp" = {
# Add headers when delivering, see http://www.postfix.org/smtp.8.html # Add headers when delivering, see http://www.postfix.org/smtp.8.html
# D => Delivered-To, O => X-Original-To, R => Return-Path # D => Delivered-To, O => X-Original-To, R => Return-Path
@@ -513,10 +323,7 @@ in
chroot = false; chroot = false;
maxproc = 0; maxproc = 0;
command = "cleanup"; command = "cleanup";
args = [ args = ["-o" "header_checks=pcre:${submissionHeaderCleanupRules}"];
"-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 # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib, ... }:
config,
pkgs, with lib;
lib,
...
}:
let let
inherit (lib)
optionalString
mkIf
;
cfg = config.mailserver; cfg = config.mailserver;
preexecDefined = cfg.backup.cmdPreexec != null; preexecDefined = cfg.backup.cmdPreexec != null;
@@ -46,8 +38,7 @@ let
${cfg.backup.cmdPostexec} ${cfg.backup.cmdPostexec}
''; '';
postexecString = optionalString postexecDefined "cmd_postexec ${postexecWrapped}"; postexecString = optionalString postexecDefined "cmd_postexec ${postexecWrapped}";
in in {
{
config = mkIf (cfg.enable && cfg.backup.enable) { config = mkIf (cfg.enable && cfg.backup.enable) {
services.rsnapshot = { services.rsnapshot = {
enable = true; enable = true;
@@ -61,7 +52,7 @@ in
retain hourly ${toString cfg.backup.retain.hourly} retain hourly ${toString cfg.backup.retain.hourly}
retain daily ${toString cfg.backup.retain.daily} retain daily ${toString cfg.backup.retain.daily}
retain weekly ${toString cfg.backup.retain.weekly} retain weekly ${toString cfg.backup.retain.weekly}
backup ${cfg.storage.path}/ localhost/ backup ${cfg.mailDirectory}/ localhost/
''; '';
}; };
}; };
+70 -186
View File
@@ -14,145 +14,68 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib, ... }:
config,
pkgs,
lib,
...
}:
let let
cfg = config.mailserver; cfg = config.mailserver;
postfixCfg = config.services.postfix; postfixCfg = config.services.postfix;
rspamdCfg = config.services.rspamd; rspamdCfg = config.services.rspamd;
rspamdSocket = "rspamd.service";
rspamdPkg = config.services.rspamd.package;
rspamdUser = config.services.rspamd.user; rspamdUser = config.services.rspamd.user;
rspamdGroup = config.services.rspamd.group; rspamdGroup = config.services.rspamd.group;
createDkimKeypair = createDkimKeypair = domain: let
{ privateKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.key";
domain, publicKey = "${cfg.dkimKeyDirectory}/${domain}.${cfg.dkimSelector}.txt";
selector, in pkgs.writeShellScript "dkim-keygen-${domain}" ''
type, if [ ! -f "${privateKey}" ]
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 then
${lib.getExe' rspamdPkg "rspamadm"} dkim_keygen ${ ${lib.getExe' pkgs.rspamd "rspamadm"} dkim_keygen \
lib.cli.toCommandLineShellGNU { } { --domain "${domain}" \
inherit --selector "${cfg.dkimSelector}" \
domain --type "${cfg.dkimKeyType}" \
selector --bits ${toString cfg.dkimKeyBits} \
type --privkey "${privateKey}" > "${publicKey}"
bits chmod 0644 "${publicKey}"
privkey echo "Generated key for domain ${domain} and selector ${cfg.dkimSelector}"
;
}
} > "${pubkey}"
chmod 0644 "${pubkey}"
echo "Generated key for domain ${domain} and selector ${selector}"
fi 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;
in in
{ {
config = lib.mkIf cfg.enable { config = with cfg; lib.mkIf enable {
environment.systemPackages = lib.mkBefore [ environment.systemPackages = lib.mkBefore [
(pkgs.runCommand "rspamc-wrapped" (pkgs.runCommand "rspamc-wrapped" {
{
nativeBuildInputs = with pkgs; [ makeWrapper ]; nativeBuildInputs = with pkgs; [ makeWrapper ];
} }''
'' makeWrapper ${pkgs.rspamd}/bin/rspamc $out/bin/rspamc \
makeWrapper ${lib.getExe' rspamdPkg "rspamc"} $out/bin/rspamc \
--add-flags "-h /run/rspamd/worker-controller.sock" --add-flags "-h /run/rspamd/worker-controller.sock"
'' '')
)
]; ];
services.rspamd = { services.rspamd = {
enable = true; enable = true;
debug = cfg.debug.rspamd; inherit debug;
locals = { locals = {
"milter_headers.conf" = { "milter_headers.conf" = { text = ''
text = ''
use = [ "authentication-results" ];
extended_spam_headers = true; extended_spam_headers = true;
''; ''; };
}; "redis.conf" = { text = ''
"redis.conf" = { servers = "${if cfg.redis.port == null
text = '' then
servers = "${
if cfg.redis.port == null then
cfg.redis.address cfg.redis.address
else else
"${cfg.redis.address}:${toString cfg.redis.port}" "${cfg.redis.address}:${toString cfg.redis.port}"}";
}"; '' + (lib.optionalString (cfg.redis.password != null) ''
''
+ (lib.optionalString (cfg.redis.password != null) ''
password = "${cfg.redis.password}"; password = "${cfg.redis.password}";
''); ''); };
}; "classifier-bayes.conf" = { text = ''
"classifier-bayes.conf" = {
text = ''
cache { cache {
backend = "redis"; backend = "redis";
} }
''; ''; };
}; "antivirus.conf" = lib.mkIf cfg.virusScanning { text = ''
"antivirus.conf" = lib.mkIf cfg.virusScanning {
text = ''
clamav { clamav {
action = "reject"; action = "reject";
symbol = "CLAM_VIRUS"; symbol = "CLAM_VIRUS";
@@ -161,70 +84,36 @@ in
servers = "/run/clamav/clamd.ctl"; 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 scan_mime_parts = false; # scan mail as a whole unit, not parts. seems to be needed to work at all
} }
''; ''; };
}; "dkim_signing.conf" = { text = ''
"dkim_signing.conf" = { enabled = ${lib.boolToString cfg.dkimSigning};
text = '' path = "${cfg.dkimKeyDirectory}/$domain.$selector.key";
enabled = ${lib.boolToString cfg.dkim.enable}; selector = "${cfg.dkimSelector}";
# Only sign explicitly configured domains
try_fallback = false;
# Allow for usernames w/o domain part # Allow for usernames w/o domain part
allow_username_mismatch = true; allow_username_mismatch = true
# Don't normalize DKIM key selection for subdomains ''; };
use_esld = false; "dmarc.conf" = { text = ''
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 '' ${lib.optionalString cfg.dmarcReporting.enable ''
reporting { reporting {
enabled = true; enabled = true;
email = "noreply-dmarc@${cfg.systemDomain}"; email = "${cfg.dmarcReporting.email}";
domain = "${cfg.systemDomain}"; domain = "${cfg.dmarcReporting.domain}";
org_name = "${cfg.systemName}"; org_name = "${cfg.dmarcReporting.organizationName}";
from_name = "${cfg.systemName}"; from_name = "${cfg.dmarcReporting.fromName}";
msgid_from = "${cfg.systemDomain}"; msgid_from = "${cfg.dmarcReporting.domain}";
${lib.optionalString (cfg.dmarcReporting.excludeDomains != [ ]) '' ${lib.optionalString (cfg.dmarcReporting.excludeDomains != []) ''
exclude_domains = ${builtins.toJSON cfg.dmarcReporting.excludeDomains}; exclude_domains = ${builtins.toJSON cfg.dmarcReporting.excludeDomains};
''} ''}
}''} }''}
''; ''; };
};
};
overrides = {
"options.inc" = {
text = ''
local_addrs = [::1/128, 127.0.0.0/8]
'';
};
}; };
workers.rspamd_proxy = { workers.rspamd_proxy = {
type = "rspamd_proxy"; type = "rspamd_proxy";
bindSockets = [ bindSockets = [{
{
socket = "/run/rspamd/rspamd-milter.sock"; socket = "/run/rspamd/rspamd-milter.sock";
mode = "0664"; mode = "0664";
} }];
];
count = 1; # Do not spawn too many processes of this type count = 1; # Do not spawn too many processes of this type
extraConfig = '' extraConfig = ''
milter = yes; # Enable milter mode milter = yes; # Enable milter mode
@@ -239,13 +128,11 @@ in
workers.controller = { workers.controller = {
type = "controller"; type = "controller";
count = 1; count = 1;
bindSockets = [ bindSockets = [{
{
socket = "/run/rspamd/worker-controller.sock"; socket = "/run/rspamd/worker-controller.sock";
mode = "0666"; mode = "0666";
} }];
]; includes = [];
includes = [ ];
extraConfig = '' extraConfig = ''
static_dir = "''${WWWDIR}"; # Serve the web UI static assets static_dir = "''${WWWDIR}"; # Serve the web UI static assets
''; '';
@@ -253,10 +140,10 @@ in
}; };
services.redis.servers.rspamd.enable = lib.mkDefault cfg.redis.configureLocally; services.redis.servers.rspamd.enable = lib.mkDefault true;
systemd.tmpfiles.settings."10-rspamd.conf" = { systemd.tmpfiles.settings."10-rspamd.conf" = {
"${cfg.dkim.keyDirectory}" = { "${cfg.dkimKeyDirectory}" = {
d = { d = {
# Create /var/dkim owned by rspamd user/group # Create /var/dkim owned by rspamd user/group
user = rspamdUser; user = rspamdUser;
@@ -277,27 +164,25 @@ in
{ {
SupplementaryGroups = [ config.services.redis.servers.rspamd.group ]; SupplementaryGroups = [ config.services.redis.servers.rspamd.group ];
} }
(lib.optionalAttrs cfg.dkim.enable { (lib.optionalAttrs cfg.dkimSigning {
ExecStartPre = map createDkimKeypair dkimKeysToGenerate; ExecStartPre = map createDkimKeypair cfg.domains;
ReadWritePaths = [ cfg.dkim.keyDirectory ]; ReadWritePaths = [ cfg.dkimKeyDirectory ];
}) })
]; ];
}; };
systemd.services.rspamd-dmarc-reporter = lib.optionalAttrs cfg.dmarcReporting.enable { systemd.services.rspamd-dmarc-reporter = lib.optionalAttrs (cfg.dmarcReporting.enable) {
# Explicitly select yesterday's date to work around broken # Explicitly select yesterday's date to work around broken
# default behaviour when called without a date. # default behaviour when called without a date.
# https://github.com/rspamd/rspamd/issues/4062 # https://github.com/rspamd/rspamd/issues/4062
script = toString [ script = ''
(lib.getExe' rspamdPkg "rspamadm") ${pkgs.rspamd}/bin/rspamadm dmarc_report $(date -d "yesterday" "+%Y%m%d")
"dmarc_report" '';
"$(date -d 'yesterday' '+%Y%m%d')"
];
serviceConfig = { serviceConfig = {
User = "${config.services.rspamd.user}"; User = "${config.services.rspamd.user}";
Group = "${config.services.rspamd.group}"; Group = "${config.services.rspamd.group}";
AmbientCapabilities = [ ]; AmbientCapabilities = [];
CapabilityBoundingSet = ""; CapabilityBoundingSet = "";
DevicePolicy = "closed"; DevicePolicy = "closed";
IPAddressAllow = "localhost"; IPAddressAllow = "localhost";
@@ -318,17 +203,10 @@ in
ProcSubset = "pid"; ProcSubset = "pid";
ProtectSystem = "strict"; ProtectSystem = "strict";
RemoveIPC = true; RemoveIPC = true;
RestrictAddressFamilies = [ RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictNamespaces = true; RestrictNamespaces = true;
RestrictRealtime = true; RestrictRealtime = true;
RestrictSUIDSGID = true; RestrictSUIDSGID = true;
SupplementaryGroups = lib.optionals cfg.redis.configureLocally [
config.services.redis.servers.rspamd.group
];
SystemCallArchitectures = "native"; SystemCallArchitectures = "native";
SystemCallFilter = [ SystemCallFilter = [
"@system-service" "@system-service"
@@ -338,7 +216,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"; description = "Daily delivery of aggregated DMARC reports";
wantedBy = [ wantedBy = [
"timers.target" "timers.target"
@@ -351,6 +229,12 @@ in
}; };
}; };
systemd.services.postfix = {
after = [ rspamdSocket ];
requires = [ rspamdSocket ];
};
users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ]; users.extraUsers.${postfixCfg.user}.extraGroups = [ rspamdCfg.group ];
}; };
} }
+47 -36
View File
@@ -14,61 +14,72 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib, ... }:
config,
options,
pkgs,
lib,
...
}:
with (import ./common.nix {
inherit
config
options
lib
pkgs
;
});
let let
cfg = config.mailserver; cfg = config.mailserver;
certificateDeps = lib.optionals withACME [ certificatesDeps =
"acme-order-renew-${cfg.x509.useACMEHost}.service" if cfg.certificateScheme == "manual" then
]; []
else if cfg.certificateScheme == "selfsigned" then
[ "mailserver-selfsigned-certificate.service" ]
else
[ "acme-finished-${cfg.fqdn}.target" ];
in 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 = { if [[ ! -f $key || ! -f $cert ]]; then
wants = certificateDeps; mkdir -p "${cfg.certificateDirectory}"
after = certificateDeps; (umask 077; "${pkgs.openssl}/bin/openssl" genrsa -out "$key" 2048) &&
preStart = "${pkgs.openssl}/bin/openssl" req -new -key "$key" -x509 -subj "/CN=$fqdn" \
let -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 ( directories = lib.strings.escapeShellArgs (
[ cfg.storage.path ] ++ lib.optional (cfg.indexDir != null) cfg.indexDir [ mailDirectory ]
++ lib.optional (cfg.indexDir != null) cfg.indexDir
); );
in in ''
''
# Create mail directory and set permissions. See # Create mail directory and set permissions. See
# <https://doc.dovecot.org/main/core/config/shared_mailboxes.html#filesystem-permissions-1>. # <https://doc.dovecot.org/main/core/config/shared_mailboxes.html#filesystem-permissions-1>.
# Prevent world-readable paths, even temporarily. # Prevent world-readable paths, even temporarily.
umask 007 umask 007
mkdir -p ${directories} mkdir -p ${directories}
chgrp "${cfg.storage.group}" ${directories} chgrp "${vmailGroupName}" ${directories}
chmod 02770 ${directories} chmod 02770 ${directories}
''; '';
}; };
# Postfix requires dovecot lmtp socket, dovecot auth socket and certificate to work # Postfix requires dovecot lmtp socket, dovecot auth socket and certificate to work
systemd.services.postfix = { systemd.services.postfix = {
wants = certificateDeps; wants = certificatesDeps;
after = [ after = [ "dovecot2.service" ]
"dovecot.service" ++ lib.optional cfg.dkimSigning "rspamd.service"
] ++ certificatesDeps;
++ lib.optional cfg.dkim.enable "rspamd.service" requires = [ "dovecot2.service" ]
++ certificateDeps; ++ lib.optional cfg.dkimSigning "rspamd.service";
requires = [ "dovecot.service" ] ++ lib.optional cfg.dkim.enable "rspamd.service";
}; };
}; };
} }
+78 -38
View File
@@ -14,51 +14,91 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/> # along with this program. If not, see <http://www.gnu.org/licenses/>
{ { config, pkgs, lib, ... }:
config,
lib, with config.mailserver;
...
}:
let let
cfg = config.mailserver; vmail_user = {
in name = vmailUserName;
{ isSystemUser = true;
config = lib.mkIf cfg.enable { 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 # assert that all accounts provide a password
assertions = map (acct: { assertions = (map (acct: {
assertion = assertion = (acct.hashedPassword != null || acct.hashedPasswordFile != null);
lib.length ( message = "${acct.name} must provide either a hashed password or a password hash file";
lib.filter (value: value != null) [ }) (lib.attrValues loginAccounts));
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);
# warn for accounts that specify both password and file # warn for accounts that specify both password and file
warnings = warnings = (map
map (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used") (acct: "${acct.name} specifies both a password hash and hash file; hash file will be used")
( (lib.filter
lib.filter (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null)) ( (acct: (acct.hashedPassword != null && acct.hashedPasswordFile != null))
lib.attrValues cfg.accounts (lib.attrValues loginAccounts)));
)
);
users.groups.${cfg.storage.group} = { # set the vmail gid to a specific value
inherit (cfg.storage) gid; users.groups = {
"${vmailGroupName}" = { gid = vmailUID; };
}; };
users.users.${cfg.storage.owner} = lib.mkForce {
inherit (cfg.storage) # define all users
group users.users = {
uid "${vmail_user.name}" = lib.mkForce vmail_user;
; };
name = cfg.storage.owner;
isSystemUser = true; systemd.services.activate-virtual-mail-users = {
home = cfg.storage.path; wantedBy = [ "multi-user.target" ];
createHome = true; 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,
)
-5
View File
@@ -1,5 +0,0 @@
[tool.ruff.lint]
extend-select = ["ISC"]
[tool.ruff.lint.flake8-implicit-str-concat]
allow-multiline = false
+6 -17
View File
@@ -11,13 +11,12 @@ header = """
""" """
template = """ template = """
({key})=
`````{{option}} {key} `````{{option}} {key}
{description}
{type} {type}
{default} {default}
{example} {example}
{description}
````` `````
""" """
@@ -25,15 +24,11 @@ f = open(sys.argv[1])
options = json.load(f) options = json.load(f)
groups = [ groups = [
"mailserver.accounts", "mailserver.loginAccounts",
"mailserver.x509", "mailserver.certificate",
"mailserver.storage",
"mailserver.dkim", "mailserver.dkim",
"mailserver.srs",
"mailserver.dmarcReporting", "mailserver.dmarcReporting",
"mailserver.tlsrpt",
"mailserver.fullTextSearch", "mailserver.fullTextSearch",
"mailserver.quota",
"mailserver.redis", "mailserver.redis",
"mailserver.ldap", "mailserver.ldap",
"mailserver.monitoring", "mailserver.monitoring",
@@ -57,8 +52,6 @@ def render_option_value(option: Mapping[str, Any], key: str) -> str:
if key not in option: if key not in option:
return "" return ""
value = None
if isinstance(option[key], dict) and "_type" in option[key]: if isinstance(option[key], dict) and "_type" in option[key]:
if option[key]["_type"] == "literalExpression": if option[key]["_type"] == "literalExpression":
# multi-line codeblock # multi-line codeblock
@@ -82,9 +75,7 @@ def render_option_value(option: Mapping[str, Any], key: str) -> str:
else: else:
value = md_literal(text) value = md_literal(text)
assert value is not None return f"- {key}: {value}" # type: ignore
return f"- {key}: {value}"
def print_option(option): def print_option(option):
@@ -99,9 +90,7 @@ def print_option(option):
key=option["name"], key=option["name"],
description=description or "", description=description or "",
type=f"- type: {md_literal(option['type'])}", type=f"- type: {md_literal(option['type'])}",
default=render_option_value(option, "defaultText") default=render_option_value(option, "default"),
if "defaultText" in option
else render_option_value(option, "default"),
example=render_option_value(option, "example"), example=render_option_value(option, "example"),
) )
) )
+3 -14
View File
@@ -12,15 +12,7 @@ RETRY = 100
def _send_mail( def _send_mail(
smtp_host, smtp_host, smtp_port, smtp_username, from_addr, from_pwd, to_addr, subject, starttls
smtp_port,
smtp_username,
from_addr,
from_pwd,
to_addr,
subject,
starttls,
ssl,
): ):
print(f"Sending mail with subject '{subject}'") print(f"Sending mail with subject '{subject}'")
message = "\n".join( message = "\n".join(
@@ -36,10 +28,9 @@ def _send_mail(
) )
retry = RETRY retry = RETRY
smtp_class = smtplib.SMTP_SSL if ssl else smtplib.SMTP
while True: while True:
try: try:
with smtp_class(smtp_host, port=smtp_port) as smtp: with smtplib.SMTP(smtp_host, port=smtp_port) as smtp:
try: try:
if starttls: if starttls:
smtp.starttls() smtp.starttls()
@@ -82,7 +73,7 @@ def _read_mail(
show_body=False, show_body=False,
delete=True, delete=True,
): ):
print(f"Reading mail from {imap_username}") print("Reading mail from {imap_username}")
message = None message = None
@@ -180,7 +171,6 @@ def send_and_read(args):
to_addr=args.to_addr, to_addr=args.to_addr,
subject=subject, subject=subject,
starttls=args.smtp_starttls, starttls=args.smtp_starttls,
ssl=args.smtp_ssl,
) )
_read_mail( _read_mail(
@@ -216,7 +206,6 @@ parser_send_and_read = subparsers.add_parser(
parser_send_and_read.add_argument("--smtp-host", type=str) 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-port", type=str, default=25)
parser_send_and_read.add_argument("--smtp-starttls", action="store_true") 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( parser_send_and_read.add_argument(
"--smtp-username", "--smtp-username",
type=str, type=str,
+6 -5
View File
@@ -1,9 +1,10 @@
(import ( (import
let (
lock = builtins.fromJSON (builtins.readFile ./flake.lock); let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in
in
fetchTarball { fetchTarball {
url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
sha256 = lock.nodes.flake-compat.locked.narHash; sha256 = lock.nodes.flake-compat.locked.narHash;
} }
) { src = ./.; }).shellNix )
{ src = ./.; }
).shellNix
+19 -31
View File
@@ -24,8 +24,7 @@
name = "clamav"; name = "clamav";
nodes = { nodes = {
server = server = { pkgs, ... }:
{ pkgs, ... }:
{ {
imports = [ imports = [
../default.nix ../default.nix
@@ -71,13 +70,10 @@
mailserver = { mailserver = {
enable = true; enable = true;
fqdn = "mail.example.com"; fqdn = "mail.example.com";
domains = [ domains = [ "example.com" "example2.com" ];
"example.com"
"example2.com"
];
virusScanning = true; virusScanning = true;
accounts = { loginAccounts = {
"user1@example.com" = { "user1@example.com" = {
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/"; hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
aliases = [ "postmaster@example.com" ]; aliases = [ "postmaster@example.com" ];
@@ -91,12 +87,10 @@
}; };
environment.etc = { environment.etc = {
"root/eicar.com.txt".text = "X5O!P%@AP[4PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"; "root/eicar.com.txt".text = "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*";
}; };
}; };
client = client = { nodes, pkgs, ... }: let
{ nodes, pkgs, ... }:
let
serverIP = nodes.server.networking.primaryIPAddress; serverIP = nodes.server.networking.primaryIPAddress;
clientIP = nodes.client.networking.primaryIPAddress; clientIP = nodes.client.networking.primaryIPAddress;
grep-ip = pkgs.writeScriptBin "grep-ip" '' grep-ip = pkgs.writeScriptBin "grep-ip" ''
@@ -104,18 +98,13 @@
echo grep '${clientIP}' "$@" >&2 echo grep '${clientIP}' "$@" >&2
exec grep '${clientIP}' "$@" exec grep '${clientIP}' "$@"
''; '';
in in {
{
imports = [ imports = [
./lib/config.nix ./lib/config.nix
]; ];
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
fetchmail fetchmail msmtp procmail findutils grep-ip
msmtp
procmail
findutils
grep-ip
]; ];
environment.etc = { environment.etc = {
"root/.fetchmailrc" = { "root/.fetchmailrc" = {
@@ -144,9 +133,7 @@
password user2 password user2
''; '';
}; };
"root/virus-email".text = "root/virus-email".text = ''
# mail
''
From: User2 <user@example2.com> From: User2 <user@example2.com>
Content-Type: multipart/mixed; Content-Type: multipart/mixed;
boundary="Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607" boundary="Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607"
@@ -182,9 +169,7 @@
X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H* X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*
--Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607-- --Apple-Mail=_2689C63E-FD18-4E4D-8822-54797BDA9607--
''; '';
"root/safe-email".text = "root/safe-email".text = ''
# mail
''
From: User <user@example2.com> From: User <user@example2.com>
To: User1 <user1@example.com> To: User1 <user1@example.com>
Cc: Cc:
@@ -202,16 +187,19 @@
}; };
}; };
testScript = testScript = ''
# python
''
start_all() start_all()
server.wait_for_unit("multi-user.target") server.wait_for_unit("multi-user.target")
client.wait_for_unit("multi-user.target") client.wait_for_unit("multi-user.target")
server.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock") # TODO put this blocking into the systemd units? I am not sure if rspamd already waits for the clamd socket.
server.wait_for_open_unix_socket("/run/clamav/clamd.ctl") server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
server.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/clamav/clamd.ctl < /dev/null; [ $? -eq 124 ]"
)
client.execute("cp -p /etc/root/.* ~/") client.execute("cp -p /etc/root/.* ~/")
client.succeed("mkdir -p ~/mail") client.succeed("mkdir -p ~/mail")
@@ -249,7 +237,7 @@
with subtest("no warnings or errors"): with subtest("no warnings or errors"):
server.fail("journalctl -u postfix | grep -i error >&2") server.fail("journalctl -u postfix | grep -i error >&2")
server.fail("journalctl -u postfix | grep -i warning >&2") server.fail("journalctl -u postfix | grep -i warning >&2")
server.fail("journalctl -u dovecot | grep -i error >&2") server.fail("journalctl -u dovecot2 | grep -i error >&2")
server.fail("journalctl -u dovecot | grep -i warning >&2") server.fail("journalctl -u dovecot2 | grep -i warning >&2")
''; '';
} }
+49 -124
View File
@@ -18,18 +18,14 @@
name = "external"; name = "external";
nodes = { nodes = {
server = server = { pkgs, ... }:
{ pkgs, ... }:
{ {
imports = [ imports = [
../default.nix ../default.nix
./lib/config.nix ./lib/config.nix
]; ];
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [ netcat ];
netcat
openssl
];
virtualisation.memorySize = 1024; virtualisation.memorySize = 1024;
@@ -40,33 +36,21 @@
''; '';
}; };
mailserver = { mailserver = {
enable = true; enable = true;
debug.dovecot = true; # enabled for sieve script logging debug = true;
fqdn = "mail.example.com"; fqdn = "mail.example.com";
domains = [ domains = [ "example.com" "example2.com" ];
"example.com"
"example2.com"
];
rewriteMessageId = true; rewriteMessageId = true;
dkim = { dkimKeyBits = 1535;
defaults.keyLength = 1535; dmarcReporting = {
domains."example2.com".selectors = { enable = true;
"dkim-rsa" = { domain = "example.com";
# rsa 1535 bits via defaults organizationName = "ACME Corp";
}; };
"dkim-ed25519" = {
keyType = "ed25519";
keyLength = null;
};
"dkim-file" = {
keyFile = "/run/rspamd/dkim-test.key";
};
};
};
dmarcReporting.enable = true;
accounts = { loginAccounts = {
"user1@example.com" = { "user1@example.com" = {
hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/"; hashedPassword = "$6$/z4n8AQl6K$kiOkBTWlZfBd7PvF5GsJ8PmPgdZsFGN1jPGZufxxr60PoR0oUsrvzm2oQiflyz5ir9fFJ.d/zKm/NgLXNUsNX/";
aliases = [ "postmaster@example.com" ]; aliases = [ "postmaster@example.com" ];
@@ -81,16 +65,13 @@
}; };
"lowquota@example.com" = { "lowquota@example.com" = {
hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0"; hashedPassword = "$6$u61JrAtuI0a$nGEEfTP5.eefxoScUGVG/Tl0alqla2aGax4oTd85v3j3xSmhv/02gNfSemv/aaMinlv9j/ZABosVKBrRvN5Qv0";
quota = "1K"; quota = "1B";
}; };
}; };
aliases = { extraVirtualAliases = {
"single-alias@example.com" = "user1@example.com"; "single-alias@example.com" = "user1@example.com";
"multi-alias@example.com" = [ "multi-alias@example.com" = [ "user1@example.com" "user2@example.com" ];
"user1@example.com"
"user2@example.com"
];
}; };
enableImap = true; enableImap = true;
@@ -98,17 +79,13 @@
fullTextSearch = { fullTextSearch = {
enable = true; enable = true;
autoIndex = true; autoIndex = true;
fallback = false; # 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";
}; };
}; };
# 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 = client = { nodes, pkgs, ... }: let
{ nodes, pkgs, ... }:
let
serverIP = nodes.server.networking.primaryIPAddress; serverIP = nodes.server.networking.primaryIPAddress;
clientIP = nodes.client.networking.primaryIPAddress; clientIP = nodes.client.networking.primaryIPAddress;
grep-ip = pkgs.writeScriptBin "grep-ip" '' grep-ip = pkgs.writeScriptBin "grep-ip" ''
@@ -121,10 +98,7 @@
echo grep '^Message-ID:.*@mail.example.com>$' "$@" >&2 echo grep '^Message-ID:.*@mail.example.com>$' "$@" >&2
exec grep '^Message-ID:.*@mail.example.com>$' "$@" exec grep '^Message-ID:.*@mail.example.com>$' "$@"
''; '';
test-imap-spam = test-imap-spam = pkgs.writeScriptBin "imap-mark-spam" ''
pkgs.writeScriptBin "imap-mark-spam"
# python
''
#!${pkgs.python3.interpreter} #!${pkgs.python3.interpreter}
import imaplib import imaplib
@@ -151,10 +125,7 @@
imap.close() imap.close()
''; '';
test-imap-ham = test-imap-ham = pkgs.writeScriptBin "imap-mark-ham" ''
pkgs.writeScriptBin "imap-mark-ham"
# python
''
#!${pkgs.python3.interpreter} #!${pkgs.python3.interpreter}
import imaplib import imaplib
@@ -181,10 +152,7 @@
imap.close() imap.close()
''; '';
search = search = pkgs.writeScriptBin "search" ''
pkgs.writeScriptBin "search"
# python
''
#!${pkgs.python3.interpreter} #!${pkgs.python3.interpreter}
import imaplib import imaplib
import sys import sys
@@ -204,21 +172,12 @@
assert needle in repr(response) assert needle in repr(response)
imap.close() imap.close()
''; '';
in in {
{
imports = [ imports = [
./lib/config.nix ./lib/config.nix
]; ];
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
fetchmail fetchmail msmtp procmail findutils grep-ip check-mail-id test-imap-spam test-imap-ham search
msmtp
procmail
findutils
grep-ip
check-mail-id
test-imap-spam
test-imap-ham
search
]; ];
environment.etc = { environment.etc = {
"root/.fetchmailrc" = { "root/.fetchmailrc" = {
@@ -278,9 +237,7 @@
password user1 password user1
''; '';
}; };
"root/email1".text = "root/email1".text = ''
# mail
''
Message-ID: <12345qwerty@host.local.network> Message-ID: <12345qwerty@host.local.network>
From: User2 <user2@example.com> From: User2 <user2@example.com>
To: User1 <user1@example.com> To: User1 <user1@example.com>
@@ -293,9 +250,7 @@
how are you doing today? how are you doing today?
''; '';
"root/email2".text = "root/email2".text = ''
# mail
''
Message-ID: <232323abc@host.local.network> Message-ID: <232323abc@host.local.network>
From: User <user@example2.com> From: User <user@example2.com>
To: User1 <user1@example.com> To: User1 <user1@example.com>
@@ -306,27 +261,11 @@
Hello User1, Hello User1,
how are you doing today? I have this exciting text for you, that helps fill how are you doing today?
your quota.
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 XOXO User1
''; '';
"root/email3".text = "root/email3".text = ''
# mail
''
Message-ID: <asdfghjkl42@host.local.network> Message-ID: <asdfghjkl42@host.local.network>
From: Postmaster <postmaster@example.com> From: Postmaster <postmaster@example.com>
To: Chuck <chuck@example.com> To: Chuck <chuck@example.com>
@@ -340,9 +279,7 @@
I think I may have misconfigured the mail server I think I may have misconfigured the mail server
XOXO Postmaster XOXO Postmaster
''; '';
"root/email4".text = "root/email4".text = ''
# mail
''
Message-ID: <sdfsdf@host.local.network> Message-ID: <sdfsdf@host.local.network>
From: Single Alias <single-alias@example.com> From: Single Alias <single-alias@example.com>
To: User1 <user1@example.com> To: User1 <user1@example.com>
@@ -357,9 +294,7 @@
XOXO User1 aka Single Alias XOXO User1 aka Single Alias
''; '';
"root/email5".text = "root/email5".text = ''
# mail
''
Message-ID: <789asdf@host.local.network> Message-ID: <789asdf@host.local.network>
From: User2 <user2@example.com> From: User2 <user2@example.com>
To: Multi Alias <multi-alias@example.com> To: Multi Alias <multi-alias@example.com>
@@ -374,9 +309,7 @@
XOXO User1 XOXO User1
''; '';
"root/email6".text = "root/email6".text = ''
# mail
''
Message-ID: <123457qwerty@host.local.network> Message-ID: <123457qwerty@host.local.network>
From: User2 <user2@example.com> From: User2 <user2@example.com>
To: User1 <user1@example.com> To: User1 <user1@example.com>
@@ -390,9 +323,7 @@
this email contains the needle: this email contains the needle:
576a4565b70f5a4c1a0925cabdb587a6 576a4565b70f5a4c1a0925cabdb587a6
''; '';
"root/email7".text = "root/email7".text = ''
# mail
''
Message-ID: <1234578qwerty@host.local.network> Message-ID: <1234578qwerty@host.local.network>
From: User2 <user2@example.com> From: User2 <user2@example.com>
To: User1 <user1@example.com> To: User1 <user1@example.com>
@@ -409,18 +340,16 @@
}; };
}; };
testScript = testScript = ''
# python
''
start_all() start_all()
server.wait_for_unit("multi-user.target") server.wait_for_unit("multi-user.target")
client.wait_for_unit("multi-user.target") client.wait_for_unit("multi-user.target")
server.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock") # TODO put this blocking into the systemd units?
server.wait_until_succeeds(
server.succeed("rspamadm dkim_keygen > /run/rspamd/dkim-test.key") "set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
server.succeed("chown rspamd: /run/rspamd/dkim-test.key") )
client.execute("cp -p /etc/root/.* ~/") client.execute("cp -p /etc/root/.* ~/")
client.succeed("mkdir -p ~/mail") client.succeed("mkdir -p ~/mail")
@@ -457,10 +386,10 @@
with subtest("dkim has user-specified size"): with subtest("dkim has user-specified size"):
server.succeed( 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/*") client.execute("rm ~/mail/*")
# send email from user2 to user1 # send email from user2 to user1
client.succeed( client.succeed(
@@ -471,9 +400,7 @@
client.succeed("fetchmail --nosslcertck -v") client.succeed("fetchmail --nosslcertck -v")
client.succeed("cat ~/mail/* >&2") client.succeed("cat ~/mail/* >&2")
# make sure it is dkim signed # make sure it is dkim signed
client.succeed("grep 's=dkim-rsa' ~/mail/*") client.succeed("grep DKIM-Signature: ~/mail/*")
client.succeed("grep 's=dkim-ed25519' ~/mail/*")
client.succeed("grep 's=dkim-file' ~/mail/*")
with subtest("aliases"): with subtest("aliases"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
@@ -485,9 +412,9 @@
# fetchmail returns EXIT_CODE 0 when it retrieves mail # fetchmail returns EXIT_CODE 0 when it retrieves mail
client.succeed("fetchmail --nosslcertck -v") client.succeed("fetchmail --nosslcertck -v")
with subtest("domain catch-all"): with subtest("catchAlls"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from chuck to non-existent account # send email from chuck to non exsitent account
client.succeed( 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"
) )
@@ -502,10 +429,10 @@
) )
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
# fetchmail returns EXIT_CODE 1 when no new mail # 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") client.fail("fetchmail --nosslcertck -v")
with subtest("Test sending from alias address (mailserver.aliases)"): with subtest("extraVirtualAliases"):
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
# send email from single-alias to user1 # send email from single-alias to user1
client.succeed( client.succeed(
@@ -528,8 +455,6 @@
client.execute("rm ~/mail/*") client.execute("rm ~/mail/*")
client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc") client.execute("mv ~/.fetchmailRcLowQuota ~/.fetchmailrc")
server.log(server.succeed("doveadm quota get -u lowquota@example.com"))
client.succeed( 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"
) )
@@ -546,9 +471,9 @@
server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]') server.wait_until_fails('[ "$(postqueue -p)" != "Mail queue is empty" ]')
client.succeed("imap-mark-spam >&2") 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 rspamd-learn-spam.sh >&2")
client.succeed("imap-mark-ham >&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 rspamd-learn-ham.sh >&2")
with subtest("full text search and indexation"): with subtest("full text search and indexation"):
# send 2 email from user2 to user1 # send 2 email from user2 to user1
@@ -566,9 +491,9 @@
# should fail because this folder is not indexed # should fail because this folder is not indexed
client.fail("search Junk a >&2") client.fail("search Junk a >&2")
# check that search really goes through the indexer # 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 'fts-flatcurve(INBOX): Query ' >&2")
# check that Junk is not indexed # check that Junk is not indexed
server.fail("journalctl -u dovecot | grep 'fts-flatcurve(JUNK): Indexing ' >&2") server.fail("journalctl -u dovecot2 | grep 'fts-flatcurve(JUNK): Indexing ' >&2")
with subtest("dmarc reporting"): with subtest("dmarc reporting"):
server.systemctl("start rspamd-dmarc-reporter.service") server.systemctl("start rspamd-dmarc-reporter.service")
@@ -576,10 +501,10 @@
with subtest("no warnings or errors"): with subtest("no warnings or errors"):
server.fail("journalctl -u postfix | grep -i error >&2") server.fail("journalctl -u postfix | grep -i error >&2")
server.fail("journalctl -u postfix | grep -i warning >&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 -v 'imap-login: Debug: SSL error: Connection closed' | grep -i error >&2")
# harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html # harmless ? https://dovecot.org/pipermail/dovecot/2020-August/119575.html
server.fail( server.fail(
"journalctl -u dovecot | \ "journalctl -u dovecot2 | \
grep -v 'Expunged message reappeared, giving a new UID' | \ grep -v 'Expunged message reappeared, giving a new UID' | \
grep -v 'Time moved forwards' | \ grep -v 'Time moved forwards' | \
grep -i warning >&2" grep -i warning >&2"
+20 -140
View File
@@ -30,39 +30,20 @@ let
''; '';
}; };
hashPassword = hashPassword = password: pkgs.runCommand
password: "password-${password}-hashed"
pkgs.runCommand "password-${password}-hashed" { buildInputs = [ pkgs.mkpasswd ]; inherit password; } ''
{ mkpasswd -sm bcrypt <<<"$password" > $out
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
''; '';
hashedPasswordFile = hashPassword "my-password"; hashedPasswordFile = hashPassword "my-password";
hashedPasswordFileWithScheme = hashPasswordWithScheme "my-password";
passwordFile = pkgs.writeText "password" "my-password"; passwordFile = pkgs.writeText "password" "my-password";
in in
{ {
name = "internal"; name = "internal";
nodes = { nodes = {
machine = machine = { pkgs, ... }: {
{ pkgs, lib, ... }:
{
imports = [ imports = [
./../default.nix ./../default.nix
./lib/config.nix ./lib/config.nix
@@ -74,47 +55,25 @@ in
(pkgs.writeScriptBin "mail-check" '' (pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
'') '')
] ] ++ (with pkgs; [
++ (with pkgs; [
curl curl
openssl openssl
netcat netcat
]); ]);
systemd.tmpfiles.settings."mailserver-test-passwords" = {
"/run/passwords/user3" = {
f = {
argument = "my-password";
mode = "0600";
};
};
};
systemd.services.dovecot.serviceConfig.CacheDirectory = "dovecot";
mailserver = { mailserver = {
enable = true; enable = true;
fqdn = "mail.example.com"; fqdn = "mail.example.com";
domains = [ domains = [ "example.com" "domain.com" ];
"example.com"
"domain.com"
];
localDnsResolver = false; localDnsResolver = false;
accounts = { loginAccounts = {
"user1@example.com" = { "user1@example.com" = {
hashedPasswordFile = hashedPasswordFile; hashedPasswordFile = hashedPasswordFile;
}; };
"user2@example.com" = { "user2@example.com" = {
hashedPasswordFile = hashedPasswordFile; hashedPasswordFile = hashedPasswordFile;
aliasesRegexp = [ ''/^user2.*@domain\.com$/'' ]; 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" = { "send-only@example.com" = {
hashedPasswordFile = hashPassword "send-only"; hashedPasswordFile = hashPassword "send-only";
@@ -127,33 +86,20 @@ in
"user2@example.com" = "user1@example.com"; "user2@example.com" = "user1@example.com";
}; };
storage = { vmailGroupName = "vmail";
gid = 5000; vmailUID = 5000;
group = "vmail";
};
indexDir = "/var/cache/dovecot/fts";
enableImap = false; enableImap = false;
}; };
}; };
}; };
testScript = testScript = ''
{
nodes,
...
}:
# python
''
machine.start() machine.start()
machine.wait_for_unit("multi-user.target") machine.wait_for_unit("multi-user.target")
machine.wait_for_unit("dovecot.service")
machine.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
# Regression test for https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues/205 # Regression test for https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/issues/205
with subtest("mail forwarded can are locally kept"): 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 # A mail sent to user2@example.com is in the user1@example.com mailbox
machine.succeed( machine.succeed(
" ".join( " ".join(
[ [
@@ -171,13 +117,13 @@ in
] ]
) )
) )
# A mail sent to user2@example.com via implicit TLS is in the user2@example.com mailbox # A mail sent to user2@example.com is in the user2@example.com mailbox
machine.succeed( machine.succeed(
" ".join( " ".join(
[ [
"mail-check send-and-read", "mail-check send-and-read",
"--smtp-port 465", "--smtp-port 587",
"--smtp-ssl", "--smtp-starttls",
"--smtp-host localhost", "--smtp-host localhost",
"--imap-host localhost", "--imap-host localhost",
"--imap-username user2@example.com", "--imap-username user2@example.com",
@@ -191,7 +137,7 @@ in
) )
with subtest("regex email alias are received"): 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 # A mail sent to user2-regex-alias@domain.com is in the user2@example.com mailbox
machine.succeed( machine.succeed(
" ".join( " ".join(
[ [
@@ -211,14 +157,13 @@ in
) )
with subtest("user can send from regex email alias"): with subtest("user can send from regex email alias"):
# A mail sent to user1@example.com from user2-regex-alias@domain.com by # A mail sent from user2-regex-alias@domain.com, using user2@example.com credentials is received
# user2@example.com via implicit TLS is in the user1@example.com mailbox
machine.succeed( machine.succeed(
" ".join( " ".join(
[ [
"mail-check send-and-read", "mail-check send-and-read",
"--smtp-port 465", "--smtp-port 587",
"--smtp-ssl", "--smtp-starttls",
"--smtp-host localhost", "--smtp-host localhost",
"--imap-host localhost", "--imap-host localhost",
"--smtp-username user2@example.com", "--smtp-username user2@example.com",
@@ -234,12 +179,6 @@ in
with subtest("vmail gid is set correctly"): with subtest("vmail gid is set correctly"):
machine.succeed("getent group vmail | grep 5000") machine.succeed("getent group vmail | grep 5000")
with subtest("Check dovecot maildir and index locations"):
# If these paths change we need a migration
machine.succeed("doveadm user -f home user1@example.com | grep ${nodes.machine.mailserver.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("mail to send only accounts is rejected"): with subtest("mail to send only accounts is rejected"):
machine.wait_for_open_port(25) machine.wait_for_open_port(25)
# TODO put this blocking into the systemd units # TODO put this blocking into the systemd units
@@ -255,65 +194,6 @@ in
"set +o pipefail; curl --unix-socket /run/rspamd/worker-controller.sock http://localhost/ | grep -q '<body>'" "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"): with subtest("imap port 143 is closed and imaps is serving SSL"):
machine.wait_for_closed_port(143) machine.wait_for_closed_port(143)
machine.wait_for_open_port(993) machine.wait_for_open_port(993)
+46 -207
View File
@@ -1,42 +1,29 @@
{
pkgs,
...
}:
let let
hashPassword =
password:
pkgs.runCommand "password-${password}-hashed"
{
buildInputs = [ pkgs.mkpasswd ];
inherit password;
}
''
mkpasswd -s <<<"$password" > $out
'';
bindPassword = "unsafegibberish"; bindPassword = "unsafegibberish";
alicePassword = "testalice"; alicePassword = "testalice";
bobPassword = "testbob"; bobPassword = "testbob";
carolPassword = "testcarol";
malloryPassword = "testmallory";
in in
{ {
name = "ldap"; name = "ldap";
nodes = { nodes = {
machine = machine = { pkgs, ... }: {
{ pkgs, lib, ... }:
{
imports = [ imports = [
../default.nix ./../default.nix
./lib/config.nix ./lib/config.nix
]; ];
virtualisation.memorySize = 1024;
services.openssh = {
enable = true;
settings.PermitRootLogin = "yes";
};
environment.systemPackages = [ environment.systemPackages = [
(pkgs.writeScriptBin "mail-check" '' (pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
'') '')];
];
environment.etc.bind-password.text = bindPassword; environment.etc.bind-password.text = bindPassword;
@@ -63,9 +50,7 @@ in
}; };
}; };
}; };
declarativeContents."dc=example" = declarativeContents."dc=example" = ''
#ldif
''
dn: dc=example dn: dc=example
objectClass: domain objectClass: domain
dc: example dc: example
@@ -75,85 +60,33 @@ in
objectClass: simpleSecurityObject objectClass: simpleSecurityObject
objectClass: top objectClass: top
cn: mail cn: mail
# unsafegibberish userPassword: ${bindPassword}
userPassword: {SSHA}JNr6l3s/RHo1LKRXqFsJg8sXznyRid8L
dn: ou=users,dc=example dn: ou=users,dc=example
objectClass: organizationalUnit objectClass: organizationalUnit
ou: users ou: users
dn: cn=alice,ou=users,dc=example dn: cn=alice,ou=users,dc=example
entryUUID: c52f777b-a6e8-4507-80f9-c4de47e8520d
objectClass: inetOrgPerson objectClass: inetOrgPerson
uid: alice cn: alice
sn: Foo sn: Foo
mail: alice@example.com mail: alice@example.com
# testalice userPassword: ${alicePassword}
userPassword: {SSHA}gkJq4Dm4jfIKjxviR0WD63wMt0Ti6zMB
dn: cn=bob,ou=users,dc=example dn: cn=bob,ou=users,dc=example
entryUUID: f3b4e8ea-087f-42cc-95f0-cbfd99386092
objectClass: inetOrgPerson objectClass: inetOrgPerson
objectClass: posixAccount cn: bob
uid: bob
uidNumber: 9999
gidNumber: 9999
sn: Bar sn: Bar
mail: bob@example.com mail: bob@example.com
homeDirectory: /home/bob userPassword: ${bobPassword}
# 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 = { mailserver = {
enable = true; enable = true;
fqdn = "mail.example.com"; fqdn = "mail.example.com";
domains = [ "example.com" ]; domains = [ "example.com" ];
localDnsResolver = false; 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 = { ldap = {
enable = true; enable = true;
@@ -164,83 +97,35 @@ in
dn = "cn=mail,dc=example"; dn = "cn=mail,dc=example";
passwordFile = "/etc/bind-password"; passwordFile = "/etc/bind-password";
}; };
base = "ou=users,dc=example"; searchBase = "ou=users,dc=example";
scope = "sub"; searchScope = "sub";
attributes = {
# disable auth bind
password = "userPassword";
};
}; };
forwards = { forwards = {
"bob_fw@example.com" = "bob@example.com"; "bob_fw@example.com" = "bob@example.com";
}; };
};
specialisation.auth_bind = { vmailGroupName = "vmail";
inheritParentConfig = true; vmailUID = 5000;
configuration = {
mailserver = {
ldap = {
attributes = {
# enable auth bind
password = lib.mkForce null;
};
};
};
services.openldap.settings.children = { enableImap = false;
"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
''
];
}; };
}; };
}; };
}; testScript = ''
};
};
};
testScript =
{
nodes,
...
}:
# python
''
import sys import sys
import re import re
machine.start() machine.start()
machine.wait_for_unit("multi-user.target") machine.wait_for_unit("multi-user.target")
# if the schema is broken, fail fast. helps during development.
machine.wait_for_unit("openldap.service")
machine.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock")
# This function retrieves the ldap table file from a postconf # This function retrieves the ldap table file from a postconf
# command. # command.
# A key lookup is achieved and the returned value is compared # A key lookup is achived and the returned value is compared
# to the expected value. # to the expected value.
def test_lookup(postconf_cmdline, key, expected): def test_lookup(postconf_cmdline, key, expected):
conf = machine.succeed(postconf_cmdline).rstrip() conf = machine.succeed(postconf_cmdline).rstrip()
ldap_table_path_match = re.match('.* =.*ldap:(.*)', conf) ldap_table_path = re.match('.* =.*ldap:(.*)', conf).group(1)
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() value = machine.succeed(f"postmap -q {key} ldap:{ldap_table_path}").rstrip()
try: try:
assert value == expected assert value == expected
@@ -249,55 +134,46 @@ in
raise raise
with subtest("Test postmap lookups"): with subtest("Test postmap lookups"):
test_lookup("postconf virtual_mailbox_maps", "alice@example.com", "alice") 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") test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "alice@example.com", "alice@example.com")
test_lookup("postconf virtual_mailbox_maps", "bob@example.com", "bob") 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") test_lookup("postconf -P submission/inet/smtpd_sender_login_maps", "bob@example.com", "bob@example.com")
with subtest("Test doveadm lookups"): with subtest("Test doveadm lookups"):
machine.succeed("doveadm user -u alice@example.com") machine.succeed("doveadm user -u alice@example.com")
machine.succeed("doveadm user -u bob@example.com") machine.succeed("doveadm user -u bob@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"): 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/postfix/*.cf | grep -e '-rw------- 1 root root'")
machine.succeed("ls -l /run/dovecot2/dovecot-ldap.conf.ext | grep -e '-rw------- 1 root root'")
with subtest("Test account/mail address binding via explicit TLS"): with subtest("Test account/mail address binding"):
machine.fail(" ".join([ machine.fail(" ".join([
"mail-check send-and-read", "mail-check send-and-read",
"--smtp-port 587", "--smtp-port 587",
"--smtp-starttls", "--smtp-starttls",
"--smtp-host localhost", "--smtp-host localhost",
"--smtp-username alice", "--smtp-username alice@example.com",
"--imap-host localhost", "--imap-host localhost",
"--imap-username bob", "--imap-username bob@example.com",
"--from-addr bob@example.com", "--from-addr bob@example.com",
"--to-addr aliceb@example.com", "--to-addr aliceb@example.com",
"--src-password-file <(echo '${alicePassword}')", "--src-password-file <(echo '${alicePassword}')",
"--dst-password-file <(echo '${bobPassword}')", "--dst-password-file <(echo '${bobPassword}')",
"--ignore-dkim-spf" "--ignore-dkim-spf"
])) ]))
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user alice'") machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user alice@example.com'")
with subtest("Test mail delivery via implicit TLS"): with subtest("Test mail delivery"):
machine.succeed(" ".join([ machine.succeed(" ".join([
"mail-check send-and-read", "mail-check send-and-read",
"--smtp-port 465", "--smtp-port 587",
"--smtp-ssl", "--smtp-starttls",
"--smtp-host localhost", "--smtp-host localhost",
"--smtp-username alice", "--smtp-username alice@example.com",
"--imap-host localhost", "--imap-host localhost",
"--imap-username bob", "--imap-username bob@example.com",
"--from-addr alice@example.com", "--from-addr alice@example.com",
"--to-addr bob@example.com", "--to-addr bob@example.com",
"--src-password-file <(echo '${alicePassword}')", "--src-password-file <(echo '${alicePassword}')",
@@ -305,15 +181,15 @@ in
"--ignore-dkim-spf" "--ignore-dkim-spf"
])) ]))
with subtest("Test mail forwarding via explicit TLS works"): with subtest("Test mail forwarding works"):
machine.succeed(" ".join([ machine.succeed(" ".join([
"mail-check send-and-read", "mail-check send-and-read",
"--smtp-port 587", "--smtp-port 587",
"--smtp-starttls", "--smtp-starttls",
"--smtp-host localhost", "--smtp-host localhost",
"--smtp-username alice", "--smtp-username alice@example.com",
"--imap-host localhost", "--imap-host localhost",
"--imap-username bob", "--imap-username bob@example.com",
"--from-addr alice@example.com", "--from-addr alice@example.com",
"--to-addr bob_fw@example.com", "--to-addr bob_fw@example.com",
"--src-password-file <(echo '${alicePassword}')", "--src-password-file <(echo '${alicePassword}')",
@@ -321,13 +197,13 @@ in
"--ignore-dkim-spf" "--ignore-dkim-spf"
])) ]))
with subtest("Test cannot send mail via implicit TLS from forwarded address"): with subtest("Test cannot send mail from forwarded address"):
machine.fail(" ".join([ machine.fail(" ".join([
"mail-check send-and-read", "mail-check send-and-read",
"--smtp-port 465", "--smtp-port 587",
"--smtp-ssl", "--smtp-starttls",
"--smtp-host localhost", "--smtp-host localhost",
"--smtp-username bob", "--smtp-username bob@example.com",
"--imap-host localhost", "--imap-host localhost",
"--imap-username alice@example.com", "--imap-username alice@example.com",
"--from-addr bob_fw@example.com", "--from-addr bob_fw@example.com",
@@ -336,44 +212,7 @@ in
"--dst-password-file <(echo '${alicePassword}')", "--dst-password-file <(echo '${alicePassword}')",
"--ignore-dkim-spf" "--ignore-dkim-spf"
])) ]))
machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user bob'") machine.succeed("journalctl -u postfix | grep -q 'Sender address rejected: not owned by user bob@example.com'")
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}'")
''; '';
} }
-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, security.dhparams.defaultBitSize = 2048; # minimum size required by dovecot
...
}:
{
# 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;
};
} }
-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";
}
+27
View File
@@ -0,0 +1,27 @@
# 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/>
{
name = "minimal";
nodes.machine = {
imports = [ ./../default.nix ];
};
testScript = ''
machine.wait_for_unit("multi-user.target");
'';
}
+26 -52
View File
@@ -1,33 +1,22 @@
# This tests is used to test features requiring several mail domains. # This tests is used to test features requiring several mail domains.
{ {
lib,
pkgs, pkgs,
... ...
}: }:
let let
hashPassword = hashPassword = password: pkgs.runCommand
password: "password-${password}-hashed"
pkgs.runCommand "password-${password}-hashed" { buildInputs = [ pkgs.mkpasswd ]; inherit password; }
{
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 = domainGenerator = domain: { pkgs, ... }: {
domain: imports = [../default.nix];
{ pkgs, ... }:
{
imports = [
../default.nix
./lib/config.nix
];
environment.systemPackages = with pkgs; [ netcat ]; environment.systemPackages = with pkgs; [ netcat ];
virtualisation.memorySize = 1024; virtualisation.memorySize = 1024;
mailserver = { mailserver = {
@@ -35,7 +24,7 @@ let
fqdn = "mail.${domain}"; fqdn = "mail.${domain}";
domains = [ domain ]; domains = [ domain ];
localDnsResolver = false; localDnsResolver = false;
accounts = { loginAccounts = {
"user@${domain}" = { "user@${domain}" = {
hashedPasswordFile = hashPassword "password"; hashedPasswordFile = hashPassword "password";
}; };
@@ -45,14 +34,8 @@ let
}; };
services.dnsmasq = { services.dnsmasq = {
enable = true; enable = true;
settings.mx-host = [ settings.mx-host = [ "domain1.com,domain1,10" "domain2.com,domain2,10" ];
"domain1.com,domain1,10"
"domain2.com,domain2,10"
];
}; };
# breaks the test, due to running into DNS timeouts
services.postfix-tlspol.configurePostfix = lib.mkForce false;
}; };
in in
@@ -61,55 +44,46 @@ in
name = "multiple"; name = "multiple";
nodes = { nodes = {
domain1 = domain1 = {...}: {
{ ... }:
{
imports = [ imports = [
../default.nix ../default.nix
(domainGenerator "domain1.com") (domainGenerator "domain1.com")
]; ];
mailserver.forwards = { mailserver.forwards = {
"non-local@domain1.com" = [ "non-local@domain1.com" = ["user@domain2.com" "user@domain1.com"];
"user@domain2.com" "non@domain1.com" = ["user@domain2.com" "user@domain1.com"];
"user@domain1.com"
];
"non@domain1.com" = [
"user@domain2.com"
"user@domain1.com"
];
}; };
}; };
domain2 = domainGenerator "domain2.com"; domain2 = domainGenerator "domain2.com";
client = client = { pkgs, ... }: {
{ pkgs, ... }:
{
environment.systemPackages = [ environment.systemPackages = [
(pkgs.writeScriptBin "mail-check" '' (pkgs.writeScriptBin "mail-check" ''
${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@ ${pkgs.python3}/bin/python ${../scripts/mail-check.py} $@
'') '')];
];
}; };
}; };
testScript = testScript = ''
# python
''
start_all() start_all()
for domain in [domain1, domain2]: domain1.wait_for_unit("multi-user.target")
domain.wait_for_unit("multi-user.target") domain2.wait_for_unit("multi-user.target")
domain.wait_for_unit("dovecot.service")
for host in [domain1, domain2]: # TODO put this blocking into the systemd units?
host.wait_for_open_unix_socket("/run/rspamd/rspamd-milter.sock") domain1.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
domain2.wait_until_succeeds(
"set +e; timeout 1 nc -U /run/rspamd/rspamd-milter.sock < /dev/null; [ $? -eq 124 ]"
)
# user@domain1.com sends a mail to user@domain2.com via explicit TLS # user@domain1.com sends a mail to user@domain2.com
client.succeed( 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" "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 # Send a mail to the address forwarded and check it is in the recipient mailbox
client.succeed( 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" "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"
) )
''; '';
} }